Technical Guide
Table of Contents
- 1. Integration Service Implementation
- 2. Frontend (LWR) Component Logic
- 2.1
c-skill-network(Visualization) - 2.2
c-resume-builder(Resume Generation) - 2.3
c-testimonial-submit(Social Proof) - 2.4
c-schema-visualizer(Dynamic ERD Visualizer) - 2.5
c-smart-checklist(Executable Governance) - 2.6
c-footer(Glass Box Telemetry) - 2.7 Architectural Decision Record: ADR-014 (Deferred Telemetry)
- 2.8 Analytics Instrumentation (GA4)
- 2.9 Site Theme, Tokens and Fonts
- 2.10 Project Asset Gallery and Decision Matrix
- 2.11 Mobile Performance
- 2.12 Home Page
- 2.13 Cloudflare Cache Warmer Worker
- 2.14 View Source (
c-view-source) - 2.15 API Lab and API Reference
- 2.16 Contact Form and Submission Guard
- 2.17 Docs Page (
c-docs-viewer) - 2.18 Page Intro (
c-page-intro) - 2.19 By the Numbers (
c-by-the-numbers) - 2.20 The 10 Minute Tour (
c-tour-bar) - 2.21 Native Form Controls (
c/nativeControls) - 2.22 The Cover Letter Page and PDF
- 2.23 Storage Snapshot and Site Usage
- 2.24 Live Data Marker (
c-data-source) - 2.25 The Static Build and the Shims
- 2.1
- 3. Quality Assurance Checklist
Project: Salesforce Platform Architect Portfolio
Version: 1.0
Owner: Ryan Bumstead
Date: MVP – Q1 2027
1. Integration Service Implementation
All external API calls originate from a secure Named Credential accessed via Apex, with no client-side access tokens used.
1.1 GitHub Integration (Live Feed)
- Mechanism: Uses the
GitHub_APINamed Credential, authenticated by theGitHub_AuthExternal Credential (a read-only token in theGitHub_Principalprincipal). Authenticated calls get 5,000 requests an hour. - Caching Strategy (ADR-007):
IntegrationCacheRefreshSchedulerruns at :00, :15, :30 and :45 and enqueuesGitHubCacheRefreshJob(Queueable,Database.AllowsCallouts, because scheduled Apex cannot call out synchronously).- The job reads the latest commits on
mainand inserts one row into theGithub_Cache__ccustom object, with the mapped commit list inJSON_Payload__c. - The same scheduled run also enqueues
SourceCacheRefreshJobwhen a View source file is due, which fillsSource_Cache__cfrom the GitHub contents API (section 2.14). It needs no schedule of its own. - Guests never call out. The
c-changelogcomponent reads the newest row throughDeliveryController.getRecentCommits(simulate).
- Degraded Mode:
- A failed refresh inserts nothing, so the last good list keeps showing with its age.
- Three consecutive failures open the
githubcircuit breaker for 30 minutes, refreshes are skipped while it is open, andDeliveryControllerreportsDEGRADED. - The cleanup batch keeps the newest row per cache object, so an outage never empties the page.
- Logging. When no
Portfolio_Config__mdtrecord names a repository, and when a refresh fails,GitHubCacheRefreshJob.refreshwrites one line throughPortfolioLog.info(section 1.5) and returns without writing a cache row. The failure line names only the exception type, and the failure still counts toward thegithubbreaker.
1.2 Jira Integration (Roadmap)
- Authentication: Basic Auth through the
Jira_APINamed Credential (Jira_AuthExternal Credential,Jira_Principalprincipal). - Refresh:
JiraCacheRefreshJob, enqueued by the same scheduler, runs the JQLproject = <key> AND labels = "portfolio-roadmap" ORDER BY updated DESC, where the key isPortfolio_Config__mdt.Jira_Project_Key__c(SPAP), and inserts oneJira_Cache__crow from up to 100 issues.JiraService.fetchRoadmapposts to/rest/api/3/search/jql, followsnextPageTokenand stops atMAX_RESULTS(100) issues, the last page orMAX_PAGES(5) requests, whichever comes first. Only issues labelledportfolio-roadmapbecome public (my decision, 2026-10-21). -
Component: c-roadmap-viewerreads the newest row throughDeliveryController.getRoadmap(simulate)and places issues in To Do, In Progress and Done by Jira status category, so a renamed workflow status cannot empty a column. It never calls Jira. Cards name their epic as “Part of {name}{key}” and epic cards come first in each column. Every column shows at most 8 cards (the Roadmap_Column_Limitsetting, section 1.6, sent ascolumnLimit) with its own “Show all N” button and state, which keepsaria-expandedand the count in the column heading. Done shows its newest 8 byupdatedAt, and To Do and In Progress keep the feed order and cut at 8. Earlier only Done was capped. I chose to cap every column, which replaced the test that pinned the Done only rule. A View source button in the roadmap header opensroadmapViewer.js,roadmapViewer.htmlandDeliveryController.cls(section 2.14). - Outage before and after. While the feed’s status is
SIMULATED,c-roadmap-viewerandc-changelogeach placec-outage-compareabove their content (showCompare). It does not show forDEGRADED, a real open breaker. It is a static, labelled illustration, not a real error: a panel with no saved copy and no breaker, beside a three step trace of what the panel really does. Step one says no call was made to the source. Step two says the breaker counts a failed refresh and opens after the failure threshold in a row for the open minutes. Step three says the panel shows the last saved copy with its age, “N min ago”, from the feed’sageMinutesor elsefetchedAt. The two numbers come from the settings.DeliveryController.getRoadmapandgetChangelogputfailureThresholdandopenMinuteson the feed, read fromCircuitBreaker.failureThreshold()andopenMinutes()(theCircuit_Failure_ThresholdandCircuit_Open_Minutessettings, with theCircuitBreakerdefaults of 3 and 30 when a record is missing).c-roadmap-viewerandc-changelogpass them toc-outage-compare, which holds no default of its own. When either is missing the step reads without that number (“After repeated failures in a row it opens”) and the other part of the sentence stays, so changing a setting changes the text with no edit to the component (ADR-022 amendments of 2026-10-29 and 2026-10-30). - Principal access: the scheduling admin user reaches both principals through
Portfolio_Admin(my decision, 2026-10-21).Integration_Accesskeeps them too, by my choice.Portfolio_Guesthas neither. - Degraded Mode:
- After three consecutive failures, the
jiracircuit breaker skips Jira refreshes for 30 minutes, andDeliveryControllerreportsDEGRADEDwhile it is open. - With no usable cache row, the user sees a plain empty line (“Nothing on the roadmap yet”), and only a failed read shows “Roadmap temporarily unavailable”.
- After three consecutive failures, the
- Logging.
JiraCacheRefreshJob.refreshlogs the same two cases (no config record, a failed refresh) throughPortfolioLog.info, the failure by exception type only.
1.3 AI Content Generation (Agentforce on)
[!NOTE] Agentforce is built and its switch ships on (ADR-015 amendment of 2026-10-26).
AgentforceServiceruns a Prompt Builder template. The decision of 2026-10-02 to defer it is lifted.
- One dispatch.
ContentGenerationController.dispatchtries Agentforce, then Gemini, then the Local Template (cover letters only), and records every attempt with its outcome (served,skipped_off,skipped_open_circuit,failed), its elapsed time and, on a failure, a shortdetailsuch asHTTP 503or the exception type, never a key or a URL with a query string. While thegeminioragentforcecircuit breaker is open, that provider is skipped. Breaker state is written after every provider has been tried.- Agentforce is consulted only when
AgentforceService.isAvailableis true: theAgentforcerecord ofAI_Provider_Config__mdthasEnabled__cset, and the running user is not a site guest. The record ships withEnabled__ctrue,Timeout_Ms__c20000,Hourly_Budget__c120 andModel_Or_Template__cPortfolio_Cover_Letter.AgentforceService.generateContentruns that Prompt Builder template throughConnectApi.EinsteinLLM.generateMessagesForPromptTemplate, behind thePromptTemplateClientinterface (the real client isEinsteinPromptTemplateClient, which names the applicationPromptTemplateGenerationsInvocable). The platform documents no test hook, so tests stand a stub in for the interface. The template has one input,Input:Prompt, which carries the whole prompt the dispatch built, and its content adds one line that holds the letter to the prompt’s word range. Its model issfdc_ai__DefaultGPT41. The call runs as the context user, who needs the Prompt Template User permission set. A Published template cannot be changed by a deploy, so a later wording change is a new version made in Prompt Builder. - Agentforce is held to
Hourly_Budget__ccalls per UTC hour (AgentforceServiceBudget), under the Developer Edition’s 150 LLM generations an hour. The count is oneAPI_Rate_Window__crow with the keyagentforce-hour|yyyyMMddHH, read before the template runs and written with the breaker results after the last provider. Past the budget the attempt isbudget_exhaustedand the chain moves on. The read takes no lock, so calls in flight together can pass the budget by a few. A 429 or an answer from the limiter never counts as a breaker failure, and neither does a 401. - Measured in a scratch org on 2026-10-26 and 27, three cover letters called directly took 2.7 to 3.5 seconds and came back at 333 to 340 words. The first call through the API took 13.7 seconds, then 2.9 to 3.2 seconds. In production on 2026-10-27, after the deploy, one proof call served by Agentforce wrote a letter of 418 words, 10 seconds from page load to letter, over the 350 word cap. After the word limit line below, six letters in a row came back at 277 to 322 words in a scratch org loaded with production’s grounding records (nine Agentforce calls in all, 2026-10-27). Production lengths after the deploy that carries that line are not measured yet.
- Gemini is
gemini-2.5-flash, the model on theGeminirecord, through the Gemini API and theGemini_APINamed Credential, with a 60 second callout timeout from the same record (my decision 2026-10-21).GeminiService.MODEL(gemma-4-31b-it) is the code default, used only when the record names no model. Gemma 4 did not answer a full cover letter prompt inside the 60 seconds, so every letter fell back to the template, and Flash answered one in about 11 seconds in a scratch org.GeminiServicereports a failure as text startingError: Unable to generate content.orException:, which the dispatch treats as a failed attempt. - The Local Template serves a cover letter when Gemini fails.
LocalTemplateServicebuilds it in Apex from the records with no model: the company and role, the current title, the years on Salesforce, the current certifications and up to three ranked highlights or projects. It names no employer, holds no email or phone, never throws, and its last line says no model wrote it. Other content types answer 503ALL_PROVIDERS_UNAVAILABLE.
- Agentforce is consulted only when
- Callers.
PAPIAiGenerate.generate(POST /papi/ai/generate) calls the dispatch for outside callers with a Bearer token and returnsprovider,model,fallbackOccurred,latencyMs,attemptsandpromptShown. The site’s cover letter page (coverLetterGenerator) callsApiLabController.generateCoverLetter. While the Agentforce switch is on and its breaker is closed, that method first posts toPOST /papi/ai/generatewithproviderset toAgentforcethrough thePortfolio_APINamed Credential, so the template runs as the integration user (a guest may not run generative AI). A 200 is the letter. Anything else runs the same dispatch in process, as the site guest, from Gemini on, then the template (ADR-015 amendment on the in-process cover letter and the amendment of 2026-10-26):- It checks
ApiLabController.AI_RATE_KEY(papi-ai:site-cover-letter, 10 requests a minute, one bucket for every site visitor) withApiRequestContext.rateLimiter().checkKeybefore the dispatch and writes the count withApiRequestContext.commitRateCounts()after it. The key has thepapi-aiprefix that/papi/ai/counts on, so the footer’s “AI requests today” counts both paths. Over the limit, the page says “Too many cover letters were requested in the last minute. Try again in a minute.” - It writes one log line through
PortfolioLogwith the pathaura:ApiLabController.generateCoverLetter. Portfolio_Guestholds theGemini_Auth-Gemini_Principalprincipal for this. Agentforce is never consulted in process for a guest, only through the API call above.- The Agentforce call gets the
Agentforcerecord’s 20 second timeout, capped to what Gemini’s timeout leaves of the 120 seconds Apex allows for callouts in a transaction, less a 2 second margin (ApiLabController.CALLOUT_BUDGET_MS). The attempt list shows the Agentforce attempt first. - The AI Lab shows my header over every letter, and the resume and the AI Lab share
c/profileLinksfor their links (LinkedIn, GitHub, Trailhead, Portfolio, Contact last). The footer’s “AI requests today” counts a letter that went through the API once (FooterControllertakes off thesite-ai-proxied:cover-lettermarker the AI Lab adds when the API counted the letter). - Grounding as the guest goes through a
without sharingselector that returns a fixed shape with no email, phone or owner field, because a guest sees only rows shared to it. - Why Gemini is not asked through the API: an earlier version of the page posted everything to the org’s own
/papi/ai/generatethrough thePortfolio_APINamed Credential. A request that arrives as a callout from the same org may not call out again (“Callout loop not allowed”), so Gemini could never answer the page and every live letter came from the Local Template. A Connect API call did work inside such a request in a scratch org, so Agentforce takes the API route and Gemini stays in process.
- It checks
- Limits. The Gemini key’s free tier allowed Gemini 2.5 Flash 5 requests per minute and 20 requests per day when it was read from the AI Studio rate limit page on 2026-10-20 (Gemma 4 31B had 30 per minute, 16,000 tokens per minute and 14,400 per day). That is a recorded reading, not a live one. Once Google refuses a call, the template serves the cover letter.
- Grounding.
PortfolioGroundingServiceadds featured projects and experience highlights to a cover letter prompt, ranked by keywords from the job title and description. It never selects Email, Phone or an owner field, strips anything shaped like an email address or phone number, and caps the JSON at 8,000 characters (about 2,000 tokens). - The cover letter prompt.
ContentGenerationController.coverLetterPrompt(companyName, jobTitle, jobDescription)is the one prompt builder, used bygenerateCoverLetterand byApiLabController.generateCoverLetter. Its lines, in order: the instruction, then the word limit line (coverLetterLengthLine, built fromCover_Letter_Min_Words(250) andCover_Letter_Max_Words(350)), the company, the role, the job description when given, the persona when the job points at one, and the grounding facts. Email and phone shapes are removed from the visitor’s text first. The instruction isInstruction__cof theAI_Prompt__mdtrecordCover_Letter, read with SOQL becausegetInstancereturns only the first 255 characters of a long text field, andCOVER_LETTER_INSTRUCTIONin code is the fallback, so the wording can change without a code change. The letter is never cut. The word limit line reads “Word limit: 250 to 350 words, never more than 350.”, says that limit overrides any later line about length, paragraphs or sentences, and fixes the shape: five paragraphs and no more, an opening of two sentences, three body paragraphs of three sentences each and a closing of two sentences, then the sign off, each sentence under 25 words. It does not start with “Length” on purpose. The Published template adds a sentence of its own for aLengthline (write toward the upper number, in three body paragraphs of five sentences), and that sentence pushed letters past the maximum. A wording change in a Published template is a new version made in Prompt Builder, so the fix lives in the code line and the template is unchanged.Ai_Max_Lengthbounds only themaxLengtha caller ofPOST /papi/ai/generatemay ask for, and that endpoint builds its own prompt. - Persona.
PortfolioGroundingService.inferPersonareads Architect, Developer or Admin from the job title first and the description second, and the highlights tagged with that persona rank higher in the grounding. - Generation config. Every Gemini request sends a
generationConfig:maxOutputTokensfromAi_Max_Output_Tokens(8192, because the model’s reasoning counts against the limit and a limit hit while reasoning returns no letter) andtemperaturefromAi_Temperature(0.7). - Show your work. The page shows the whole letter with its word count beside the provider badge. The badge shows the provider and model from the response. A panel the visitor opens shows the fallback flag, the round trip and generation time, the attempts (each with its
detailwhen the dispatch reports one) and the prompt that was sent. The panel is built in Apex and the component, not by asking the model to reveal its prompt. - Fallback message. When the template letter is used, the page says “The AI model could not be reached” if the Gemini attempt failed in under 1 second (
coverLetterGenerator.UNREACHABLE_MS), and “The AI model was busy or too slow” otherwise. Each letter sends the GA4 eventai_generation_completed, and a request with no letter sendsai_complete_failure, both through the head script (section 2.8). - Footer.
ContentGenerationController.providerStatus()returns each provider’s switch and state (on,off,not built) andcircuitOpenUntilwhile its breaker is open, for the footer (section 2.6). - The Portfolio Guide Agent is in source and not live (ADR-036). It is a classic Bot (
Portfolio_Guide_Agent,EinsteinServiceAgent) with aGenAiPlannerBundleon the ReAct planner and four topics: Career and Experience, Projects and Case Studies, Skills and Certifications, and Contacting Ryan, which has no action and points to the site’s contact page. Three read only invocable actions serve the first three:PortfolioAgentCareer,PortfolioAgentProjectsandPortfolioAgentCredentials. Each reads throughQueryDefinitionsin system mode (the fivePortfolioAgent*records, section 1.7), returns JSON, and passes every text throughPortfolioAgentText.clean, which removes email and phone shapes. No Contact is read and the client line is never read, so no email, phone or client name reaches the agent. No agent user is in source. ThePortfolio_Guide_Agent_Actionspermission set grants class access to the three actions and nothing else, for the agent’s user.- The agent metadata needs API 65.0, so
sourceApiVersionis 65.0. - It deploys to production inactive, at my word. No page embeds it and no Messaging channel is built.
- A deploy over an active agent fails (“Can’t edit an active bot version”), so it must be deactivated before any deploy that holds it. Production’s agent is inactive today.
- Tested in a scratch org on 2026-10-27:
sf agent test run, four cases, 12 of 12 checks. Not run in production. - It replaces the January prototype’s six topics, one of which told the agent to give
Owner_Email__c. The January prompt templates (Portfolio_Architect_Response,Contact_Record_Summary) are not in source and nothing uses them. I deactivate them in Setup. PortfolioAgentServiceis unused by the new agent and stays in source with its reads in code (section 1.7). The agent is separate from the Prompt Builder templateAgentforceServiceruns.
- The agent metadata needs API 65.0, so
1.4 System API Security (The “Twin” Pattern)
The APIs use platform-native OAuth 2.0 on both sides of the boundary (ADR-027). The earlier design validated client_id/client_secret headers against Custom Metadata to simulate a MuleSoft policy point; that simulation was retired when MuleSoft left the architecture.
| Role | Component | Responsibility | Implementation |
|---|---|---|---|
| Provider | External Client App (Client Credentials flow) | Authentication: issues Bearer tokens; rejects bad callers upstream | Runs as a dedicated integration user holding Integration_Access (read-only objects + execute on SAPI*/PAPI*). The platform rejects unauthenticated calls before Apex. |
| Consumer | External Credential + Portfolio_API Named Credential |
Secure Storage: holds the consumer key/secret in the platform vault | Used by ApiLabController.invoke (site proxy) and PAPIHealth (credential check). Token vending uses a second pair, Portfolio_API_Token_Auth and Portfolio_API_Token. No secret is ever stored in metadata or source. |
| Vendor | PAPIAuthToken (POST /papi/auth/token) |
Evaluator access: exchanges email + purpose for a short-lived token | The only REST class the guest user can execute (ADR-028). Shares TokenExchangeService with the proxy. |
| Context | ApiRequestContext (shared by every REST class) |
Contract hygiene: headers, pagination, errors, cache, rate limits | Reads X-Request-Id and X-API-Version (v1 when missing, v1 or v2 accepted, anything else 400, and sets X-Request-Id only when it generated one), adds the v1 deprecation headers, answers 410 after the sunset date (except on the token and health paths), bounds limit and offset (the offset to 2000), emits the Error schema with its help link, answers an unplanned exception with a fixed 500 that holds the correlation id (serverError), applies Cache-Control per resource, and enforces the ADR-025 tiers through RateWindowLimiter. |
API versions (ADR-039). The URLs are the same for both versions and the header chooses the contract.
- Selection.
ApiRequestContext.version()returnsv1orv2andisV2()is the shortcut. A missingX-API-Versionis v1.v1andv2are accepted in any letter case, soV2works, and anything else is a 400 that says to send v1 or v2. Every response carriesX-API-Versionin lower case with the version served andVary: X-API-Version, whichappendHeaderadds to any value already set. TheLinkheader is appended the same way. These checks and the sunset check run before the rate limiter, so a request they refuse spends none of the rate limit quota. Whether a given cache honoursVaryis [unverified]. - v2 shape. For the record endpoints,
SAPITransformer.toV2shapes a body recursively. It dropsid, eachxIdthat has anxKeybeside it, andaccomplishments.PAPITransformer.toV2delegates to it. The token, health, AI generate, resume generate and portfolio config bodies are not shaped, because they are not lists of records (UNSHAPED_PATHS). v1 never calls it, so v1 bodies are unchanged. - v2 parameters.
projectKey,experienceKeyandcertificationKeytake a public key only. A Salesforce Id gets 400 “must be a public key, the key field of a record from GET - . A Salesforce Id does not work in v2." and an old `*Id` name gets 400 "In v2 this filter is called projectKey, and it takes a public key. Rename projectId to projectKey." (`SAPIProjectSkill`, `SAPIProjectAsset`, `SAPIExperienceSkill`, `SAPIExperienceHighlight`, `SAPICertificationSkill`). The `/projects/{id}` path takes a key in v2 and answers 404 for anything else (`SAPIProject`). v1 keeps `projectId`, `experienceId` and `certificationId` and takes an Id or a key.
- v1 headers. Every v1 response carries
Linkwithrel="successor-version"tohttps://ryanbumstead.com/api-lab/reference?version=v2,Deprecation(RFC 9745,@and unix seconds) onceApi_V1_Deprecation_Dateis set, andSunset(RFC 8594, an HTTP date) onceApi_V1_Sunset_Dateis set, withrel="deprecation"andrel="sunset"entries inLinkfor those dates (ApiRequestContext.addRetirementHeaders). On an old/v1/URL the same method addsLinkwithrel="canonical"to the versionless URL and applies the path dates, and when both retirements apply it sends the earlier date of each pair (ADR-040). - The 410. From 00:00 UTC on the sunset date every v1 call except the three below, including one with no version header, gets 410 in the usual error envelope with
errorCodeGONEand a message that gives the retirement date and says to sendX-API-Version: v2, with the v2 reference inhelp(isGone). v2 never returns it. The three exceptions arePOST /papi/auth/token,/sapi/healthand/papi/health(SUNSET_EXEMPT_PATHS). They keep their deprecation headers and are answered normally, so a client can still get a token and check that the API is up. Both settings arePortfolio_Setting__mdtrecords read asyyyy-MM-ddtext, blank sends no header and never answers 410, and a value that is not a date reads as blank (section 1.6). - The path 410. From 00:00 UTC on
Api_V1_Path_Sunset_Datean old/v1/URL answers 410GONEwith a message that names the versionless path and never echoes a query value, whatever the version header says. The same three paths are exempt (SUNSET_EXEMPT_PATHS, now canonical paths, so both spellings of each). With the version also gone the message is the version sentence followed by the path sentence. - The site’s own calls. The Agentforce cover letter call (
ApiLabController) and thePAPIHealthself call sendX-API-Version: v2, so the sunset cannot reach them.
Routing (ADR-040). The URLs carry no version. PAPIRouter is mapped to /papi/* and SAPIRouter to /sapi/*, and each calls the static method of the existing handler for the route and verb, so a handler runs ApiRequestContext.begin() as before. The handler classes keep their /v1/ urlMapping, and Apex REST picks the most specific mapping, so an old URL never reaches a router. ApiRouting.routeParts gives the route segments, and an old spelling gives none. An unknown route is ApiRouting.notFound() (404 in the error envelope), a verb a route lacks is ApiRouting.methodNotAllowed (405 in the same envelope with the usual headers and an Allow header), a caller with no token is ApiRouting.unauthorized() (401 with WWW-Authenticate: Bearer) and a token holder who asks the instance address for a token is ApiRouting.forbidden() (403). ApiRequestContext.canonicalPath drops the version segment, lower cases and trims, and isLegacyPath says which spelling came in. Class access is checked on the entry class only, so PAPIRouter keeps the old matrix itself: the guest reaches the token route and no other (401 elsewhere, on every PAPI route), and the integration user reaches every route but the token route (403 there). SAPIRouter is granted to the guest behind the same gate and answers it 401 on every SAPI route, so an anonymous caller is told how to get a token and not that a class is forbidden. Portfolio_Guest holds both routers and Integration_Access holds both routers. A new endpoint needs its handler and a row in the router. The Api_Versionless_Urls setting (On or Off, shipped On, anything but Off reads as On) switches the feature: Off, the routers answer 404 with the /v1/ URL, old URLs stop being aliases (no canonical Link, no path dates, no path 410), and every Apex caller of our own API builds its URL with ApiRoutes (papi, sapi, forCanonical), which honours the switch. The API Lab reads it with the cacheable ApiLabController.getVersionlessUrls.
Error messages (ADR-040, amendment of 2026-10-31). Every sentence the API sends a person is a constant or a small builder in ApiMessages, so a test asserts the constant and not a copy of the text. A message says what happened, what the person needs and how to get it, in plain second person English with a closing period, and holds no exception type, no stack line and no class name (ApiMessagesTest checks the rules on every message). errorCode values do not change, and the Error object gains one field, help, a link to the code’s entry on the API reference page with the spec of the API that was called (ApiMessages.helpUrl, for example https://ryanbumstead.com/api-lab/reference?spec=papi&version=v2#error-bad-request). The anchors are error- and the code in lower case with hyphens, one for each code in the Errors section of each v2 spec, and c-api-reference scrolls to the entry and focuses its heading (errorEntry).
- An unplanned exception. Every handler catches it with
ApiRequestContext.serverError, which logs the exception type and the start of its message throughPortfolioLogunder the correlation id and answers 500 with a fixed message that holds the id. A failure whilebegin()applies the request rules, and any failure in either health check, takes the same road, so no exception type or stack line reaches a caller as the platform’sAPEX_ERROR. The rate count write inokanderroris bookkeeping and cannot turn an answer into an escaped exception. - The anonymous caller. The 401 and the 403 are written by
ApiRequestContext.beginRefusal, which applies no version rule, noX-Request-Idrule and no rate limit. A caller with no headers at all is told how to get a token, and spends none of the shared quota. The token URL in the message is the site’s own address worked out at run time byApiSite.tokenUrl:Site.getBaseSecureUrl()inside a site request, else the org’s My Domain host with.my.salesforce.comreplaced by.my.site.com, plus the site’s path prefix, in the formApi_Versionless_Urlsselects. It is neverryanbumstead.com, which redirects into the site, and a client that follows a redirect can turn the POST into a GET. - Retryable and
Retry-After.retryableis true only when the same request can succeed later with no change. A 503 that can recover and has noRetry-Aftergets 60 seconds fromerror, AI failures set their own (300 seconds, or the rest of the hour for the Agentforce budget), and the missing token credential isretryable: falsewith none. A lock timeout on the counter row is a 429 that says the API is busy (RateDecision.busy), apart from a used up limit. - Offset.
offsetabove 2000 is a 400, because the platform allows no larger SOQL offset (ApiRequestContext.MAX_OFFSET). - Persona.
personamatches in any letter case on every route and/papi/resume/generateechoes it asAdmin,DeveloperorArchitect. - The API Lab. The Lab checks the email by the server’s own pattern and checks the purpose before it sends.
ApiLabControllerlogs a callout failure and tells the visitor the Lab could not reach the API. A failed options read shows a line that says the filters can still be typed under Advanced.
Plain text resume. GET /papi/resume/generate honours Accept: text/plain. When the first media type in Accept is text/plain (PAPIResume.wantsPlainText, parameters after a semicolon ignored, the header name matched in any case), a 200 answer carries the generated text alone with Content-Type: text/plain; charset=UTF-8. PAPIResume.getResume still runs ApiRequestContext.ok first, so the status, the rate count, the cache directive, the other headers and the request log line are the same as for JSON, and then replaces the body with the text. Any other Accept keeps the JSON body, and errors are always JSON. scripts/api-smoke.sh makes one such call and prints the first line.
Rate limiting. Rate limits are enforced in Apex by RateWindowLimiter, a custom-object counter: one API_Rate_Window__c row per caller key per minute. For SAPI and PAPI requests the caller key is {tier}:{SHA-256 hex of the session id}, or {tier}:{user id} when the session id is null. The token endpoint keys by papi-token:{SHA-256 of lowercase email|source IP}. Tiers: sapi 120, papi 15, papi-ai (/papi/ai/*) 10 requests per minute, over a fixed calendar minute. These are the shipped values of the Api_Sapi_Rate_Per_Minute, Api_Papi_Rate_Per_Minute and Api_Ai_Rate_Per_Minute settings (section 1.6). A request refused for an unsupported X-API-Version or a v1 410 is answered before the limiter runs and spends none of this quota. Request limit + 1 within a minute gets 429 RATE_LIMIT_EXCEEDED with Retry-After set to the seconds left in the minute. The check (RateWindowLimiter.checkKey) reads and locks the row and writes nothing. The count is written with the response (ApiRequestContext.ok/error) or by ApiRequestContext.commitRateCounts() for non-REST callers, so resources may make callouts after begin(). Full rules: ADR-025 amendment (2026-10-21).
Deployment prerequisites (per org):
- Integration user (
portfolio-api@ryanbumstead.comin the production org) on thePortfolio API Integrationprofile. The profile is in source (packages/integration-api/main/default/profiles/): Salesforce Platform licence, API access only, the licence’s default object permissions revoked, and a 30-minute session timeout (Portfolio_API_Integration.profileSessionSetting-meta.xml). The user itself is created in Setup. Integration_Accessassigned to the integration user. It is the only source of data access: read-only onAccount,Certification__c,Certification_Skill__c,Contact,Education__c,Experience_Highlight__c,Experience_Skill__c,Experience__c,Github_Cache__c,Jira_Cache__c,Project_Asset__c,Project_Skill__c,Project__c,Skill__candTestimonial__c, read onUserExternalCredential, which a callout through an External Credential needs alongside principal access, no editable field, plus execute access on 21 SAPI and PAPI REST classes (every resource class exceptPAPIAuthToken, which the integration user never needs) and External Credential principal access toPortfolio_API_Auth-Portfolio_API_Principal, without whichPAPIHealthreportscredential: DOWN, plus theGemini_Auth,GitHub_AuthandJira_Authprincipals (ADR-027 amendments).- “Portfolio API” External Client App, created in Setup (it is not in source): Client Credentials flow enabled, “run as” set to the integration user, pre-authorised through
Integration_Access. - External Credential principals, two of them. Enter the values in Setup; they are never in source or custom metadata.
Portfolio_API_Auth(OAuth 2.0 client credentials, in source), named principalPortfolio_API_Principal: the app’s consumer key and secret. Used through thePortfolio_APINamed Credential.Portfolio_API_Token_Auth(Custom, in source), named principalPortfolio_API_Token_Principal: authentication parametersClient_Id(the consumer key) andClient_Secret(the consumer secret).TokenExchangeService.exchangeTokensends them as merge fields in the token request body, because Apex cannot read the secret of an OAuth credential.
- Named Credentials (both in source, both the My Domain root
https://ryan-bumstead-dev-ed.develop.my.salesforce.com):Portfolio_API, authenticated byPortfolio_API_Auth. Callers append/services/apexrest/....Portfolio_API_Token, usingPortfolio_API_Token_Auth, merge fields allowed in the body, no generated Authorization header. Used only for/services/oauth2/token.
- Assign
Portfolio_Guestto the site guest user (in source,packages/portfolio-site/main/default/permissionsets/Portfolio_Guest.permissionset-meta.xml, no licence, assignable to the guest user). It grants:- class access to twelve site controllers,
ApiLabController,ContactFormController,DecisionMatrixController,DeliveryController,FooterController,GovernanceController,PortfolioStatsController,ProjectCardController,ResumeController,SchemaService,SourceControllerandTestimonialController, plus four REST classes, the token endpointPAPIAuthToken, the two routersPAPIRouterandSAPIRouter(each turns the guest away with a 401 on every route but the token route) and the spec endpointOpenApiDocument - read on
Experience__c,Experience_Skill__c,Project__c,Project_Asset__c,Project_Skill__candSkill__c, create and read onTestimonial__c, and read onUserExternalCredential - read on
Experience__c.Start_Date__candExperience__c.End_Date__c, which the skills matrix needs for “years used”, onSkill__c.Persona_Tag__c(for the persona filter) and on the testimonial fields the wall shows - no Lead permission: the contact form saves Leads through a platform event (section 2.16)
- principal access to
Portfolio_API_Auth-Portfolio_API_Principal,Portfolio_API_Token_Auth-Portfolio_API_Token_PrincipalandGemini_Auth-Gemini_Principal(the last one for the in-process cover letter, as the ADR-015 amendment on it explains) - no Contact, Account, certification or education access, and no GitHub or Jira principal.
Without it, the guest gets 403 on
POST /papi/auth/token. Assigning it to the guest user is a Setup step for me as the org owner. - class access to twelve site controllers,
-
Schedule the cache cleanup job daily at 03:00 org time (decision 2026-10-20).
CacheCleanupScheduleris not scheduled by any deploy.CacheCleanupScheduler.executechecksCacheCleanupSettings.isEnabled()and then callsCacheCleanupOrchestrator.run(), which startsGithubCacheCleanupBatchand, throughCacheCleanupOrchestrator.runRateWindowCleanup(),CacheCleanupRateWindowBatch. That batch deletesAPI_Rate_Window__crows whoseWindow_Start__cis older thanCache_Cleanup_Settings__mdt.Rate_Window_Retention_Days__c(Defaultrecord: 1 day). Schedule it once per org from anonymous Apex, as an administrator:System.schedule( 'Cache Cleanup Daily', '0 0 3 * * ?', new CacheCleanupScheduler() );Check it with
SELECT CronJobDetail.Name, NextFireTime FROM CronTrigger WHERE CronJobDetail.Name = 'Cache Cleanup Daily'. - Confirm
SAPIResumeis gone. The class and its test were removed from source. A deploy that carries no destructive manifest leaves them in the org. After the deploy, run the Tooling querySELECT Name FROM ApexClass WHERE Name LIKE 'SAPIResume%'. If it returns rows, delete both classes with a destructive deploy. - Assign
Portfolio_Adminto administrators who need to report onAPI_Access_Request__c(the token audit trail),API_Rate_Window__candPipeline_Run__c. It grants read and View All on the first two, read onPipeline_Run__c, and read on their fields. Without it the fields are not readable, even in SOQL. It also grants theGitHub_Auth-GitHub_PrincipalandJira_Auth-Jira_Principalprincipals, which the admin who schedules the cache refresh needs (my decision, 2026-10-21).Integration_Accesskeeps those two principals as well. - Token settings.
Portfolio_Config__mdt.Ryan_Bumstead.Token_Daily_Cap__csets the daily evaluator token cap (TokenExchangeService.dailyCap, default 50 when blank);Last_Secret_Rotation__crecords the last rotation date. - Governance publisher. CI publishes
Governance_Event__eas each workflow’s existing JWT user (deploy.ymlandkeep-alive.yml). AssignGovernance_Publisher(Create on that event only) to whichever of those users cannot create it yet. The publish steps in the two workflows are my edits, made after the event is deployed to production. - Schedule the cache refresh as the administrator who holds
Portfolio_Admin(it carries the GitHub and Jira principals). From anonymous Apex, once per org:IntegrationCacheRefreshScheduler.scheduleAll();. It creates four jobs,Integration Cache Refresh :00,:15,:30and:45, each enqueuingGitHubCacheRefreshJobandJiraCacheRefreshJob. With noPortfolio_Config__mdtrecord (a scratch org), each job logs and exits. - Allow deployments while Apex jobs are scheduled. In Setup > Deployment Settings, tick “Allow deployments of components when corresponding Apex jobs are pending or in progress”. Items 7 and 12 schedule five jobs, and Salesforce refuses any deploy that changes a class one of them depends on (the error reads “You can bypass this error by allowing deployments with Apex jobs in the Deployment Settings page in Setup”). The setting is an org preference, not metadata in this repository, so set it in every org that runs the scheduled jobs. The production org has it since 2026-10-22. Scratch orgs do not need it, because nothing is scheduled in them.
- Graph engine resource.
D3_Library(eleven d3 modules, 81,482 bytes in source, Public cache) replacesG6_Library. The delta deploy onmainremovesG6_Libraryandc/g6Loaderas post-destructive changes (deploy.yml, “Generate Delta”). A full deploy does not delete anything, so after one, check with the Tooling querySELECT Name FROM StaticResource WHERE Name = 'G6_Library'and remove it with a destructive deploy if it is still there. Nothing on the site loads it. - Employer Hub demo objects. The four
EH_objects ship with theEH_Demo_Readpermission set (read only). It is optional and for administrators only. The site guest needs no grant to see them in the diagram, because describe does not filter by object permission (ADR-029). - Contact form email rules (Setup, not in source). The Lead assignment rule, the auto-response rule with its “Portfolio Website” entry, and the notification address they use are created in Setup, after the deploy, with me present. They are kept out of source on purpose, because they hold an email address. The email templates (
Portfolio_Contact_Auto_Response,Portfolio_Contact_Auto_Response_Html,Portfolio_Contact_New_Lead) are in source. The HTML auto reply is a template of its own because a deploy cannot change an existing template’s type, so the rule entry is pointed atPortfolio_Contact_Auto_Response_Htmlin Setup after the deploy, and the older text template keeps working until then. The owner’s email for each new Lead is sent from Apex (section 2.16), so the assignment rule needs no notification template. A rule entry that assigns “Portfolio Website” leads to me is optional, and with it the in app reminders reach me too. - Testimonial approvals. The Testimonial Approval process, its field update, the
Portfolio_Testimonial_Approval_Requesttemplate and email approval response (EmailAdministrationSettings) deploy from source, and the process is active on deploy (section 2.3). A testimonial saved before the hand off existed is not submitted on its own, so submit it for approval or approve it by hand.
Operational notes: rotation and revocation procedures are in the Maintenance Guide §1.2.
Interface Definition (IAIGenerationService):
public interface IAIGenerationService {
String generateContent(String prompt, Map<String, Object> context);
Boolean isAvailable();
}
AgentforceService, GeminiService and LocalTemplateService implement it. The API dispatch returns a ContentGenerationController.GenerationResult (content, provider Gemini or Local, model, fallbackOccurred); PAPIAiGenerate.generate writes those fields to the response.
Gemini Quota Tracking: not built. The design counted Gemini calls in a Platform Cache partition (local.AIMetrics). The org has no Platform Cache partition, and no class reads or writes such a counter. The bounds that do exist are the papi-ai rate limits (10 requests per minute on the API and 10 on the site page), the gemini circuit breaker and the Gemini API key’s own limits above. The footer shows “AI requests today” from the rate rows (section 2.6).
1.5 Logging (Nebula Logger, optional)
- One seam.
PortfolioLog.info(message)writes a line to Nebula Logger when it is installed and toSystem.debugwhen it is not (ADR-013 amendment on an optional install behind a seam). It finds Nebula’sSystem.Callableentry point,CallableLogger, at run time withType.forName(namespaceNebulafirst, then none), so no source file depends on Nebula at compile time. CI and scratch orgs run without it. - One line per API request. After the response is written,
ApiRequestContextcallsRequestUtil.logRequest, which writesapi request path=<path> status=<status> correlationId=<id> elapsedMs=<ms>. The query string is cut off and no body, header value or token is logged. The site cover letter writes the same line with the pathaura:ApiLabController.generateCoverLetter. - Never fails a request. Nebula reports its own failures as
isSuccessfalse, and the seam then falls back toSystem.debug. An exception inside the seam is caught. - Install (me, production only). Nebula Logger’s unlocked package with no namespace, installed by hand. Set log retention to 2 days in Nebula’s settings, to stay inside Developer Edition storage. Entries carry the tag
portfolio.
1.6 Operational Settings (Portfolio_Setting__mdt)
Operational values such as page sizes, rate limits, refresh intervals and display counts live in one custom metadata type, one record per value, so I can change one without editing code (ADR-032).
- The type.
Portfolio_Setting__mdt, visibility Public.Number_Value__c(Number 18,4) orText_Value__c(Text 255) holds the value,Min_Value__candMax_Value__c(Number 18,4) its safe range, andDescription__c(Text Area 255) says in plain words what it controls and what range is safe. The label is the setting’s plain name. - The accessor.
PortfolioSettings(with sharing) hasgetInteger(developerName, fallback),getDecimal(developerName, fallback)andgetText(developerName, fallback). Every caller passes its own code constant as the fallback, so the code still says what the default is. A record that is missing, holds no value, or holds a number outside itsMin_Value__ctoMax_Value__crange gives the fallback, so a mistyped record never breaks a request.getIntegercuts a decimal to its whole part and gives the fallback for a number too large for an Integer.getTextgives the fallback for a blank value and has no range. - One read per transaction. The records are read once with
Portfolio_Setting__mdt.getAll(), which runs no SOQL, and kept in a static map for the rest of the transaction. - Tests. The test visible
PortfolioSettings.overridesmap sets a value without a record, andoverrideNumber(developerName, value)is the short form. A key mapped to null reads as a missing record.PortfolioSettingsTest(16 tests) covers a shipped record, a missing one, a null value, both sides of the range and both bounds, no range, a number too large for an Integer, the overrides, one load per transaction with no SOQL, and a read as the site guest with no permission set assigned. - What stays in code. Contract values (field lengths, enum and key strings, spec names) and layout constants (graph geometry, image sizes, label lengths) are not settings.
- The records. 37 ship in source (counted 2026-11-01). The first 23 shipped with the value the code used before, so the deploy that brought them changed no behaviour. The others came later with the features that read them, the last three with the Postman link and the footer’s storage panel:
Postman_Run_Url,Storage_Snapshot_HoursandFooter_Site_Traffic_Visible.Api_V1_Deprecation_DateandApi_V1_Sunset_Datecame with API v2, andApi_V1_Path_Deprecation_DateandApi_V1_Path_Sunset_Datecame with the versionless URLs. The last two ship empty.Portfolio_Config__mdt.Token_Daily_Cap__cstill wins overToken_Daily_Cap_Defaultwhen it is set. - The readers. Every record has one, and each keeps its old constant as the fallback:
ApiRequestContext: the two page sizes and the three rate tiers, through the publicmaxPageSize(),defaultPageSize()(never above the max),sapiLimitPerMinute(),papiLimitPerMinute()andaiLimitPerMinute(). The 400 message for a badlimitnames the values in force.ApiLabControllerchecks the site cover letter’s bucket againstaiLimitPerMinute().TokenExchangeService:requestsPerMinute()and the daily cap default (the config cap first).PAPIAiGenerate: the threemaxLengthbounds, with the default held inside the other two, and its 400 message names the bounds in force.PAPIHealth: the credential callout timeout.CircuitBreaker:failureThreshold()andopenMinutes().SubmissionGuard: the contact form’s minimum time and daily cap and the testimonial rate.SourceCacheRefreshJob: the refresh age, the files per run and the content length.GovernanceController: the heartbeat and cache freshness windows. The checklist’s item labels are fixed inc/smartChecklist(“Org heartbeat today”, “Integration caches under 30 minutes old”), so they do not follow a changed value.ContentGenerationController.coverLetterPrompt: the cover letter word range.GeminiService.generationConfig: the output token limit and the temperature.TestimonialController.getHomeTestimonials: the home page minimum.DeliveryController.getRoadmap:Roadmap_Column_Limit, sent toc/roadmapViewerascolumnLimit, which keeps its own constant of 8 when the value is missing or not a whole number of 1 or more.ResumeController.getTimeline:Timeline_Tab_Limit, sent astabLimit.getIntegerdrops a fraction, so 2.75 reads as 2.
| Record | Value | Safe range | What it is for |
|---|---|---|---|
Api_Default_Page_Size |
50 | 1 to 200 | Records an API list returns when the caller sends no limit |
Api_Max_Page_Size |
200 | 1 to 200 | Largest limit an API caller may ask for |
Api_Sapi_Rate_Per_Minute |
120 | 1 to 1000 | SAPI requests per caller per minute before 429 |
Api_Papi_Rate_Per_Minute |
15 | 1 to 1000 | PAPI requests per caller per minute before 429 |
Api_Ai_Rate_Per_Minute |
10 | 1 to 100 | AI generation requests per caller per minute before 429 |
Token_Requests_Per_Minute |
15 | 1 to 100 | Evaluator token requests per email and IP per minute |
Token_Daily_Cap_Default |
50 | 0 to 1000 | Daily token cap when Portfolio_Config__mdt.Token_Daily_Cap__c is blank |
Circuit_Failure_Threshold |
3 | 1 to 20 | Failed callouts in a row that open an integration’s circuit |
Circuit_Open_Minutes |
30 | 1 to 1440 | Minutes an open circuit stays open |
Testimonial_Rate_Per_Minute |
5 | 1 to 100 | Testimonial submissions per minute across all visitors |
Contact_Daily_Cap |
20 | 1 to 1000 | Contact form Leads accepted in a day |
Contact_Min_Elapsed_Ms |
3000 | 0 to 60000 | Milliseconds a form must be open before a submission counts |
Source_Refresh_Hours |
24 | 1 to 168 | Hours before a cached source file is read from GitHub again |
Source_Max_Files_Per_Run |
90 | 1 to 100 | Source files one refresh run reads |
Source_Content_Max_Length |
131072 | 1024 to 131072 | Characters of a source file kept before it is cut and marked truncated |
Heartbeat_Stale_Hours |
26 | 1 to 168 | Hours before the checklist calls the org heartbeat out of date |
Governance_Cache_Minutes |
30 | 1 to 1440 | Minutes before the checklist calls the GitHub and Jira caches out of date |
Ai_Min_Length |
100 | 50 to 4000 | Smallest maxLength an AI generation request may ask for |
Ai_Max_Length |
1000 | 50 to 4000 | Largest maxLength a POST /papi/ai/generate caller may ask for. It never cuts a site cover letter |
Ai_Default_Length |
500 | 50 to 4000 | maxLength an AI generation request gets when it sends none |
Health_Credential_Timeout_Ms |
10000 | 1000 to 120000 | Milliseconds the health check waits for its credential callout |
Roadmap_Column_Limit |
8 | 1 to 50 | Cards each roadmap column shows before its Show all button |
Timeline_Tab_Limit |
5 | 1 to 50 | Sent as tabLimit. No component reads it since the timeline tabs were removed |
Salesforce_Start_Year |
2017 | 1999 to 2100 | Year I started on Salesforce. The career line folds earlier roles by it and the hero shows it |
Cover_Letter_Min_Words |
250 | 100 to 600 | Fewest words the cover letter prompt asks for |
Cover_Letter_Max_Words |
350 | 150 to 800 | Most words the cover letter prompt asks for, and the hard maximum in its word limit line |
Ai_Max_Output_Tokens |
8192 | 1024 to 32768 | Output token limit of every Gemini request, reasoning included |
Ai_Temperature |
0.7 | 0 to 2 | Temperature of every Gemini request |
Postman_Run_Url |
empty | text, an https link on postman.com | Link behind the API Lab’s Run in Postman button. While it is empty the button is hidden and the download stays |
Storage_Snapshot_Hours |
1 | 1 to 24 | Hours before the storage snapshot is taken again. The 15 minute scheduler enqueues the job only when the stored snapshot is older |
Footer_Site_Traffic_Visible |
0 | 0 to 1 | 1 shows the last 7 days of site usage in the footer’s storage panel. It ships off |
Home_Testimonials_Min |
3 | 1 to 20 | Approved testimonials needed before the home page shows any |
Api_V1_Deprecation_Date |
empty | text, a date yyyy-MM-dd |
The day API v1 is deprecated, read as 00:00 UTC. Empty sends no Deprecation header |
Api_V1_Sunset_Date |
empty | text, a date yyyy-MM-dd |
The day API v1 is retired, read as 00:00 UTC. Empty sends no Sunset header and never answers 410 |
Api_V1_Path_Deprecation_Date |
empty | text, a date yyyy-MM-dd |
The day the old /v1/ URLs are deprecated, read as 00:00 UTC. Empty sends no Deprecation header on them |
Api_V1_Path_Sunset_Date |
empty | text, a date yyyy-MM-dd |
The day the old /v1/ URLs are retired, read as 00:00 UTC. Empty sends no Sunset header on them and never answers 410 |
Api_Versionless_Urls |
On | text, On or Off |
Off makes the routers answer 404 and every caller use the /v1/ URLs. Anything but Off reads as On |
How to change a value safely is in the Maintenance Guide §3.5.
1.7 Query Definitions (Query_Definition__mdt)
Content reads can live in custom metadata, one record per read, with the class’s own query as the fallback, so a record can narrow a read and never widen it (ADR-033).
- The type.
Query_Definition__mdt, visibility Public:Object__c,Fields__candFilter__c(Long Text 4000),Order_By__c,Row_Limit__c,Access_Mode__c(USER_MODEorSYSTEM_MODE),Active__candDescription__c. The DeveloperName isClass_Purpose, for exampleSAPISkill_List. - The service.
QueryDefinitions(inherited sharing) hasrun(name, binds, fallback),runPage(name, binds, fallback, pageSize, pageOffset)andcount(name, binds, fallback). The fallback is aQueryDefinitions.QueryDefaultbuilt in the calling class: object, fields, filter, order, limit, mode, the allowlist (allow), the fields the caller reads (requires, every default field unless set) and sample binds (binds). - When a record is used. Only when it is active, reads the same object, asks only for allowed fields and keeps every required one, names only supplied binds, is no weaker than the default’s mode, has a limit from 1 to 50000, has balanced brackets outside quotes, has no bracket, bind or quote in its order, and parses. Otherwise the default runs, and a record that failed to parse is not tried again in the transaction. The record’s filter is joined to the default’s with AND and the smaller limit wins. Request values are only ever bind values.
- Optional filters.
(IsDeleted = :hasX OR Field = :x), withhasXfalse when the parameter is blank. Personas on highlights are three literal INCLUDES clauses, each with its own switch, because a bind inside INCLUDES matches no row. - Loading. One
getAll()per transaction. Records whoseFields__corFilter__creaches 255 characters are read again in full with one SOQL query, becausegetAll()cuts long text there. Today that isSAPIProject_ListandSAPIExperienceHighlight_List. The site guest re-reads a long record through SOQL on the type, which it may read because the type is Public. - Validation. Each owning class implements
QueryDefinitions.Providerand returns its defaults by record name.validateAll()finds the providers throughApexTypeImplementor, checks every record and runs each one that passes once, andeveryDeployedRecordPassesValidation(inSAPIQueryDefinitionsTestandSiteQueryDefinitionsTest) andApiQueryDefinitionsTest.everyApiRecordPassesValidationfail when it returns anything.everySapiRecordShipsEqualToItsDefault,everyApiRecordShipsEqualToItsDefaultandeverySiteRecordShipsEqualToItsDefaultcompare each record with its default field by field, and each guest facing class has a guest test that asserts its reads came from their records. - Readers. 27 classes read through
QueryDefinitions, with 69 records. The 13 SAPI list classes have 14 (one list read each, plusSAPIProject_Detailfor a single project). The Process API and the API Lab options have 27:PAPIProfile,PAPIResume,PAPIProjects,PAPITestimonialFeedandApiLabOptionsController. Nine site classes have 28:ResumeController,PortfolioGroundingService,ProjectCardController(which adds the home case study read),GovernanceController,DeliveryController,SourceController,TestimonialController,SkillGraphController(the certification evidence for the skills graph) andPortfolioStatsController(section 2.19, which adds the usage counter read). Each guest facing read keeps its sharing and access mode, and its allowlist is the fields the class returned before. The totals on/skills,/experience,/projectsand/testimonialsnow count with the same filters as the rows, so a record that narrows the read narrows the total too. - Split reads. A record’s field list cannot safely hold a child subquery, because an edit could move a top level field inside it. So the three reads that used one, the profile and resume experiences in
PAPIProfileandPAPIResumeand the roles inResumeController, are two reads each: the parents, then their highlights by parent Id in sort order, grouped in code. The rows, order and payload are the same (ApiQueryDefinitionsTest,SiteQueryDefinitionsTest.theSplitRoleReadBuildsTheSameRolesAsOneSubqueryRead), at the cost of one more SOQL query per request on those paths. - Kept in code.
PortfolioAgentServicekeeps all its reads in code. It serializes nested child lists exactly as they come back, a split read could only rebuild them through a JSON round trip, and no site path or API calls it. Plumbing stays coded: the rate window readFOR UPDATE(RateWindowLimiter), the counters inSubmissionGuardandTokenExchangeService,CircuitBreaker, the cache cleanup batches,GovernanceEventHandler,ContactFormLeadWriter,LeadReminderService, and every read of custom metadata or a setup object.ApiLabController,PAPIHealth,FooterController,DecisionMatrixControllerandSourceCacheRefreshJobhold no content read, so they did not change. - Tests.
QueryDefinitions.overridesstands in for a deployed record,ignoreDeployedRecordshides the deployed ones andproviderOverridesets the providers.QueryDefinitionsTestcovers each fallback reason, the AND join, paging, counts, one load per transaction and the long text re-read.
How to change a record safely is in the Maintenance Guide §3.6.
2. Frontend (LWR) Component Logic
The front-end is based on LWR for sub-second page loads. Lightning Web Security (LWS) is required for security and to support libraries like D3.js and jsPDF.
2.1 c-skill-network (Visualization)
The Skills page (c/skillNetwork) opens on a matrix: one row per skill category, ordered by Category Order then name, with one chip per skill ordered by proficiency and then name. Each chip shows the skill’s name, its proficiency out of 5 and the years it was used. Years used come from real role dates, not a self rating: the date ranges of the roles linked to the skill through Experience Skill are merged where they overlap, a current role or one with no end date counts to today, and the total shows to one decimal, as “under 1 yr” below a year, or not at all when no role is linked. Selecting a chip opens its detail under the chip’s row, moves focus to the detail’s heading and lists the roles, projects and certifications that back the skill, with a line saying which kinds of evidence back it. On desktop and tablet a Matrix and Graph switch opens a graph view. The graph engine is D3.js behind c/graphEngine, from the D3_Library static resource, and it is requested only when the graph is first opened. The layout runs to completion before anything is drawn: skills cluster by category on an inner ring, roles, projects and certifications sit on outer rings, and edges appear only for the node that is hovered or selected. Each skill carries its evidence at rest: a ring whose weight counts the kinds of evidence behind it (a role, a project, a certification, never the number of rows, so linking one skill to many roles adds nothing), and its lines step up the same way when shown. A legend says what the weights mean. The component draws the result as SVG from its own template, so nothing moves on its own. The view eases about 250 ms only after the visitor presses Fit, Reset layout, Zoom in or Zoom out, and moves at once under reduced motion. Phones and viewports under 768 px get the matrix only and never request the engine. A visually hidden list names every node and its connections. A retired certification shows, marked retired. The AntV graph library is no longer used anywhere on the site.
- Certifications:
SkillGraphController.getCertificationLinks()(with sharing, cacheable, section 1.7) returns each certification that backs at least one skill, with its Id, its name, whether it is retired and the Ids of the skills it backs, and nothing else. The graph reads it through Apex because the guest cannot read certifications through GraphQL without access to the Contact record. - Evidence beside the years. Years stay a count of years worked, so a skill no role links never gets one. Every chip also shows each other kind of evidence it has, beside the years when there are any: “Certified
" from the earliest year a linked certification was earned, and "Project" or " projects". A certification, projects and time in a role are different evidence, so a skill such as Education Cloud shows both its years and its certification (`evidenceParts` in `skillNetwork.js`). `SkillGraphController.getCertificationLinks` returns `earnedYear` (an Integer) for each certification, still no full date, issuer, email or phone. A failed load shows one title line and the detail only when there is one, so the sentence is no longer said twice. - Data:
Skill__cwithCategory_Order__c, itsProject_Skill__candExperience_Skill__cjunctions, and each linked role’sStart_Date__c,End_Date__candIs_Current_Role__c, through thelightning/uiGraphQLApiwire adapter. The site guest reads the two dates throughPortfolio_Guestfield permissions. Without them the query fails validation for the guest. - Engine contract (
c/graphEngine):loadGraphEngine(component)returns a Promise resolving towindow.d3. It loadsD3_LibrarythroughloadScriptonce per page. A failed load rejects and is not cached, so the next open tries again.layoutGraph(d3, nodes, edges, options)is pure and synchronous. It runsforceManyBody,forceCollide,forceLink,forceXandforceYtowardoptions.anchors[node.group](groups on a ring when absent) for a fixed number of ticks with a seeded random source, keeps every node insideoptions.widthbyoptions.height, and returns{ nodes, edges }withxandyon each node andx1 y1 x2 y2on each edge. The same input gives the same output.
- Engine resource:
D3_Libraryholds the official UMD builds of d3-dispatch 3.0.1, d3-quadtree 3.0.1, d3-timer 3.0.1, d3-force 3.0.0, and then d3-selection 3.0.0, d3-color 3.1.0, d3-interpolate 3.0.1, d3-ease 3.0.1, d3-transition 3.0.1, d3-drag 3.0.0 and d3-zoom 3.0.0, appended unchanged after the project’s own header comment, 81,482 bytes in source.graphEngine.test.jspins each segment’s SHA-256 to its npm package file. - Pan, zoom and drag:
attachPanZoom(d3, svg, options)andattachNodeDrag(d3, elements, options)inc/graphEnginewire d3-zoom and d3-drag to the drawn SVG and report the transform and the dragged positions back to the component, which still draws everything from its template. Zoom runs from 1x to 4x in steps of 1.5 (ZOOM) by default. The skills graph passes a lower minimum so a visitor can step back past the fit, andfitTois an opt in that moves the view to a computed transform, with an eased move only when the caller asks for a duration. Double click does not zoom.- Pan. A background drag pans the graph at any zoom level, 100% included, up to 120 view box units past the graph’s edge (
PAN_MARGIN, passed astranslateExtent), so the graph can never be lost. The graph itself is a keyboard stop (a group labelled “Skill graph”). While it has focus, the arrow keys pan it 40 units a press (PAN_STEP, throughpanBy, the same on screen at any zoom) and do not scroll the page, and a hint over its top left corner reads “Arrow keys pan the graph. Reset view centres it again.” - Zoom. A plain mouse wheel scrolls the page and never zooms (
ctrlWheel). Ctrl with the wheel, or a trackpad pinch, which arrives as a Ctrl wheel, zooms, at most one step per wheel event. A one finger swipe still scrolls a tablet page. - Buttons. Zoom in, Zoom out and Reset view stay as real buttons for keyboard users, the zoom level is announced politely (“Zoom 150%”), and Reset view returns the zoom, the pan and every dragged node to the start. The skills graph also has a Fit button, which shows the whole graph again, and a Reset layout button, which puts every dragged node back. It opens fitted to its content. Dragged nodes stay where they were put for the visit, kept in
sessionStorage, and a saved position for a node the data no longer has is dropped.
- Pan. A background drag pans the graph at any zoom level, 100% included, up to 120 view box units past the graph’s edge (
- Persona filter: the matrix and the graph can be filtered by the resume’s personas, with pressed buttons for All, Platform Architect, Salesforce Developer and System Admin and a polite summary such as “Showing 2 of 6 skills for Platform Architect.” The tags are
Skill__c.Persona_Tag__c, a multi-select with the same values asExperience_Highlight__c.Persona_Tag__c(Admin, Developer, Architect), read by a separate GraphQL query so the skills still load if the tags cannot be read. The filter appears only once at least one skill is tagged. The tags are set by a data script that I review and run, so until then the page looks as it did. On the graph, skills without the chosen tag and categories left empty drop out, the roles and projects stay, and nothing moves. Choosing a persona sendsskills_persona_filtered. - View source: a View source button at the top of the component opens its
skillNetwork.js,skillNetwork.htmlandgraphEngine.js(section 2.14). - Spacing: both graphs are spread out, and the spacing is measured by a Jest test on production shaped data (
skillNetwork/__tests__/layoutSpacing.test.js). The ring radii follow the node counts, and a label placement pass puts each label right, left, above or below its node, wherever it covers no other label or node. On that data the test counts 0 hover label overlaps and 0 labels sitting on other nodes. On the skills graph each category label holds its own place in the layout, so skills gather round it rather than under it. - Failure: if the engine cannot load, the graph view says “The graph could not load. The matrix still shows every skill.”
- Analytics:
skills_view_changedwith the view on every switch,skill_selectedwith the skill and its years on a selection, andgraph_layout_changedwithactionset todrag,fitorreset_layout,graph_zoom_usedwithcontrolset toin,outorresetfor the buttons,wheelwhen a Ctrl wheel or trackpad pinch ends,pinchonce per two finger touch gesture, andkeyboardon the first arrow key pan each time the graph has focus (section 2.8). - The D3.js skill: the skill production listed under the AntV graph library’s name is renamed “D3.js”, keeping its links to roles and projects, by a data script that I run after the deploy.
- History: before the D3.js engine, the page drew a force graph with the AntV graph library and an inline SVG on phones (ADR-020 amendment).
2.2 c-resume-builder (Resume Generation)
- One layout spec. The preview and the PDF read
resumeLayout.js: the fonts, sizes in points, spacing, colours, rule, labels and section order. The PDF writer reads it in points and the preview reads the same values as CSS custom properties at 1.6 px per point (previewStyle), so a change to the spec changes both. A Jest test builds the preview’s sections and the PDF’s lines from one model and asserts the same sections, order and certification groups. - Pdf Generation: Performs client-side PDF generation using jsPDF, in one click with no print dialog. The PDF matches the preview: Inter embedded, a centred header with the links side by side and clickable, no “Summary” heading, the title and dates on one row, the same bullets and section rules. jsPDF and the two Inter files (
Portfolio_Fonts, TTF, weights 400 and 700) load only on the first download, throughc/documentPdf, the module the cover letter shares (section 2.22). Moving that code out ofresumeFormat.jschanged nothing in the resume PDF: a recorded trace of every jsPDF call for six resume shapes, with page breaks, was byte for byte the same before and after, and the 33resumeBuildertests passed unchanged before and after (the documents stream, 2026-10-27). When the fonts cannot be fetched the PDF is still written, in the spec’s fallback font. A role, the header and a skill group never split across pages. The preview stays white paper in both themes. - A failed load says what happened. A failed
getResumeshows a plain sentence in place of the platform’s raw text: “The resume could not be loaded. The server sent back no resume. Try again in a moment.” when the call answered with no resume, and “The server did not send an answer. Check your connection and try again.” when it never answered. The empty state “No resume content” shows only for a persona that has none. - Role order: newest first, the way the home timeline orders it.
resumeFormat.jssorts the roles once inbuildViewModel: a current role (or one with no end date) first, then the latest end year, then the latest start year, then full time before part time, then the exact end date and the exact start date. The server read still sends current roles first, then the newest start date, then sort order, and that order only settles ties.getResumecarriesisPartTimeon each role (Experience__c.Is_Part_Time__c, never null, fromQuery_Definition.ResumeController_Roles), so a part time role that started a few months after a full time one in the same years sits under it, not above it. The page, the PDF and the ATS text read the same sorted model. -
Employers and titles. The preview and the PDF make the employer a heading with its span and each title a subheading with its own dates and bullets, so an employer with two titles is written once. The ATS text writes one line per role with the employer repeated, “Title Employer dates”, so a parser that reads one line at a time still sees both. - Earlier roles: past roles with no highlights for the chosen persona fold into one “Earlier roles:” line at the end of Experience, in the preview, the PDF and the ATS text. A past role with no highlights stays under its employer when that employer has another role that prints.
- View source: a View source button in the card’s title row opens
resumeBuilder.js,resumeBuilder.htmlandResumeController.cls(section 2.14). - Logic: The underlying Apex logic filters
Experience_Highlight__crecords based on the user’s requested Persona Tag (e.g., Admin vs. Architect). - Skills follow the persona.
ResumeController.getResume(persona)keeps, for a chosen persona, the skills whoseSkill__c.Persona_Tag__cincludes it plus every skill with no tag, so a new skill nobody has tagged yet never disappears. With no persona chosen it keeps every skill. The screen, the “Copy ATS text” output and the PDF all build from that one payload, and PAPIGET /papi/resume/generateapplies the same rule to the skills it prints (PAPIResume.showsSkillFor). - Certifications: current certifications are listed newest first with issuer and earned month, then one “Verify on Trailhead” link (clickable in the PDF), then the retired ones in their own list, so the link never reads as covering them.
Certification__c.Is_Retired__cseparates the two, and the preview, the PDF and the ATS text group them the same way. In the preview and the PDF the current certifications are one flat list under one issuer label, “Salesforce”, each line the name without the repeated “Salesforce Certified” prefix and with its year, such as “Data 360 Consultant (2025)”. The ATS text keeps the full certification names. The link comes fromPortfolio_Config__mdt.Credential_Verification_URL__c, a Text field so the value renders exactly as stored, throughResumeControllerascredentialVerificationUrl. With no value set, no link shows. - Timeline.
ResumeController.getTimeline()returnswork,education,certifications,verificationUrl,tabLimitandsalesforceStartYearthrough the samewithout sharing, system mode selector as the resume, in a fixed shape with no email, phone, address orOwner_*field. Roles come current first, then by end year, start year and full time before part time, schools newest completion first.certificationslists only the current ones (Is_Retired__cfalse), newest earned first, each with its name, issuer, earned date andbadgeFile, the file name inside theCertification_Badgesstatic resource fromCertification__c.Badge_File__c, or null when it is blank. Each role inworkalso carriesisPartTime(fromExperience__c.Is_Part_Time__c, never null) andhighlights, up to three short lines inSort_Order__corder, each the highlight’sTimeline_Text__c(Text 70) when set and itsDescription__cotherwise.highlightsis empty for a role that ended beforesalesforceStartYear. These are additions only, sogetResumekeeps its shape.verificationUrlisPortfolio_Config__mdt.Credential_Verification_URL__c,tabLimitis theTimeline_Tab_Limitsetting (section 1.6), 5 when the record is missing or out of its range, and no component reads it since the tabs were removed.salesforceStartYearis theSalesforce_Start_Yearsetting (2017 when the record is missing or out of its range), which the career line folds earlier roles by and the hero’s “On Salesforce since” shows. The site guest needs class access only. - Timeline analytics.
career_role_opened{position} andcareer_timeline_toggled{expanded} from the career line (section 2.12). Nothing sendstimeline_tab_changed,timeline_expanded,career_timeline_filteredorcareer_timeline_show_allnow. - Decision record. ADR-023 records why jsPDF stays.
2.3 c-testimonial-submit (Social Proof)
[!NOTE] Per host, as of 2026-11-02. The text below describes the Salesforce site. On the static site the form posts to the
testimonialfunction after a Turnstile check, and the testimonial wall shows the approved rows read at the last build, so a newly approved testimonial appears at the next build. Arecordedswitch replaces the form with the contact call. See Technical Guide §2.25.
- The form. The sentence “I’m a [relationship], and Ryan [strength]. The result? [outcome]” sits inside one
fieldsetwhoselegendreads the sentence. Each blank is a labelled select, a polite live region previews the finished sentence, and an optional note follows. The sentence fits screens down to 320 px. The vibe (Professional or Casual) chooses theTestimonial_Fragment__mdtset, and switching it clears the chosen phrases. A Recruiter, or a Fan or visitor, sees only the outcomes whose audience is Anyone. - The server.
TestimonialControlleraccepts only a known fragment of the chosen vibe, stores the composed sentence inContext__cand the fragment names and the note in fields of their own, runsSubmissionGuard(honeypot, minimum time, blocklist, 5 a minute across all visitors on thetestimonial-submitkey) and never setsApproved__c(ADR-005 amendment). - Approval. After the save the controller publishes one
Testimonial_Submission__e, because the site guest cannot submit a record for approval.TestimonialSubmissionTriggerruns as the Automated Process user, andTestimonialApprovalSubmittersubmits the record to the Testimonial Approval process on its owner’s behalf. I am the site’s default owner for guest records, and I am both the only allowed submitter and the approver, so no user is named in metadata. The request email (Portfolio_Testimonial_Approval_Request) shows the author, title, relationship, vibe, quote and note, and I approve or reject by replying with one word or in Salesforce. Final approval setsApproved__cthrough theTestimonial_Set_Approvedfield update. Rejection leaves it false. A record already approved, already waiting or deleted is skipped, and a record that cannot be submitted stays saved and unapproved, so the visitor never sees an error from this step (ADR-005 amendment of 2026-10-24). - The wall.
c-testimonial-wallon/testimonialsshows approved testimonials fromTestimonialController.getApprovedTestimonials, each in its tone, with a “Professional / All” filter that uses the API feed’s rule (the Professional vibe from a Manager, Client or Peer). It renders nothing when there are none or the read fails. The guest reads the rows through theGuest_Read_Testimonialssharing rule, and the restriction rule hides unapproved ones (ADR-012 amendment). - Analytics. Each submit the server answers sends
testimonial_submittedwith the vibe and the outcome (accepted,rate_limitedorinvalid), and nothing the visitor typed or chose. Switching the wall’s filter sendstestimonials_filteredwith the new filter,allorprofessional.
2.4 c-schema-visualizer (Dynamic ERD Visualizer)
The Business Architecture page draws the live ERD with c/schemaVisualizer, fed by SchemaService.getSchemaGraph(scope) (@AuraEnabled(cacheable=true), with sharing, API 64.0). The scope is employerHub (the default on the page) or portfolio, and a two-button toggle switches between them. Discovery reads the global describe keys once: custom objects of this org (ending in __c with no namespace), minus custom settings, plus a standard allowlist per scope (Account and Contact for the portfolio, and Account, Contact, Case, Opportunity and Contract for the Employer Hub). Objects whose name starts with EH_ belong to the Employer Hub. Schema_Visualizer_Object__mdt rows move an object to a scope or hide it, and four shipped rows hide API_Rate_Window__c, API_Access_Request__c, Github_Cache__c and Jira_Cache__c. SchemaService.getObjectGraph(objectNames) (cacheable, at most 12 names) draws just the named objects. It keeps only names either scope draws, so a guest cannot describe any other object, adds any drawn object that links two or more of them (a junction such as Experience_Skill__c), and keeps only edges inside that set. The component takes them through its public objects property, which hides the scope toggle. Its highlight property instead draws the whole scope the named objects belong to (the portfolio, or the Employer Hub when every name is an EH_ object), gives them a heavier outline and fades the rest, says “read by this section” in their labels and list entries and lists them first. That is what the live data marker’s dialog uses (section 2.24), so a section that reads one object still shows how it connects. objects wins when both are set. Only the filtered list is described, with SObjectDescribeOptions.DEFERRED. Edges come from reference fields whose target is in scope, not from child relationships, and a field with a relationship order is master-detail while every other reference is a lookup. The only cache is the browser’s, from cacheable=true. There is no Platform Cache. The response carries its own CPU time, which the caption shows (“9 objects, described in 96 ms of server CPU”). Uncached, the portfolio scope took 141 ms and the Employer Hub 96 ms in a scratch org, against a 1,000 ms budget. The site guest sees the same objects as an admin, because describe does not filter by object permission. On desktop and tablet the D3.js engine (c/graphEngine) loads once the diagram scrolls into view. Nodes are drawn as rounded rectangles with the label and field count. Master-detail edges are solid with a dot at the parent, lookups are dashed, and a legend explains both. Selecting a node lists its fields (name, label, type, reference targets). On phones and viewports under 768 px the object list is the visible view and the engine is never requested. The same list sits behind the diagram on desktop for screen readers and keyboards.
- Deviation from the first design: this section used to say edges came from
getChildRelationships()and the result was cached in Platform Cache. Neither is built: child relationships onAccountalone run to hundreds, and the org has no Platform Cache partition (ADR-029). - Spacing: the layout treats each object box as an oval, so boxes keep at least 36 px apart without hitting the edge. Measured by
schemaVisualizer/__tests__/layoutSpacing.test.json both scopes. - Failure: if the engine cannot load, the page says “The diagram could not load, so the object list is shown instead.”
- View source: a View source button beside the heading opens
schemaVisualizer.js,schemaVisualizer.htmlandSchemaService.cls(section 2.14). - Analytics:
erd_scope_changedwith the scope,erd_node_selectedwith the object (section 2.8). - Tests:
SchemaServiceTestproves, among others, thatProject_Skill__ctoProject__cis a master-detail edge, the four hidden objects are absent, an override moves an object, anEH_object lands in the Employer Hub, custom settings are left out, an unknown scope throws, each scope stays under the CPU budget, and the site guest gets the same objects as an admin.
2.5 c-smart-checklist (Executable Governance)
[!NOTE] Per host, as of 2026-11-02. On the static site the checklist renders the build snapshot first and then swaps in the live reading from the
pipeline-statusfunction (cached 300 seconds), and adds a “Static site build green” row. The row stays hidden only until a site build has ever reported, and after that it shows the latest build, red included. WithFEATURE_LIVE_READSset torecordedit stays on the build snapshot.
- No browser subscription.
lightning-emp-apiis unsupported in LWR sites, and a guest has no token for Pub/Sub API, so the component imports nolightning/empApiand opens no channel (ADR-019 amendment on the event, the subscriber and the page). - Server side. CI publishes
Governance_Event__eafter a production deploy and after the daily heartbeat.GovernanceEventTriggerhands each batch toGovernanceEventHandler.handle, which upserts aPipeline_Run__crecord byRun_Key__c(<workflow>:<run id>), so a replayed event changes nothing, skips events with an unknown workflow or status or no run id, and keeps the newest 60 rows per workflow. - Read. The component wires
GovernanceController.getPipelineStatus()(cacheable): the latest run per workflow plus the age of the integration caches. It reads when the page is viewed and callsrefreshApexwhen the tab becomes visible again (visibilitychange). There is nosetIntervaland nosetTimeoutloop. - Items. “Production deploy green”, “Org heartbeat today” (a heartbeat more than 26 hours old, the
Heartbeat_Stale_Hourssetting, is out of date) and “Integration caches under 30 minutes old” (theGovernance_Cache_Minutessetting), each worked out inGovernanceControllerfrom evidence only: no run is “No signal yet”, aFailureis “Failed”, old evidence is “Out of date”, and only fresh success is “Passed”. Each shows its evidence (commit and time, linking to the run). A time shows in UTC and then in the visitor’s own time and zone, for example “2026-10-24 12:06 UTC (8:06 AM EDT, your time)”, inside atimeelement. Nothing is kept inlocalStorage.
2.6 c-footer (Glass Box Telemetry)
[!NOTE] Per host, as of 2026-11-02. The text below describes the Salesforce site. On the static site the footer shows its loading state and then the live reading from the
glass-boxfunction (cached 60 seconds), and withFEATURE_LIVE_READSset torecordedit keeps its loading or unavailable state. The GraphQL probe times a live round trip on the Salesforce site and is not exported at build time.
- Component:
c-footer: copyright, then one caption (“Live readings from Salesforce and its integrations”) with View source right after it (source-key="footer", the footer’s js, html andFooterController, at the caption’s 13 px through--view-source-sizeand--view-source-padding). Under it the readings sit in Fira Code in labelled groups, all always open. CI comes first on a line of its own, the GitHub Actions badges for the production deploy, the Live Smoke Test and the Postman Sync, each with a text link fallback, built from theCI_BADGESlist infooter.jsso another workflow’s badge is one more entry. Then Platform, Integrations, and Quotas and limits, which gains a Storage row once a snapshot is stored. Then the footer links (Contact, Docs, then the profile links. Skills and Testimonials are in the header) and “Back to top”. Rules separate the groups on wide screens, and they stack on phones. Inside a group the metrics are split by thin vertical bars drawn in CSS only. The list clips the first item of every line, so a bar never starts or ends a line, and the markup and text hold no separator. The Contact link goes to the/contactpage ({site base}/contact), built from the site base path infooter.js. - Analytics events. In the same caption row, after View source,
c-analytics-eventsrenders a button “Analytics events (N)”, where N is the number of events this visit has sent so far. It opens the dialog described in section 2.8. A second View source button beside it (source-key="siteAnalytics", labelled “View analytics source”) opens the analytics service and that component’s files (section 2.14). - Links:
FooterController.getLinks()(cacheable) returns onlygitHubUrlandlinkedInUrlfromPortfolio_Config__mdt. It never selects my email or phone. - Deferred load (ADR-014): the component paints first and calls
FooterController.getSystemSnapshot()fromrequestIdleCallback, or a zero delay timeout where the browser has none, so the fetch never delays the main page load (LCP). The method is not cacheable, so each visit reads current values. - Telemetry Data:
- Limits: CPU time, heap size and SOQL queries used against their limits (
Limits.getCpuTime,getHeapSize,getQueriesand their limits). - AI providers: one row per provider from
ContentGenerationController.providerStatus(), for example “Agentforce: on”, or “Gemini: paused until 14:05” while its breaker is open. - “AI requests today: N”:
SUM(Count__c)of today’sAPI_Rate_Window__crows whose key startspapi-ai:, through awithout sharinginner class that returns only the number. It counts requests from the site page and from/papi/ai/generate, not Gemini calls, so it is never called a Gemini quota (my decision, 2026-10-21). A letter that went through the API counts once. Hover or focus sendsfinops_quota_visibility. - Org quotas:
DailyApiRequestsandDailyAsyncApexExecutionsfromOrgLimits.getMap(), shown as “API requests today N of M” and “async Apex today N of M”. A quota the org does not report is left out, and a failedOrgLimitsread leaves the list empty, so the footer never shows a guessed number.DataStorageMBis not read here, because the site guest cannot read it (FooterControllerTest). The Storage line below reads a stored snapshot instead. - GitHub and Jira breakers: “GitHub: connected” and “Jira: connected” beside the AI providers, or “paused” (with the time it reopens) while a breaker holds calls back, from
CircuitBreaker.snapshot(). - If the snapshot call fails, the line shows “status unavailable”.
- “GraphQL: live, N ms”: at the same idle moment the footer loads
c/graphqlProbewith a dynamic import and times one small read (the firstSkill__cId) from the moment the probe connects to the first answer. If the read fails the line says “GraphQL: unavailable”. The timer starts when the probe’s field is created, not inconnectedCallback, because the wire can answer before the component connects, and the footer shows a time only when it is a finite number. An earlier order showed “GraphQL: live, NaN ms” on/skills(graphqlProbe.earlyWire.test.js). - “GraphQL: cached, no request made”: the data service can answer from records another component already read, with no request at all. So the probe watches the browser’s resource timing (a
PerformanceObserver) from the moment it is created, and when the answer arrives with no request to/services/data/vNN.N/graphqlsince then, it reports the answer as cached and the footer shows this line instead of a near zero time. When a request did go out, the line reads “GraphQL: live, N ms”. Where resource timing is missing, the probe reports no cached flag and the footer shows the time. The probe is a separate module so the GraphQL adapter stack never loads before first paint (§2.11).
- Limits: CPU time, heap size and SOQL queries used against their limits (
- Storage. Between Integrations and Quotas and limits, once the snapshot holds stored figures, one line such as “Data 1 of 5 MB, Files 5 of 20 MB” is a button that opens a small panel (
aria-expanded, focus stays on the button, Escape closes it). The panel lists the data estimate by area, the files by kind and, only when the server returns them, the site’s page views, with a note that data is an estimate at about 2 KB a record and that Salesforce shows exact figures per object only in Setup. Opening it sendsstorage_panel_openedonce per page view. Where the figures come from is section 2.23. - Deploy badge:
https://github.com/rdbumstead/salesforce-platform-architect-portfolio/actions/workflows/deploy.yml/badge.svg?branch=main, alt text “Production deploy status”, allowed by theGitHub_BadgeCSP trusted site (img-src). If the image fails, a text link to the Actions page takes its place. - Resilience toggle: not in the footer. The “Simulate an outage” switch sits in the roadmap card, beside its “Last updated” label, and drives both the roadmap and Latest Updates through
c/outageSimulation(the ADR-022 amendment on the breaker state and the toggle). While it is on, each of the two panels also shows the before and after (section 1.2).
2.7 Architectural Decision Record: ADR-014 (Deferred Telemetry)
- Context: Real-time telemetry is non-essential for the user journey.
- Decision: All “System Health” data fetching must be decoupled from the critical rendering path.
- Reference: See ADR-014: Deferred Telemetry Loading for full decision context.
2.8 Analytics Instrumentation (GA4)
- One event layer. Components send analytics through
c/analyticsService, which exportstrackEvent(name, params)andEVENTS, the one event list. New events are appended toEVENTS, and nothing else in it changes. - This visit’s events.
c/analyticsServicealso keeps the last 100 events of the page visit in memory (MAX_RECENT_EVENTS), each with its name, a copy of its parameters, the local time and an outcome (OUTCOMES):head_scriptwhen the head script took it,gtagwhen the service calledwindow.gtagitself, ornot_sentwhen the page had no analytics tag.recentEvents()returns copies andonAnalyticsEvent(listener)tells a listener about each new event. The list is gone on reload.c-analytics-eventslists them live in a dialog opened from the footer (section 2.6), and opening it sendsanalytics_events_opened. - No consent setting. No setting gates sending. The head script and the components have no consent banner or consent mode, and the head script’s GA4 config sets
cookie_domain: 'auto'only. - Delivery. A component cannot see the page’s
gtag: inside the component sandboxwindow.gtagreads as undefined. SotrackEventdispatches a cancelableportfolio-analyticsevent on the document with the name and the parameters, and the plain script in the site’s head markup (sfdc_cms__appPage/mainAppPage/content.json) sends it to GA4 with only text, number and true or false values, then cancels the event to say it was sent. It accepts only a name of lower case letters, digits and underscores, up to 40 characters. When no listener takes the event (no head script, or Jest),trackEventcallswindow.gtagdirectly if there is one. It does nothing when neither is there and never throws, so analytics can never break a component. - Where it is proven. On a scratch copy of the site, with every collect request answered locally, the collect requests carried
theme_changed,back_to_top_usedfrom both buttons,view_source_opened,view_source_file_selected,testimonials_filteredandcontact_form_submittedwith their parameters and no personal data, in Chromium and WebKit. Before this change no component event reached GA4 on production. Production is checked again after the deploy that carries it. - Events (measurement id
G-G6P1S31J8L):api_test_executed{path,status},api_reference_opened{spec}ai_generation_completed{provider,model,fallback,latency_ms,outcome}, whereoutcomeislive,template_unreachableortemplate_slow, andai_complete_failure{message_kind}, where the kind israte_limited,no_provider,invalid_inputorother, never the message. The Charter’s AI rates are to be computed in GA4 from these.resume_downloaded{persona,format}roadmap_viewed,changelog_commit_clicked{sha},governance_run_link_clicked{workflow}resilience_test_triggered{source:roadmap}, sent once when the shared “Simulate an outage” switch is turned on (thechangelogsource is no longer sent)finops_quota_visibility,theme_changed{theme}- Skills, ERD, gallery and decision matrix:
skills_view_changed{view},skill_selected{skill,years},erd_scope_changed{scope},erd_node_selected{object},gallery_asset_opened{type,pillar},matrix_weight_changed{criterion},matrix_reset - Skills persona filter:
skills_persona_filtered{persona} - Home page:
case_study_link_followed{name} (section 2.12).case_study_opened{pillar, the letter} stays in the event list for past data and is no longer sent, because the build cards no longer open in place.start_path_followed{path} is no longer sent - Home page career line:
career_role_opened{position: 1 is the newest stop} each time a stop opens, andcareer_timeline_toggled{expanded: true or false} from Expand all and Collapse all (section 2.12).career_timeline_filteredandcareer_timeline_show_allstay in the event list for past data and are no longer sent, because the filter and the phone list are gone - The tour (section 2.20):
tour_step_viewed{stop: A to E,pillar: the pillar key} once on each arrival at a stop,tour_exited{stop} from End tour, andtour_finished, no parameters, when the ending shows.tour_openedstays in the event list for past data and is no longer sent - Cover letter:
cover_letter_downloaded{provider:Agentforce,GeminiorLocal} andcover_letter_copied{provider}, never the job text or the company (section 2.22) - API Lab and API reference:
postman_collection_downloaded{source:api_laborreference},postman_run_clicked{source} andcurl_copied{example:token,healthordata}, sent after a successful copy only (section 2.15) - Footer:
storage_panel_opened, no parameters, once per page view when the panel first opens, andanalytics_events_opened{count}, the number of events listed when the dialog opened (section 2.6) - View source:
view_source_opened{source_key},view_source_file_selected{source_key,file_label} (section 2.14) - Forms:
contact_form_submitted{outcome:accepted,rate_limitedorinvalid} (section 2.16),testimonial_submitted{vibe,outcome} andtestimonials_filtered{filter:allorprofessional} (section 2.3) - Layout:
back_to_top_used, no parameters, from the floating button and the footer’s (section 2.9) - Skills graph:
graph_zoom_used{control:in,out,reset,wheel,pinchorkeyboard} (section 2.1) - Layout:
motion_toggledstays in the event list for history and is no longer sent, because the site has no motion button (section 2.9) - Docs:
docs_opened{doc_key} (section 2.17) - API Lab:
api_lab_section_toggled{section,expanded} (section 2.15) - None of these parameters holds a name, an email, a message, free text or an id of a person.
- The tag. The GA4 tag loads from head markup, with a page view sent on every route change, because the LWR router changes routes without a page load. The route change page view is sent from the plain head script that wraps
history.pushState, which calls the exportedgtag('event', 'page_view', ...)with the new location. Before that fix, the listener sat inside the privileged iframe, never received the outer window’s event, and no page view was sent after the first page. - CSP. To load the tag the site runs on Relaxed CSP (my decision, 2026-10-21). That lets inline scripts and
eval()run on the site, so only reviewed head markup adds scripts, andhttps://www.googletagmanager.comis on the site’s script allowlist (Experience Builder “Trusted Sites for Scripts”), kept in source in the site bundle atdigitalExperiences/site/Portfolio1/sfdc_cms__trustedSites/trustedSites/content.json. It deploys with the site, so production needs no hand step. On the scratch site the tag loaded and ag/collecthit reachedgoogle-analytics.com. The GA4 image and connect hosts are trusted sites in source (GA4_Tag_Manager,GA4_Analytics,GA4_Google), so a Builder setting saved only in production cannot drift from the repo. - Link previews. A link preview crawler reads only the server’s first response and runs no script. The site’s head markup (
sfdc_cms__appPage/mainAppPage/content.json) is one block for every route, so the first response carries the home page tags whatever route is asked for. Each route’s own title, description,og:urland canonical are set by that view’s SEO assistant and reach the page when scripts run (the rendered page at/ai-labshows its ownog:title,og:urland canonical). Read with a LinkedIn user agent on the integration site on 2026-10-27,/ai-laband a missing page returned the same 61 KB shell with the home tags. Link preview scrapers that do not run scripts therefore see the home preview for any route. Removing the tags from the head markup left no rawtitleorogtags at all, so they stay. A raw per route fix needs something in front of the site, and none is built. The home tags are: adescription, the Open Graph tags (og:typewebsite,og:site_name,og:title,og:description,og:urlhttps://ryanbumstead.com/,og:imagewithog:image:width1200,og:image:height630 andog:image:alt) and the Twitter card tags (twitter:cardsummary_large_image,twitter:title,twitter:description,twitter:image). The image is thePortfolio_Share_Imagestatic resource, a 1200 by 630 PNG with Public cache, named by its absolute URL on the production site domain. The preview text and the image wait for my approval before the release.
2.9 Site Theme, Tokens and Fonts
- The theme follows the visitor’s system setting. Light tokens sit on
:rootin the site stylesheet (sfdc_cms__styles/styles_css/styles.css). Dark tokens apply under@media (prefers-color-scheme: dark)for:root:not([data-theme="light"]), and again on:root[data-theme="dark"], so the first paint needs no script. Light is the fallback. - The toggle. The header’s theme button in
portfolioThemeLayoutsetsdata-theme="light"or"dark"on thehtmlelement and on its own host, which overrides the media query, and stores the choice under thelocalStoragekeyportfolio-theme. No stored value means “follow the system”. Changing it sends the GA4 eventtheme_changed. No other component reads that key or sets the attribute. - Motion. The site has no motion button.
c/motionPreferencefollows the visitor’s system “reduce motion” setting alone:isReduced()reads it, andonMotionChangetells a component when it changes. The layout mirrors it on its own host asdata-motion. The components that animate read it: the layout’s smooth scroll, its menus, the header shadow and the theme crossfade, the home page’s scrolls, case study and tour opening, the numbers panel’s count up, the timeline’s reveal, the cover letter’s fade and the graph’s eased zoom. Nothing moves on its own. A choice an earlier version stored under theportfolio-motionkey is ignored, and the layout removes it on its first load (forgetStoredMotion). - Design tokens. Components use only the
--pf-*custom properties (background, surface, text, accent, chip, warning, success, code, button, error, fonts, radius and spacing), defined instyles.cssand mirrored on the:hostofportfolioThemeLayout. The stylesheet also maps the platform’s--dxp-g-*globals onto them, because it loads after the branding set’s inline styles. - Fonts. Inter (400 to 700) for text and Fira Code (400 and 500) for section labels, pillar letters, code, API output and the footer metrics. Both are Latin woff2 files in the
Portfolio_Fontsstatic resource, loaded by@font-facewithfont-display: swapfrom a same-origin URL, so the site’s CSP needs no new host (licences in THIRD_PARTY_NOTICES.md). - Header. A slim sticky header. It shows the name as the home link on every page, then at the right Home, Resume, Skills, a “How it’s built” menu, Testimonials and Contact, a thin divider, then the Back to top arrow, the theme button and, on phones, the menu button. While the arrow is hidden the links slide over into its place, so the divider stays beside the theme button and the arrow and the theme button never move. From 768 to 899 px the links sit a little closer so all six stay on one line. Phones show no divider. Home links to the home page from every page. On the home page it carries
aria-current="page", scrolls to the top and moves focus to the page content, the same landing as the Back to top arrow. The menu opens with “Take the 10 minute tour” (a first row set apart by a rule, with the note “Five stops, with one task on each”), then lists the five pillars fromc/pillarData, then Docs and the API reference as items of their own. Skills and Testimonials are footer links. The link to the page on screen carriesaria-current="page". On phones (under 768 px) the links fold behind the menu button. The menu opens on a click, and closes on a click outside it, on Escape (focus returns to its button) and after a link is followed. Primary controls are at least 44 px high. - The tour bar.
portfolioThemeLayoutplacesc-tour-barunder the header (section 2.20). From 768 px up it sticks under the header, and below that it is pinned to the bottom of the screen with the page padded by the bar’s height. Focus moves to the new page’sh1when a tour stop arrives. - The content region holds one screen.
.region-contenthasmin-height: 100vh, so the footer never paints in the first screen while a page’s components load and then drops when they arrive. Measured on the integration site on 2026-10-27 (Chromium at 390 by 844, 4x CPU, slow 4G, GA4 blocked, shifts with no recent input over 12 s, 3 runs, median), the cumulative layout shift of/went from 0.866 to 0.255 and of/deliveryfrom 0.399 to 0. The cost is that a page shorter than a screen, such as the 404 page, shows its footer just below the first screen. - Error pages. The 404 page is
c-message-pageon the theme layout, so it keeps the header and footer, says what happened and links to the home page. The busy and unavailable views use the same component. The Limit Exceeded page is the Visualforce pageBandwidthExceeded, branded to match. The Limit Exceeded page was rendered as a guest at 1400 and 390 in both themes on the integration site (answered 200, no overflow). The busy and unavailable views answer 404 when requested directly, so their render is not confirmed, and whether the LWR site shows the Limit Exceeded page past a limit is not confirmed either. A favicon (thePortfolio_Faviconstatic resource) is served on every route. - Back to top. An up arrow in the header bar, in the same place on every page and width, never over content or the footer.
portfolioThemeLayoutshows it once the page has scrolled past the home hero, or one screen height (BACK_TO_TOP_SCREENS1, times the window’s inner height) on a page with no hero. It is a real button labelled “Back to top”, it stays while it has focus, and pressed it scrolls to the top (instantly when the system asks for reduced motion), moves focus to the top of the main content and sendsback_to_top_used. The footer’s own “Back to top” link sends the same event. - Removed. The unused
placeholderTerminalcomponent, a pre-launch splash that no page placed. TheSiteSamplesstatic resource stays, because the site’s Visualforce error pages (for exampleFileNotFoundandInMaintenance) andSitePoweredByload it. - Shared pillar list.
c/pillarDataexportsPILLARSandpillarByLetter, the one list the header menu and the home page read. Pillar B’s name and its page label there are both “Solution Architecture”, so its badge, its card link, its tour stop and its item in the header’s menu read that way. The route, view title, page heading and page title (Solution Architecture | Ryan Bumstead) say the same. Only the address stays/nonprofit-solutions, so no link breaks and no redirect is needed. TheProject__c.Pillar_Label__cformula, which the API returns aspillarLabel, and the pillar page descriptions in the site views repeat the names, sopillarData/__tests__/pillarSources.test.jsfails when either disagrees withc/pillarData. That test keeps “Nonprofit Solutions” as a former name.
2.10 Project Asset Gallery and Decision Matrix
c/projectAssetGallery (properties pillar A to E and heading) reads Project__c by Pillar__c with Project_Assets__r, ordered by Sort Order, through its child c/projectAssetGalleryData. The child holds the lightning/uiGraphQLApi read, and the gallery loads it after load plus idle (section 2.11). An Image shows with its alt text and opens full size in a new tab. A Document is a download link. A Video or Link is an external link with rel="noopener" and no embed. An asset with no URL shows its name and “Not available yet”. URLs that start with http or https are used as they are. resource:<Name>/<path> becomes <site base path>/sfsites/c/resource/<Name>/<path>, so images ship as static resources deployed by CI. Any other value gets no link. Opening an asset sends gallery_asset_opened with the type and pillar. It is generic, so any project page can reuse it. With no assets, or when the read fails, it renders nothing at all, no heading and no note, so a pillar with no published material shows no empty section. A Url field accepts the resource: form (SchemaServiceTest.assetUrlFieldAcceptsResourcePath), so no extra field was needed.
c/decisionMatrix shows the Pillar B matrix (ADR-030):
- Data: one cacheable call,
DecisionMatrixController.getMatrix(), returns the three options, the ten criteria with my starting weights, the thirty scores with rationale and evidence, andhasPlaceholders. It reads three Custom Metadata Types in thenonprofit-literacypackage with SOQL and maps each score to its option and criterion by record Id. The site guest needs only class access, no custom metadata grant. - In the browser:
computeTotalssums weight times score per option,rankOptionsorders them, andfindFlipsearches each criterion’s weight from 0 to 10 for the smallest single change that would put the runner-up ahead. That is the sensitivity line. Moving a slider makes no server call. “Reset to the starting weights” restores everyDefault_Weight__c. - Placeholders: while any score has
Is_Placeholder__c, a banner says “Scores and weights are provisional placeholders until I review them.” Every score is now reviewed and no flag is set, so no banner shows. At the starting weights the totals are NPSP and PMM 268, Nonprofit Cloud 262 and Education Cloud 152. - Analytics:
matrix_weight_changedwith the criterion,matrix_reset. - View source: a View source button at the top opens
decisionMatrix.js,decisionMatrix.htmlandDecisionMatrixController.cls(section 2.14). - Tests:
DecisionMatrixControllerTestfails when any option and criterion pair lacks a score, and proves the site guest gets the same matrix as an admin, cell for cell.
c/pillarBStory holds the seven sections of the Pillar B page: the client, discovery with the lifecycle to object table, the decision matrix introduction, the cost and effort table from public licence facts with a source per row, the solution design with the object model redrawn in NL_Assets, the outcome, and what I would revisit. Discovery opens by saying I ran the work in two parts: an unpaid pre-sales cycle of discovery, a demo and the decision matrix before the nonprofit chose a platform, then a paid engagement in which I led the build. Each table that scrolls sideways is a named keyboard stop (tabindex="0", role="region" and an aria-label such as “Lifecycle concepts table, scrolls sideways”), so a keyboard user can scroll it on a phone. There is no video prototype, by my decision, so the page has no video section and sends no video event.
2.11 Mobile Performance
[!NOTE] Per host, as of 2026-11-02. Every measurement in this section was taken on the LWR (Salesforce) site and describes that host, except the bullet that begins “The static front end” below, whose numbers are local and labelled so.
A first performance pass measured every change with the Lighthouse CLI (11.4.0, mobile profile, simulated throttling), three runs per route on a scratch copy of the site, and kept or reverted it on the median.
- Kept: the footer’s GraphQL probe loads after the page is idle.
c/footerimportedlightning/uiGraphQLApistatically, so about 237 KB of GraphQL adapter modules were preloaded with the layout on every route before first paint. The probe now lives inc/graphqlProbe, which the footer loads with a literalimport("c/graphqlProbe")afterloadplus idle and renders throughlwc:component(the footer declareslightning__dynamicComponent). Rendering the child behindlwc:ifwas not enough, because the bundler still preloaded it. - Kept: the hero reads
ResumeController.getHeader(). It returns only the header from my Contact, so the title line and the value sentence, which the live site paints as the hero’s text, no longer wait on the full resume query. - Tried and reverted: loading
gtag/jsafter the windowloadevent (Total Blocking Time rose on all four routes) and preloading the headshot from the head markup (LCP rose on three of four routes, because a head preload is fetched on every route). - Results, medians of three runs, before in brackets:
| Route | FCP | LCP |
|---|---|---|
/ |
4.61 s (5.91 s) | 7.30 s (8.20 s) |
/resume |
5.16 s (6.43 s) | 9.85 s (9.39 s) |
/skills |
5.75 s (5.75 s) | 9.28 s (9.42 s) |
/api-lab |
5.27 s (6.50 s) | 8.65 s (10.02 s) |
- Reading them. First paint came about 1.2 s sooner everywhere except
/skills, which needs the GraphQL stack for the skills matrix itself. The/resumeLCP lands near 8.6 s or near 10.1 s from run to run on identical code, so its median does not show a direction. Accessibility scored 1 on every run. - What is left. The launch target of LCP under 2.5 s on mobile is not met. The remaining cost is outside the site’s own files: about 500 KB of LWR framework and data service modules on every route, about 420 ms of
gtag/jsscript, and the platform’s SLDS stylesheets. Fonts were not changed in that pass, because dropping a weight the pages use would change how they look.
The recorded gate. A second measured pass, on a scratch copy of the site with Lighthouse 11.4.0 (mobile profile, simulated throttling, three runs per route, medians). Two changes, each measured alone:
- Tried and reverted: fonts. Dropping the unused Inter 500 face and preloading Inter 600 lowered LCP on three routes and raised it on three, and Total Blocking Time rose on three. A font preload cannot shorten script render delay, so the numbers did not earn it.
- Tried and reverted: deferring SLDS stylesheets 2 to 4. Lighthouse never counted any stylesheet as render blocking (
render-blocking-resourcesis empty on all six routes), so deferring them left the critical path as it was, and LCP rose on three routes. - Why LCP stays high. Every text LCP on these routes is render delay from script: the LWR framework and data service modules and the components’ own data calls. The home page LCP is the hero photo, whose request waits on the component. None of that is in the site’s own files.
- Results of the shipped build (the same code as before the round), medians with all three runs:
| Route | FCP | LCP (runs) |
|---|---|---|
/ |
4.55 s | 7.51 s (7.21, 7.51, 8.72) |
/resume |
5.00 s | 7.66 s (7.66, 6.57, 10.01) |
/skills |
6.11 s | 9.62 s (9.71, 9.02, 9.62) |
/api-lab |
5.18 s | 8.65 s (8.51, 9.92, 8.65) |
/business-architecture |
5.92 s | 8.51 s (7.28, 8.57, 8.51) |
/nonprofit-solutions |
6.24 s | 8.43 s (8.61, 8.43, 7.33) |
- The gate. Mobile LCP on the scratch site measured 7.5 to 9.6 s with Lighthouse 11 mobile, which is the best the LWR shell allows, against a 2.5 s target. Accessibility scored 1 on every run. The same code measured twice spreads by up to 3.9 s on one route (
/skills, 5.71 to 9.71 s), so a single run proves little. These are scratch numbers, not live ones. - A pass on 2026-10-27, and what it showed. Lighthouse mobile, three runs a route, on the integration site with proof data loaded (the scratch org is slower than production). Before and after one round of lazy rendering below the fold (
content-visibility: autoon the career line, case study and testimonial wrappers, and the career highlights read waiting until the section nears the screen), medians:/LCP 6443 ms before and 6280 ms after, FCP 4931 and 4882, TBT 558 and 562./deliveryLCP 6496 and 6537, FCP 5170 and 4998, TBT 620 and 328. CLS read 0 in both. Only the/deliveryTBT moved by more than run to run noise (LCP runs ranged from 6232 to 8065 ms), so no LCP improvement is claimed. A separate cumulative layout shift fix is in section 2.9. On 2026-10-28 it came off the career line and case study wrappers, because its paint containment clipped the live data marker’s panel. The testimonial wrapper keeps it. - The static front end (ADR-041) is measured separately, and locally. Lighthouse CI 0.15.1, mobile profile, three runs a page, against the built static pages on a local server. These are local numbers, not Cloudflare numbers and not field data. With every component in one bundle the medians were home 2.50 s, business architecture 2.12 s, skills 2.71 s, API Lab 3.59 s, solution architecture 3.65 s and resume 4.74 s. The largest paint on the slow pages was the page intro’s own paragraph, held back by about 4 s of render delay while every component on the page downloaded and ran. It was not jsPDF, which loads only on a download, and not the resume data, about 15 KB inline. Each page now paints its first section before loading the rest. After that change every run landed near 1.75 s or near 3.85 s for the same element, and each page reached 1.75 to 1.9 s in at least one run except home, whose hero sentence swaps to the record text once the data lands. Against a loopback server Lighthouse’s simulated network decides which of the two you get by frame timing, so these numbers cannot rank the options. On the same profile the live LWR site measured 6.5 to 8.3 s. The medians that count come from a real Cloudflare preview. On
pr-156.ryanbumstead.pages.dev, with sample fixture data rather than production data, mobile profile, three runs a page, the median LCP was resume 2.13 s, nonprofit solutions 2.14 s, API Lab 2.29 s, business architecture 2.44 s, skills 2.44 s and home 2.94 s, against 6.5 to 8.3 s on the LWR site. Home misses 2.5 s because its hero sentence waits about 2.5 s of render delay for the hero bundle. Production numbers are recorded here after the first real data build. The lab results by condition are in the same range on the live site: desktop fast 1.37 to 1.62 s, desktop on slow 4G 6.40 to 8.34 s, mobile profile 6.51 to 8.32 s and mobile with no throttling 1.30 to 2.40 s (Lighthouse CI 0.15.1, one run a route, 2026-11-02), so the network and not the device sets the cost. - Production is not measured in the two passes above. The figures above are the scratch and integration sites’. The 2026-10-29 baseline below is the first production measurement since.
The October 2026 pass (2026-10-30). I measured production first, then changed what the site’s own files control. Lighthouse 11.4.0, mobile profile, simulated throttling.
- Baseline on production (2026-10-29). Five runs on
/and/resume, three on the other four routes, GA left on. Medians, LCP and FCP in seconds:
| Route | LCP | FCP | CLS |
|---|---|---|---|
/ |
7.59 | 5.79 | 0.300 |
/resume |
7.57 | 6.04 | 0.095 |
/skills |
10.62 | 7.10 | 0 |
/api-lab |
9.45 | 5.76 | 0 |
/business-architecture |
9.45 | 7.00 | 0 |
/nonprofit-solutions |
9.50 | 7.11 | 0 |
- Where the time goes. The server sends a 56.8 KB shell with an empty body, so every route is a client render. In a throttled trace the first site component renders at about 5.0 to 5.35 s, after about 850 KB of scripts and styles. The framework scripts are about 40% of the bytes before first render, and the site’s own
c/*code is 11 to 21% of each view. The LCP is text (the hero sentence on/, the intro lead elsewhere) with no load delay, so it waits on script and not on a request. The first site component alone appears at twice the target, so the 2.5 s target is not reachable by changes to the site alone. I keep 2.5 s as the target and I do not claim it is met. - Changed.
- The data model dialog behind the live data marker loads its code when it opens.
c/dataSourceimportsc/schemaVisualizerwith a literalimport()and renders it throughlwc:component, and its bundle declareslightning__dynamicComponent, the same pattern as the footer’s probe. - The project gallery’s GraphQL read moved into a child,
c/projectAssetGalleryData, whichc/projectAssetGalleryloads afterloadplus idle. A skeleton holds the gallery’s space until the read settles. The gallery no longer importslightning/uiGraphQLApiitself (ADR-021 amendment). The two pillar views carried about 1,165 KB uncompressed of the GraphQL adapter stack because of that import. c/viewSourceformats its fetch time withIntl.DateTimeFormatin atimeelement, in place oflightning-formatted-date-time, which pulled the i18n library into the layout bundle through this component. The site now useslightning-formatted-date-timenowhere.- The resume’s two include toggles are native checkboxes in place of
lightning-input, which carried about 240 KB uncompressed of machinery into the resume view (ADR-037 amendment). - The home page reserves space for the hero’s text slots, the profile links and the build section’s rows before their data arrives.
- The data model dialog behind the live data marker loads its code when it opens.
- Tried and removed. An early read of the header from the layout, so the hero’s sentence would not wait for its own Apex call. The extra call ran during boot on every route, and home measured slower with it, so I removed it.
- Tried and not shipped: SLDS parts 2 to 4. Removing the platform stylesheets 2 to 4 and
dxp-slds-extensionsfrom the head markup saved 93 KB a page and brought first paint forward about 0.3 s on home and 0.5 s on resume (seven runs a side). It broke the four pages with alightning-card(apiTester,coverLetterGenerator,skillNetwork,resumeBuilder): the card lost its border, icon and header layout, and resume gained a 0.19 layout shift. Those parts hold the card and form element styles (part 2), the spinner, icon, assistive text and text styles (part 3) and the grid and spacing utilities (part 4). The “95 to 100% unused” first reading was by bytes. About 30 classes are used and the site depends on them. I hold to shipping only on a gain with nothing broken, so it stayed out. To do it later I would replace the fivelightning-carduses with my own markup, copy the used rules intostyles.css, checklightning-spinnerandlightning-icon, then remove the parts and require a 0% screenshot diff. - What was proven. Home layout shift went from 0.33 to 0 (three runs before and after, and 0 in the later control runs). Nothing else is proven as a gain. The medians of identical code moved by about 1.5 s between measurements of three to five runs, as much as any change I made, so I claim no LCP or FCP gain for any of the changes above. They rest on the bundle sizes they remove from the first load, which is a reason and not a measurement.
/skillsstill imports the GraphQL adapter statically, because its matrix is the page’s content. Moving it is the largest item left, with a cacheable Apex read as the alternative. - Production after this pass is not measured. A new production measurement follows the deploy. The 7.5 to 9.6 s gate above is the reading of 2026-10-22 and the production baseline above is wider (7.57 to 10.62 s), so I treat the gate as the best the LWR shell allows and not as a limit I hold the site to.
2.12 Home Page
The home page reads for a hiring manager first and a technical reviewer second (ADR-034). The view holds six components in this order: c-hero-banner, c-case-study-grid, c-career-line (the work timeline), c-credentials-band, c-home-testimonials and c-contact-call. “How this site is built” (c-site-build-section) is inside c-docs-viewer on the Docs page, under its title and lead. Each reads its width as var(--portfolio-content-width, 1024px). The theme layout sets that property once, so the content width is one value, and each component keeps 1024 px as its own fallback.
- Hero. The photo is a 132 px circle, vertically centred beside the text. The name is the page’s one
h1. Under it come the meta line (the employer title, “Senior Solution Consultant at TELUS Digital”, and the location), the title line, one line under the headline and a proof strip. The title line isContact.Hero_Line__c(Text 160) and the line under the headline isContact.Value_Sentence__c(Text 255).ResumeController.getHeaderreturns both, trimmed, asheroLineandvalueSentence. The title line shows only when it is set. The line under the headline falls back to the constantWHY_FALLBACKinheroBanner.js(“If you’re hiring a Salesforce architect for education or nonprofit work, start with the three case studies below. Each shows the problem, the call I made and how it turned out.”) when the record is blank or the read has not returned, so I tune the wording in the record. The fields are read in system mode through the resume’s selector, so the guest needs no field permission. The proof strip is two chips: “On Salesforce since 2017” and the active certification count. They paint fromPROOF_POINTSinc/pillarDatawithout waiting on Apex. Then the year comes fromgetTimeline(salesforceStartYear) and the count is the number of certifications in that same read, which leaves retired ones out. The constantACTIVE_CERTIFICATIONSstays on screen until the read returns, and when the read fails or counts none. Under the strip a proof panel says “This site runs on Salesforce. Every case study, stat and timeline entry on it is a record served through my own API.”, shows one monospace line of live test counts, and holds every hero button. Since 2026-10-31 the panel is as wide as the line under the headline (58 characters at 17 px, border and padding inside), and there is no “See the case studies” button, because the case studies come right after the hero. The primary button, “Take the 10 minute tour” (the minutes are computed fromc/pillarData), sits on its own row after the counts. It starts the tour (startTourinc/tourBar) and goes straight to its first stop as a plain link. If the tour cannot start because storage is blocked, the link still opens that page and no bar shows. Under it sit two secondary buttons, “Architecture and decisions” (the docs page) and “How a request reaches the data” (the guest access section of the architecture spec). Those two buttons and the counts moved up from “How this site is built”. The counts line readsProjectCardController.getBuildCounts, the same cacheable read the build section used to make: the Apex tests passing, the coverage and the Jest tests passing, each part left out when its count is missing and the whole line left out when there is no Apex count. The profile links are GitHub, LinkedIn, Trailhead, Resume and API docs, the first three from my Contact’s stored address (Trailhead fromTrailhead_URL__cthroughgetHeaderastrailheadUrl), shown only when set and safe, in a new tab. - Career line.
c-career-lineis the work timeline, under the case studies, fromResumeController.getTimeline. It shows work only. Education and certifications are in the credentials band, and there are no filters. Roles are grouped by employer name, so an employer is one stop on a single track (an ordered list), in the server’s order of first appearance. A stop shows the employer, its overall span (“2017 to 2021”, or “2021 to now” when any role there is held now), the title of its newest role that is not part time (the newest role when all of them are part time), and a button reading “N roles · Details ▾”. A filled dot marks an employer held now and an outlined dot a past one. Roles that ended beforeSalesforce_Start_Year(2017, the setting the hero’s “On Salesforce since” also reads) fold into one last stop, “Earlier roles”, with a gray outlined dot. The button is a disclosure:aria-expandedflips,aria-controlsnames the panel, the label switches to “Hide ▴”, and focus stays on the button. The panel opens under its own stop, inside its own column, and any number of stops can be open at once. Each role in a panel shows its title, its years (with “ · part time” whenisPartTimeis true) and up to three highlights, each wrapping in full with its bullet hanging, so wrapped lines sit under the text. Inside one employer the newest roles come first by end year and then start year, and two roles that read the same years put the full time role ahead of the part time one. The Earlier roles panel lists each role as a line of text that may wrap (title, employer, years) with no bullets, then a “See the full resume” link. An intro line, “Open any employer to see each role. Several can be open at once.”, has an Expand all button beside it, which opens every stop together and reads “Collapse all” once every stop is open. Under the track one primary “See the full resume” button links to the resume page. At 900 px and up the stops share one row of five columns. Below 900 px, tablets included, the track stacks into one column with the line on the left and each panel directly under its stop. A panel reveals in 180 ms with a height change and a fade, and with no transition when the system “reduce motion” setting is on (c/motionPreferenceand a media query). The component makes one read,getTimeline, and its data marker lists only the roles definition, so the marker counts roles. It renders nothing when the read fails or has no roles. The “Verify on Trailhead” button is in the credentials band. - Credentials band.
c-credentials-bandfollows the career line. It is one section with Education on the left and Certifications on the right, a thin divider between them, from the same cacheablegetTimelineread. Education is one plain circle tile with an inline graduation cap (no logo), the text “, " (just the credential when `Field_of_Study__c` is blank) and the caption " · ". Certifications show every current certification the read sends, newest first by earned date with ties broken by the name shown. Each tile has the badge image, the name without the issuer's own " Certified " prefix, and the caption " · " built from the record, so a certification from another issuer works. The image comes from the `Certification_Badges` static resource at ` / `, where `badgeFile` is `Certification__c.Badge_File__c`. Each is a 171 by 168 WebP, shown about 84 px tall with `width` and `height` set, `loading="lazy"` and the certification name as its `alt`. A certification with a blank `badgeFile`, or whose image fails to load, shows the same plain circle tile as the degree, with its initials. One primary "Verify on Trailhead" button sits beside the Certifications heading, from the read's `verificationUrl`, in a new tab, and it is left out when that value is empty or not an http or https address. Below 768 px the two sides stack into one column. The band renders nothing when the read fails or sends neither. Its data marker lists the education and certification definitions. A new certification needs its record, a badge file in the static resource and the file name in `Badge_File__c`, with no code change. - Case studies.
c-case-study-gridwiresProjectCardController.getHomeCaseStudies, which returns at most three cards: theProject__crows withHome_Sort__cset, lowest first and then by name, in system mode. It never readsIs_Featured__c, because that flag already drives the featured projects path of the Process API, the cover letter grounding and the pillar card order, so a home card cannot change what the API or the letters use. The query is aQuery_Definition__mdtrecord equal to its code default (ProjectCardController_HomeCaseStudies, section 1.7). The card fields areHome_Title__c(Text 120, shown in place of the name when set),Client_Line__c(Text 120) andHome_Sort__c(Number 3,0), beside the existing headline and link fields. A card is compact: the metric with its label (only when the number and its label both resolve), the title, the engagement line fromClient_Line__cand a “Read the case study” button. The first card’s button is primary and the other two are secondary. No status pill shows. A framing line above the cards reads “A selection of my work: two client engagements and the build behind this site. Client names are left out.” The full story lives on the page the button links to, and the button’s accessible name includes the title. A card with no title or no usable link (an http or https address or a path on the site) is hidden. Case study E links to/deliveryin the seed data. Following the button sendscase_study_link_followedwith the card’s title. The grid renders nothing, with no heading, before the read returns, when it is empty and when it fails. While it shows cards its host carries the idcase-studies. Three columns on desktop, one under 980 px. - Card numbers are computed.
Project__c.Headline_Metric_Source__c(a picklist) names where a card’s number comes from, and the controller computes it each time the cards load: “Employer Hub objects” counts the Employer Hub objects the ERD draws (SchemaService.countObjects), “API paths” counts the paths in theSAPI_SpecandPAPI_Specresources, “AI providers” counts the providersContentGenerationController.providerStatus()reports as on, and “Apex tests” readsTests_Passed__candOrg_Coverage__cfrom the newestPipeline_Run__cwithStatus__cSuccess and a test count, which CI writes after each production deploy. The number replaces the first number inHeadline_Metric__cand fills the label’s{n}token (and{coverage}for Apex tests). With no source set, or when a source fails, the stored text shows. A label whose token cannot be filled hides the headline, so a card never showsNaNor a 0 from a failed read. Setting the sources on the production rows is a data script that I run. - Case study cards have their own number. The home cards read
Project__c.Case_Metric__c,Case_Metric_Label__candCase_Metric_Source__c(getHomeCaseStudies), and the “How this site is built” pillar cards keep reading the Headline fields (getPillarCards), so the two sections stop repeating the same three numbers. The source rule is the same for both. A blank Case metric shows no number on the card and never falls back to the Headline one. The Case source picklist adds “Production deploys”, a live success rate over the retaineddeployruns inPipeline_Run__c(the runs the numbers panel counts as deploys recorded). The number is the successful runs, and the label fills{n},{total}(every retained deploy run) and{recovery}.{recovery}is a clause on how fast failures were fixed, such as “, and the one failure was fixed by the next deploy 12 minutes later”, and it is empty when nothing failed or the latest failure has no later success yet. My data script sets the production values after the deploy: the Employer Hub card reads “8 steps of a partner inquiry, mapped end to end from the web form to the leadership dashboard”, the Nonprofit card keeps “$0”, the Delivery card shows the live success rate, and the build section’s nonprofit row reads “3 platforms scored on 10 weighted criteria in a live decision matrix”. - What people say.
c-home-testimonialsreadsTestimonialController.getHomeTestimonials, which returns nothing untilHome_Testimonials_Min(3, range 1 to 20) approved testimonials exist, and then up to three: the Professional ones first, then the rest, each group in the wall’s order. They show as quote cards in the same style as the case study cards, each with its tone, and one secondary button, “Read every testimonial”, leads to/testimonials. Until then it renders nothing. The section sits after the credentials band and before “How this site is built”. The certifications and their verify link are in the credentials band, and “References available on request” is in the contact call. - Contact.
c-contact-callis the last section before the footer. It sets the heading “Get in touch”, the line “Questions about a project, a role or this site?”, a note under it (“References available on request.” by default) and a link to/contact. The form itself is section 2.16. - How this site is built.
c-site-build-sectionis no longer on the home page. Since 2026-10-30 it is on the Docs page (/docs). Since 2026-10-31c-docs-viewerplaces it itself in its list view, after the page title and lead and before the document groups, so the page reads “Architecture docs”, the five cards, then the documents. The Docs view holds onlyc-docs-viewer. A document that is open, and the “Document not found” view, do not show the cards. It no longer holds a tour. The tour starts from the hero button and the first row of the header’s “How it’s built” menu and runs inc-tour-bar(section 2.20). The section is one card for each live pillar fromc/pillarData, in the case study card style: the pillar’s stat as the big number with its label whenProjectCardController.getPillarCardshas both (merged in by letter, and a pillar with no stat shows its card without a number), then the letter and page name, the summary and one secondary “Open” button to the pillar page. Skills is not listed, because the site header links it. A stat that starts with a whole number of 1000 or more gets thousands separators in the component (“1,350 Apex tests”), because the Apex sends the text as stored. The heading is anh2, becausec-docs-viewerholds the page’s oneh1in every state (the list, an open document and “Document not found”), so the outline never skips a level. It takes its look from the sharedc/sectionHeadingstyle, a CSS only module that the case studies, work timeline, credentials band, testimonials and this section all import. A plain link in the intro leads to the architecture spec’s section on how a visitor’s request reaches the data. On/docsa click on it sets the address withhistory.pushStateand firespopstate, whichc-docs-vieweralready listens for, so the section opens without a page load. A modified click, or the link on any other page, is a normal link. A line names the pillars that are coming. The section has no primary button. The test counts and the two architecture buttons are in the hero, and a pillar’s case study text lives on its own page, so this section no longer opens a case study in place and nothing sendscase_study_opened. A failed read leaves the cards as the staticc/pillarDatadraws them. - Card images.
Project_Card_Imagesholds one 640 by 360 WebP per pillar (pillar-a.webptopillar-e.webp), each under 28 KB, cropped from that pillar’s own public page in the light theme with nothing that identifies a person. Records point at them asresource:Project_Card_Images/pillar-x.webp, resolved byc/externalUrltoSiteAssetUrl. -
Head markup. The home view’s SEO component sets the title “Ryan Bumstead Salesforce Platform Architect, Higher Education” and a description. The app page’s head markup carries the link preview tags and one Person structured data block (name, the employer job title, the employer, the specialty as its description, the site address and the LinkedIn and GitHub profiles). - Motion. A career line stop opens with a 180 ms height change and fade. That and the smooth scroll are off under the system “reduce motion” setting.
- Analytics.
case_study_link_followed{name},career_role_opened{position},career_timeline_toggled{expanded} and the tour events (section 2.8).case_study_opened,career_timeline_filtered,career_timeline_show_allandtour_openedare no longer sent.start_path_followedstays inEVENTSbecause the list is append only, and nothing sends it now that the start here paths are gone. - The timeline moved. The Work, Certifications and Education views are no longer on the Resume page (section 2.2). On the home page the work is the career line, and the certifications and the school are the credentials band.
2.13 Cloudflare Cache Warmer Worker
[!NOTE] Retired at cutover. The warmer exists to wake the Salesforce site. When the static site is the front door (ADR-041) there is nothing to wake, and the Worker would send guest traffic to a redirect for no purpose. I pause it before cutover and delete the Worker and
deploy-worker.ymlthe same day (cutover runbook). Until then this section describes what runs.
The one piece of the running system outside Salesforce, apart from the domain redirect (SAS section 6.1).
- What it is. A Cloudflare Worker named
portfolio-cache-warmer(config/wrangler.toml), whose code isscripts/cache-warmer.js. Itsscheduledhandler runs on a cron trigger every 5 minutes (*/5 * * * *) and sends one GET toTARGET_URL,https://ryanbumstead.com/, withCache-Control: no-cacheandPragma: no-cache. It logs the status and never retries. - Why. To wake the site regularly, so a visitor is less likely to be the first request after a quiet spell. The Worker’s own comment says the
no-cacheheaders make sure the request reaches Salesforce rather than a cache. No measurement of the effect has been taken, so none is claimed. - What it is not. It is not the edge AI proxy of ADR-016, which stays deferred. It reads nothing, caches nothing and calls no API.
- Deploy.
deploy-worker.ymlrunswrangler deploy --config config/wrangler.tomlon a push tomainthat changes either file, or by hand, with theCLOUDFLARE_API_TOKENsecret. - Not yet checked.
TARGET_URLis the redirect domain, so the request reaches the site only by following the 301. Whether the deployed Worker follows it, and whether it fires every 5 minutes, has not been read from Cloudflare’s logs.
2.14 View Source (c-view-source)
A “View source” button on the showcase components opens the component’s real source, read from GitHub ahead of time and cached in the org. A visitor never causes a GitHub call.
- The control.
<c-view-source source-key="skillNetwork"></c-view-source>.sourceKeyis its required public property, andlabelis an optional one for the text on the button. It renders a realbuttonlabelled “View source”, or with thelabelwhen one is set (the footer’s analytics one reads “View analytics source”), with a 44 px target. Pressed, it opens a native<dialog>withshowModal(), so it sits above the sticky header and the page behind it is inert. The dialog is labelled by its heading, “Source: “ and the component’s name, and lists the files as tabs (the arrow keys move between them). The chosen file shows inc-code-viewerwith a link to it on GitHub that opens in a new tab withrel="noopener noreferrer". Focus moves to the heading when the dialog opens, Tab stays inside it, and Escape or Close returns focus to the button. When the key is unknown or no file is cached yet, nothing renders at all. A host can set--view-source-sizeand--view-source-paddingto fit a smaller line, as the footer does, and the target stays 44 px. On phones the button starts at the left of its row, and in the data model header it sits directly under the title. Under 768 px the dialog passesflattoc-code-viewer, which then draws no card and no padding, andc-view-sourcetakes back the dialog body’s side padding, so the code uses the dialog’s full width. The code shrinks to 12 px and long lines wrap (white-space: pre-wrapwithoverflow-wrap: anywhere) instead of scrolling sideways. The viewer has no line number gutter, so a wrapped line has nothing to number twice. The viewer reads the width once when it connects, and wider screens keep the card as before. These widths come from the CSS and Jest, not from a phone walk. - What each tab says. Each tab shows the file’s label and, under it, a type worked out from the path by
describeFileinviewSource.js: LWC · HTML, LWC · JavaScript, LWC · CSS, LWC · API module (a JavaScript file with no HTML file in its folder), Apex class or Apex trigger. A line above the code names the component (“Component c-footer”), the API module’s import (“API module c/graphEngine”) or the Apex class (“Apex class FooterController”). A “How it’s built” link opens this guide’s section for the key, at/portfolio/docs?doc=technical-guide#<id>in the same tab, fromGUIDE_SECTIONS. A key not listed there shows no link. - Files per key.
Source_File__mdtrecords (Source_Key__c,Path__c,Label__c,Sort_Order__c) list the files, so I can change them without code. Each key lists the component’s.jsand.htmland its main Apex class, orgraphEngine.jsforskillNetwork, which has no Apex. The keys areskillNetwork,schemaVisualizer,decisionMatrix,apiTester,resumeBuilder,roadmapViewer,footer,coverLetterGeneratorandsiteAnalytics. ThesiteAnalyticskey listsanalyticsService.js,analyticsEvents.jsandanalyticsEvents.html. The head script is not listed, because it is an escaped string inside a JSON content file. - Read.
SourceController.getSourceFiles(sourceKey)(@AuraEnabled(cacheable=true)) returnspath,label,language,content,htmlUrl,fetchedAtandtruncatedper file, in sort order, fromSource_Cache__conly. The guest has no permission onSource_Cache__c, so the controller reads it through awithout sharingselector in system mode that returns those fields for the configured paths only.SourceControllerTestruns as the site guest and proves the read makes no callout. - Analytics. Opening the dialog sends
view_source_openedwith the source key. Choosing another file sendsview_source_file_selectedwith the key and the file’s label. - Fill.
SourceCacheRefreshJob(Queueable, callouts allowed) reads each configured path atmainthroughGitHubServiceand theGitHub_APINamed Credential and upserts oneSource_Cache__crow per path.IntegrationCacheRefreshSchedulerenqueues it on its existing quarter hour schedule only when a path is due, meaning it has no row or was last checked 24 hours ago (theSource_Refresh_Hourssetting), so each file is read at most once a day, a failing one included. The first run after a deploy reads every path, 18 today. A run makes at most 90 callouts (Source_Max_Files_Per_Run), all before any write. A failed read keeps the last good content and records only the check time and status, and it counts toward thegithubcircuit breaker, which also makes the job skip while open. A file longer than one row (131,072 characters,Source_Content_Max_Length) is stored across up to 8 rows,path,path#part-2and on, and joined when it is read. Each cut sits inside a top level##heading where one fits, else between two visible characters, because a long text field drops whitespace at the end of a value. Past 8 rows the text is cut and markedtruncated. Rows for paths no longer configured are deleted. A forced run,SourceCacheRefreshJob.forceRefresh, reads the given paths, or every path of one source key, whatever their age, with the same upsert, parts, stale row delete, circuit breaker and file cap. The class runs with sharing, so it must run as the admin who holds the GitHub credential access (section 2.17). - Highlighting.
c/prismHighlighteris the site’s one Prism module.loadPrismloads thePrism_JSresource once per page,highlightbuilds HTML only from escaped text (Prism tokens for a known grammar, theclikegrammar for an unknown one, escaped plain text if Prism is missing or throws), andlanguageForPathmaps.clsand.triggerto Apex,.jsto JavaScript,.htmland.xmlto markup, plus CSS, YAML and JSON.Prism_JScarries Prism 1.30.0’s Apex, SQL, JSON and YAML grammars.c-code-vieweruses it, and its scroll region is a named keyboard stop. - Placed on: the API Lab (
apiTester), the skills page (skillNetwork), the Pillar A data model (schemaVisualizer), the Pillar B decision matrix (decisionMatrix), the resume (resumeBuilder) and the Delivery roadmap (roadmapViewer). Each card places the control in its title row, at the right. The ERD hidesSource_Cache__cthrough theHide_Source_Cacherecord ofSchema_Visualizer_Object__mdt. - Not yet checked in production: the first refresh through
GitHub_APIruns only after the deploy. The same credential serves the commits job today.
2.15 API Lab and API Reference
[!NOTE] Per host, as of 2026-11-02. The text below describes the Salesforce site, where the Lab calls
ApiLabControlleras the guest. On the static site the Lab’s requests go through thelab-invokeandlab-tokenfunctions to the site actions endpoint, after the function checks the path against the API Lab catalog. WithFEATURE_API_LABset torecordedthe Lab shows responses captured at build time, or says none are in this build. The API reference and Redoc render in the browser on both hosts.
- One source for the specs.
packages/integration-api/specsholds four YAML specs:salesforce-sapi-v2.yamlandportfolio-papi-v2.yamlat 2.1.0, and the deprecated v1 pairsalesforce-sapi.yamlandportfolio-papi.yamlat 1.4.0.npm run specs:sync(scripts/sync-specs.js) generates every copy: theSAPI_Spec_V2andPAPI_Spec_V2YAML static resources the API reference reads (andSAPI_SpecandPAPI_Specfor v1), theSAPI_OpenAPI_V2andPAPI_OpenAPI_V2JSON resources/openapi.jsonserves (andSAPI_OpenAPIandPAPI_OpenAPIfor v1), theAPI_Lab_Catalogresource the Lab reads, and the Postman collection, built from v2. The catalog’s entries hold the v2paramsandresponsesand keepv1Paramsandv1Responsesfor the Lab’s v1 option. The v1 files are listed inV1_SPECSin the script. The v1 specs spell their paths with/v1/, and the script drops that segment, so both versions meet on the versionless path (ADR-040).npm run specs:checkwrites nothing and fails when a copy differs, and a Jest test inapiReferencefails the same way. - Path list.
ApiLabControllerallows only the paths inAPI_Lab_Catalog, built from the specs’ GET operations, so a new spec path appears in the Lab with no code change./papi/auth/tokenand/papi/ai/generateare left out on purpose, with the reason in the script. A missing or broken catalog refuses every path. - The Lab. Query parameters are controls built from the spec: pills for up to four values, a picklist above that, true and false pills for a boolean, bounded
limitandoffset, hinted id fields, and every chosen value as a removable pill over the query string, plus the free text Advanced field, which wins on a shared name. Each name and Id filter is a picklist with “Any” first, read once per page load fromApiLabOptionsController.getOptions()(cacheable): a name sends itself, a record’s name sends its Id, andpersonaandcategorylist the fields’ active picklist values. It returns names, Ids and picklist values only, at most 200 per parameter, sorted by label, read in system mode with fixed field lists. When the options cannot be read, the fields stay text boxes. “Next page” followsX-Has-More. The response body is highlighted byc/prismHighlighterand checked against the path’s response schema: which documented keys are present, missing, or not in the spec. Each call sendsapi_test_executedwith the path, the status andparam_count. The endpoint combobox, the Advanced query text and the token Email and Purpose fields are native controls (section 2.21), and the parameter grid’s own inputs and selects use the same look. In the grid every control sits at the bottom of its cell, so the pills, selects and inputs of one row line up from 768 px up. On phones the Lab opens with the endpoint and Execute showing, and Filters, Advanced, the response details and “Request an evaluator token” are closed sections, each a realbuttonwitharia-expandedandaria-controls. Opening or closing one sendsapi_lab_section_toggled. Desktop shows every section open, as before. A version picker offers “v2” (the default) and “v1 (deprecated)”. The Lab callsApiLabController.invokeVersioned(path, params, apiVersion), where blank means v2 and any value other than v1 or v2 is refused. It shows and sends the picked version’s parameter names (paramsfor v2,v1Paramsfor v1) and clears the typed filters, the Advanced query text and the shown answer when the version changes. Picking v1 shows a one line notice that v1 is deprecated, stays for one month after v2 reaches production and then answers 410. The response headers it lists now includeX-API-Version,Deprecation,SunsetandLink(RETURNED_HEADERS).ApiLabOptionsController.getOptions()returns the same public key lists under the*Idnames for v1 and the*Keynames for v2, so a picker never holds a Salesforce Id. The curl examples sendX-API-Versionfor the picked version.ApiLabController.invokestays and calls v1, for a caller built beforeinvokeVersioned. - The reference.
c-api-referencerenders Redoc inside the component. Redoc looks its sections up ondocument, which cannot see inside the component, so the component scrolls a chosen section below the sticky header itself and answers Redoc’s section lookups from the rendered reference. It finds the header’s bottom by scanning down the page’s centre line where it cannot read the theme layout’s shadow root. Deep links (#tag/...,#operation/...,?spec=) land on their section, on load too. A version picker beside the SAPI and PAPI switch shows v2 by default or v1 labelled deprecated with a one line notice, and?version=v1|v2beside?spec=opens a version, an unknown value opening v2 (apiReference.VERSIONS). - The menu. On desktop (50rem and wider) Redoc’s menu sticks just below the site header and the pinned SAPI and PAPI switch, scrolls on its own and never grows taller than the viewport below that line. A tag row opens and closes with a click, Enter or Space (Redoc’s own expand), scrolls its intro into view and keeps focus on the row. An operation row scrolls its section into view, moves focus there and sets its link in the address. Space on a row never scrolls the page. The open row’s label is
#1D4ED8, which passes contrast on Redoc’s active grey. Phones keep Redoc’s folded menu behind its own menu button. - Spec switch. Before each render the component disposes the previous Redoc store (
disposePreviousRedoc), because Redoc 2.5.4 never unmounts its last render and the old store’s scroll tracking would rewrite the address to the other spec. The check reads React internals inside a try and does nothing when their shape is not the expected one, so a Redoc upgrade has to recheck it. - WebKit. Redoc does not render in WebKit (Safari), so there the page shows its fallback with a link to the hosted reference.
- Machine readable specs.
OpenApiDocument(SAS Appendix B.3) takesspec=sapi|papiandversion=v1|v2, defaults to v2, answers 400 for an unknown spec or version and 503 when the static resource is missing. - Use the API from your own tools. Under the token form the Lab offers three setup steps, a download of the Process API (PAPI) Postman collection, and three copyable curl examples. The download is the static resource
PAPI_Postman_Collection, built byscripts/build-postman-collection.jsfrom the v2 spec (portfolio-papi-v2.yaml, and the collection sendsX-API-Version: v2) withopenapi-to-postmanv2, an exact dev dependency whose version lives only inpackage.json. The builder stops with “Run npm install” when the installed converter is another version, and a staged spec regenerates the collection before the commit (Maintenance Guide section 3.9). The converter’s output changes on every run, so a post step makes it the same bytes for the same spec: every id is a hash of its place in the collection, every UUID in an example is one fixed value, keys are sorted and the converter’s randomness is seeded.--checkwrites nothing and exits 1 when the committed file is stale. The post step also adds the collection variablesbaseUrlandaccessToken, bearer auth on the collection from ``, and a first request “Get an evaluator token” whose test script savesaccess_tokenandinstance_urlinto those variables. The file never holds a token. The file downloads asryan-bumstead-portfolio-api.postman_collection.json. The API reference page offers the same download. - Run in Postman. A “Run in Postman” button shows only while
Portfolio_Setting.Postman_Run_Urlholds an https link on postman.com or getpostman.com, read throughApiLabController.getPostmanRunUrl(cacheable, blank on any failure). The record ships empty, so the button is hidden and the download stays. It appears once I create a public workspace and fills the record. The Postman Sync workflow (postman-sync.yml) keeps the public workspace collection equal to the generated one after each deploy. - Curl examples. Three copyable blocks: get a token, check the API’s health, and read the profile summary. They are built in the page with the visitor’s own
instance_urland issued token once a token exists, and placeholders (<instance_url>,<access_token>) before. They are never sent anywhere and never reach a URL, a log line or GA4. Long lines scroll inside the block, not the page. A note says the token is one shared session, shown once, ending after 30 minutes without use, and what 401, 429 and 503 mean. Each copy button announces its result, andcurl_copiedsends only which example. A token never appears in the collection file, a test fixture or an event. Token issuance is not configured on the integration site (the request answered 503), so the filled in form was tested in Jest with an obviously fake token and was not walked with a real one. - Two collections.
PAPI_Postman_Collection(generated, Process API only, the token request first) is the one the site offers.docs/api/postman/portfolio-api.postman_collection.json(both specs, written bysync-specs.js) stays in the repo for the System API. RunPOST /auth/tokenfirst in either: it stores the token and instance URL for the other requests. - Plain errors. A failed call with no message from the server shows a plain sentence in place of the platform’s “Failed to get error from response”, in the Lab and the reference (
reduceError), and keeps the meanings of 401 (the visit’s session ended, reload), 429 (too many requests in the last minute, wait) and 503 (the service is not taking requests, try later). When nothing reached the server it says so. - The Redoc logo. The vendored
Redoc_Script.jsno longer draws Redocly’s hosted logo, which the site’s content security policy blocked. Before the change every visit to/api-lab/referencelogged two console errors (an image blocked and a failed policy report), and after it none (read on the integration site, 2026-10-27). The change is noted inTHIRD_PARTY_NOTICES.md.
2.16 Contact Form and Submission Guard
[!NOTE] Per host, as of 2026-11-02. The text below describes the Salesforce site. On the static site the form posts to the
contactfunction after a Turnstile check. The function sends a hash of the visitor’s address, made with a secret salt, andSubmissionGuarduses it for a block rule (an activeSubmission_Block__cof type IP Hash) and a per hash rate limit,Form_Rate_Per_Hash_Per_Minute, three a minute by default, which answers “Too many tries. Wait a minute and send again.” The daily cap keeps “Try again tomorrow”. The honeypot and the minimum time stay.
A native contact form on its own page, /contact, that lands as a Lead (ADR-031).
-
The page. The Contactroute (/contact, page title “ContactRyan Bumstead”, its own description) holds c-contact-formwith the heading “Get in touch” and a short intro. The header’s Contact item and the footer’s Contact link point at it. - On the home page.
c-contact-calltakes the form’s old place: a “Get in touch” heading, one sentence and a link styled as a button, “Contact Ryan”, to/contact. An old link to/#contactstill works: when the home page’s address ends in#contact, on load or later,c-contact-callsends the visitor on to/contact, replacing the history entry so Back does not land on the redirect again. - The form.
c-contact-formsits under a heading withid="contact". First and last name, email, an optional company and a message, a one line privacy note, a hidden honeypot field, and the time since the form appeared. It thanks the visitor inline (“Thanks, check your inbox.”) with no redirect. When the address ends in#contact, on load or later, the form scrolls into view and its heading takes focus. - Analytics. Each submit the server answers sends
contact_form_submittedwith only itsoutcome:accepted,rate_limited(the daily cap) orinvalid. Nothing the visitor typed goes with it. - As the guest.
ContactFormControllervalidates the input and runsSubmissionGuard.check, then publishes oneContact_Form_Submission__e. The guest has no Lead permission at all. - The Lead.
ContactFormSubmissionTriggerruns as the Automated Process user and callsContactFormLeadWriter, which inserts each Lead in system mode withDatabase.DMLOptions: the default assignment rule, the auto-response only when the event asks for it, and duplicates allowed so a returning visitor is never refused.LeadSourceis “Portfolio Website”. The hop through the event is needed because the auto-response rule fails in the guest’s own context. - The owner’s email. Once the Leads are saved,
ContactFormLeadWritersends each Lead’s owner one email rendered fromPortfolio_Contact_New_Lead: the visitor’s name, company, email and full message, and a link to the Lead. It goes to the owner’s user Id when the owner is an active internal user, else to thePortfolio_Adminusers, for example while the Lead stays with the Automated Process user. The assignment rule’s own notification is not sent from this context, so this is the only email per message. A failed send is ignored, because the Lead is saved and the in app arrival notice still goes (ADR-031 amendment of 2026-10-24). - Limits. The auto-response is asked for only on the first message from an address in 24 hours, so the form cannot be used to send mail to someone again and again. The form takes at most 20 messages in 24 hours in all, and says so when the cap is reached.
- Silent drops.
SubmissionGuarddrops a message when the honeypot is filled, when it was sent less than 3 seconds after the form appeared, or when the email, its domain or the text matches an activeSubmission_Block__crow (types Email, Domain, Keyword, IP Hash). The visitor sees the normal thanks, nothing is stored or sent, and the block’s hit count and last hit are updated. The same guard runs for the testimonial form. No visitor IP is used, because Apex cannot read one in a guest request. - Failures. A batch that fails to insert is retried by the event bus three times, then kept as
Contact_Form_Failure__crows with the reason, and thePortfolio_Adminusers get a custom notification. The visitor never sees an error. - Reminders.
LeadReminderServicestampsReply_Reminder_1_At__candReply_Reminder_2_At__cat one and two business days after arrival, using the org’s default Business Hours, and theLead_Contact_Form_Remindersflow sends thePortfolio_Contact_Remindercustom notification at those times unless the Lead’s status has moved on. - Block sender. The “Block sender” quick action on Lead (
Lead_Block_Senderscreen flow,SubmissionBlockAction) adds the Lead’s email or domain toSubmission_Block__cwith a reason, turns an existing block back on, and marks the Lead as spam (Spam__c) so its reminders stop. It is on the Lead layout’s action list, which the mobile app uses. - Admin objects.
Submission_Block__candContact_Form_Failure__chave tabs and are admin only, throughPortfolio_Admin. A starter list of disposable email domains is loaded by a data script that I run. - Email rules. The assignment and auto-response rules are set up in Setup (deployment prerequisite 16) and are not in source. Both owner emails, the new Lead email and the testimonial approval request (section 2.3), go to a user Id, which the Apex governor limits documentation says does not count toward the daily single email limit. No address is written in source.
- Sender name. Email the site sends shows the sender name “Ryan Bumstead”, set in source as
emailSenderNameinnetworks/Portfolio.network-meta.xml. The auto-response rule carries its own sender settings in Setup. The HTML auto reply (custom HTML with a plain text version) thanks the visitor by first name, promises a reply within two business days, links the resume and LinkedIn, and is signed “Salesforce Platform Architect”. It never repeats the visitor’s message.
2.17 Docs Page (c-docs-viewer)
[!NOTE] Per host, as of 2026-11-02. On the static site the documents are read from the source cache at build time, so a merged change shows after the next build (the site workflow runs after every production deploy and every six hours), not after the daily refresh alone.
Thirty-two documents on the site at /docs (route Docs__c, view Docs), listed in this order: the Executive Overview, the SAS, the Program Charter, the Technical Guide, Guardrails and Executable Governance, then the 27 full ADRs. The list shows them in three groups: Start here (two documents), Specifications (three) and Decisions (the ADRs), which is closed until opened and draws its rows only once it opens.
- Addresses.
/docslists the documents./docs?doc=sasand/docs?doc=adr-027open one, and a heading’s slug in the hash opens its section, for example/docs?doc=sas#6-2-lightning-web-components-lwc. - Content.
SourceController.getDocs()lists, andgetDoc(docKey)returns one document with its parts joined, both cacheable and read fromSource_Cache__cthrough the same system mode selector as View source. The documents are theSource_File__mdtrecords withSource_Key__cdocs, listed bySort_Order__c(the Overview 0, the SAS 1, the Charter and the Technical Guide 2, Guardrails 3, the ADRs after them). The Technical Guide is the recordSource_File.docs_technical_guide, and the group function lists it under Specifications with no code change.SourceCacheRefreshJobfills them from the repository’smainbranch once a day, never from GitHub on page load. A push tomainthat changesdocs/**also runs the Deploy to Production workflow’sdocs-cachejob: after the deploy job it waits 60 seconds, because the GitHub contents API caches for 60, runsscripts/refresh-docs-cache.apexin production, and fails unless every docs record reads 200 since it started (ADR-007 amendment of 2026-10-24). - Badges. Each row shows a badge from its key: “SAS”, “ADR-027”, and the short badges “OVERVIEW”, “CHARTER”, “GUARDRAILS” and “TECH GUIDE” for the four guides.
- Rendering. Only the lexer of marked 18.1.0 (MIT, vendored unchanged as the
Marked_Librarystatic resource) is used.docsRendererbuilds every element itself withcreateElementfrom a fixed tag list and text nodes, so no HTML from a document is ever parsed: an HTML block shows as text and an inline tag is dropped. A GitHub alert, a quote whose first line is only[!NOTE],[!TIP],[!IMPORTANT],[!WARNING]or[!CAUTION], renders as a callout with the type as a visible title androle="note", drawn as GitHub draws it: a left border and title in GitHub’s colour for the type (the--pf-alert-tokens, light and dark) and no fill. Any other quote stays a quote. A link keeps its target only when it ishttps, another listed document on this site, or a heading of the same document. A Mermaid block is drawn ahead of a deploy byscripts/render-mermaid.js, as a light and a dark SVG named by a hash of the block’s source, in theMermaid_Diagramsstatic resource. The drawings are never committed, only the resource’splaceholder.txtis. Both drawings show as images with the block’s text alternative (CSS shows the one for the visitor’s theme), and the source stays in a closed details element under them. A block with no drawing for its source shows the source in that element, with a link to the document on GitHub, which draws the diagram. The PR check renders every diagram, and the deploy renders them and then deploys the drawings on their own (ADR-035). Wide tables scroll inside their own box. - Headings. Each heading has an id by the docs slug (lower case letters, digits and hyphens), and the slug GitHub would give it still works, so the documents’ own contents links land. A followed link scrolls the heading clear of the header and moves focus to it. A fresh load of
/portfolio/docs?doc=<key>#<heading id>does the same once the document has drawn, and places the heading once more after the page settles, so a link from View source or the live data marker lands on its section. The Contents list renders only while it is open. - Analytics. Opening a document sends
docs_openedwith its key.
2.18 Page Intro (c-page-intro)
The five pillar pages (Business Architecture, Solution Architecture, API Lab, AI Lab and Delivery), and the Skills and Resume pages, open with c-page-intro: eyebrow names the pillar, heading is the page’s one h1, lead is a larger paragraph, and designLabel and designUrl make a “Read the design” link to the pillar’s SAS section on /docs (5.1 to 5.5). The h1 is larger than every section heading below it.
2.19 By the Numbers (c-by-the-numbers)
[!NOTE] Per host, as of 2026-11-02. On the static site the counts are as of the last build. The Salesforce site reads them when the page loads.
The Delivery page shows a “By the numbers” panel right after its intro, above the checklist, the roadmap and Latest Updates, with its own h2.
- What it shows. The production deploy runs recorded (the
Pipeline_Run__crows of the deploy workflow, passed or failed), the newest deploy’s Apex tests and Jest tests each with one plain line on what they prove, the API Lab evaluator tokens issued “since” the month the first was issued, the cover letters written and the API Lab requests sent “since” the month counting began, a closed “What the tests cover” disclosure, and a table of approved testimonials by vibe once one is approved. Every number is text in a list or a table, so none is carried by colour alone. It shows a loading line and an error line like the site’s other cards. While the counts load, a hidden, unread copy of the loaded tiles holds their space (.stats-pending,aria-hidden), so the checklist and everything below it no longer jump when the counts arrive. Measured on the integration site on 2026-10-27 under 4x CPU and slow 4G, the tiles’ block grew from 130 px to 947 px when their counts loaded, and that moved the checklist down about 817 px. With the placeholder, the checklist moves 233 px when the counts land, the rest being the “What the tests cover” line and the approved testimonials table (measured at 412 px wide with the same throttling). - What the tests cover. The disclosure lists the Apex and Jest test groups with their counts. The counts are in
testGroups.js, taken from the repository’s test files on the date written beside them (COUNTED_ON), and move only when someone recounts. The Jest line of the panel appears only once a deploy has published a Jest count. - Count up. When the counts scroll into view after the visitor has scrolled, a copy hidden from assistive technology counts up from 0 over about 600 ms while the real number stays in the text, so a screen reader reads it once. Nothing counts at the top of the page or under reduced motion.
- Read. One cacheable call,
PortfolioStatsController.getStats(), which returns counts only. Each count comes from a query definition read of an Id and at most one grouping field or date (thePortfolioStatsController_*records, section 1.7) and is counted in Apex. The issued token read also takesCreatedDate, andtokensSinceis the UTC day of the earliest one. Tokens have their own month because their records start before the usage counters do (in production the first token is 2026-10-21 and the first counter row 2026-10-27). No row, name, email or free text leaves the class. The site guest needs only class access. - Waiting testimonials. The number waiting for approval is not part of the answer. A visitor has no use for it, and I read it in the Testimonials Waiting Approval report. Approved testimonials are read in user mode with sharing, the rows the wall shows.
Pipeline_Run__c,API_Access_Request__candUsage_Counter__chave no guest grant, so their reads run in system mode inside awithout sharingselector that returns counts. - Usage counters.
UsageCounterWriterkeeps oneUsage_Counter__crow per UTC day and kind (Cover_Letter,Api_Lab_Request), keyed “kind:yyyy-mm-dd” inKey__c. It reads the day’s row withFOR UPDATE, adds one and writes it, andApiLabControllercalls it last, after the callout and after the visitor’s result is in hand. Every error is caught and logged, so counting can never fail the visitor’s request. The row holds a kind, a day and a count, with no request content or personal data. Rows are never cleaned up. With no rows the panel shows 0 and no month. - Jest on each deploy.
Governance_Event__eandPipeline_Run__ccarryJest_Tests_Run__candJest_Tests_Passed__c, twins of the Apex count fields, andGovernanceEventHandlercopies them. The deploy workflow runs the Jest suite after the Apex tests and publishes the two counts on the same event. A Jest failure is reported and never undoes the deploy. - Private reports. For me, not the site: the Portfolio Owner Reports folder holds Contact Form Leads by Month, Testimonials Waiting Approval, Evaluator Token Requests by Outcome and Usage by Month, and the Portfolio Owner Overview dashboard in the Portfolio Owner Dashboards folder charts them. The dashboard runs as the logged in user, so its metadata names no user.
2.20 The 10 Minute Tour (c-tour-bar)
The tour is a journey over the five pillar pages. It is opt in, and nothing tour related renders on any page unless a tour is running.
- Start. The hero’s “Take the 10 minute tour” button and the first row of the header’s “How it’s built” menu both call
startTour, which sets one session storage key,portfolio-tour, to “1” and sends aportfolio-tour-startdocument event, then the link goes to the first stop as a plain link. The key says a tour is running and holds nothing else. Nothing goes in the address. If storage throws, no tour starts, no bar shows and the site works as it did without one. - The stop is worked out from the page path against the live pillars in
c/pillarData(A/business-architecture, B/nonprofit-solutions, C/api-lab, D/ai-lab, E/delivery). On a page that is not a stop the bar shows nothing and the tour waits for the next stop page. - The bar.
c-tour-baris placed byportfolioThemeLayout. It shows a progress row (“Stop 3 of 5, API Lab” with the stop’s minutes, announced politely and nothing else is live), the stop’s task line as a button, Back and Next as links to the stops either side, and End tour. The task line for each stop istourinc/pillarData, at most 70 characters. Next on the last stop is Finish, which shows “That’s the tour. Thanks for taking it.” and a Close button, centred on one row at every width. It has no links of its own, because the header already links Resume, Contact and Docs. The ending goes away when it is closed or on the next page. End tour removes the bar for the rest of the visit. - Minutes.
tourMinuteson each pillar inc/pillarDatais A 2, B 3, C 1, D 3 and E 1.TOUR_MINUTESis their sum over the live pillars, 10, and “10 minute tour” is computed from it, never typed. These are the minutes the plan asks of a careful reader. They are not a measurement. How long a careful reader takes on the live site is measured after the deploy and is not stated here. A run of the timing script on the integration site gave 4.7 minutes, but the API Lab could not reach the API there and no letter was asked for, so that is not the figure. - Show me. The task line sends a
portfolio-tour-show-medocument event with the stop letter (showMeinc/tourShowMe). The one component that owns the stop’s control listens (onShowMe), scrolls to its own element markeddata-tour-anchorand focuses it (revealAnchor). A is the first image link inc-project-asset-gallery, B the first weight slider inc-decision-matrix, C the Execute button inc-api-tester, D the job description field inc-cover-letter-generatorand E the In Progress tab or column inc-roadmap-viewer. No component reads another’s DOM, and a press with no listener does nothing. On the integration site B to E focused their controls in both engines, and A found no anchor there because the gallery is hidden with no assets in the scratch data [unverified that this is the only cause]. - Layout. From 768 px up the bar sticks under the header. Below that it is pinned to the bottom of the screen and the layout pads the page by the bar’s height (127 px at 390 px wide on the integration site, 2026-10-27). The header’s brand, divider, arrow, theme button and menu button moved 0 px with the bar on, at the top and scrolled, in both engines (a header position probe, 2026-10-27).
- Focus and motion. The bar follows the header in the keyboard order. When the visitor moves by the bar, focus lands on the new page’s
h1on arrival and is never trapped in the bar. End tour and Close tell the layout so focus does not drop with the bar. The bar has no animation of its own, and Show me scrolls smoothly unless the system asks for reduced motion. - Analytics.
tour_step_viewed{stop,pillar},tour_exited{stop} andtour_finished(section 2.8). - Smoke.
scripts/live-smoke.jswalks all five stops and the ending, andscripts/check-smoke-routes.jsand its test change with the views.
2.21 Native Form Controls (c/nativeControls)
Salesforce’s base form components draw at 32 px in a site whose own controls are 44 px, so rows of mixed controls misaligned by up to 46 px (the alignment audit of 2026-10-27). ADR-037 records the decision.
- One CSS only module. A component adds
@import "c/nativeControls";at the top of its CSS and usesnc-control,nc-button(nc-button_secondaryfor a secondary action),nc-field,nc-label,nc-hint,nc-errorandnc-required. The module sets height, fill, edge and focus from the--pf-tokens, and a component’s own CSS sets layout only. - What a replaced control keeps. A visible label tied by
forandid,requiredwith anaria-hiddenasterisk, the email check announced to screen readers (aria-invalid,aria-describedby), keyboard use and a focus ring. A nativeselectfollows the theme throughcolor-scheme, set from the--pf-color-schemetoken (light or dark) in the theme layout and the site stylesheet. - Moved. 13 controls, counted from the base component tags in the four components before and after: the API Lab’s endpoint combobox, Advanced query text, token Email and Purpose, the AI Lab’s Company Name, Job Title and job description, the resume’s “Tailor for” combobox with its Download PDF and Copy ATS text buttons, the code viewer’s button, and since 2026-10-30 the resume’s two include checkboxes (ADR-037 amendment). The contact and testimonial forms were already native and are not moved.
- Known. WebKit draws a native select at 48 px against Chromium’s 44.
config/jest.config.jsmapsc/nativeControlsto its CSS file, and a second CSS only module needs its own line.
2.22 The Cover Letter Page and PDF
[!NOTE] Per host, as of 2026-11-02. On the static site the page posts to the
cover-letterfunction, which calls the site actions endpoint as the edge user. The Agentforce, Gemini and Local Template chain is the same code in Apex. WithFEATURE_COVER_LETTERset torecordedthe page shows a saved sample letter, plainly labelled as a sample, and calls nothing. PDF and copy run in the browser on both hosts.
- The sheet.
c-cover-letter-generatorshows the letter on one sheet of paper at a printed page’s width, centred, with the same paper, ink and header as the resume preview (the layout values come fromc/documentPdf, the same source the resume uses). The template letter that serves when no model answers gets the same sheet. Contact details stay out of the letter body. - Download PDF writes the header and the letter as selectable text with the resume PDF’s header (the same five links), named
Ryan-Bumstead-Cover-Letter-<Company>.pdfwhen the company is known. Copy text copies the letter alone. Both buttons are native controls (section 2.21), and the Copy result is announced. Read with a PDF text extractor on the integration site, both PDFs hold real text in Inter and no images, and carry the same five header links (2026-10-27). - One shared module.
c/documentPdfloads jsPDF fromjsPDF_Libraryand the two Inter files fromPortfolio_Fontson the first download, and provides the header writer, the block writer, the file name helper and the layout values. The resume and the letter both import it (section 2.2 for the proof the resume did not change). - Analytics.
cover_letter_downloaded{provider} andcover_letter_copied{provider}, whereprovideris Agentforce, Gemini or Local. No parameter holds the job text or the company. - Tour. The job description field is stop D’s anchor (section 2.20).
- Length. The model is held to a word limit line in the prompt (section 1.3).
2.23 Storage Snapshot and Site Usage
[!NOTE] Per host, as of 2026-11-02. On the static site the footer’s Storage row arrives through the
glass-boxfunction with the rest of the live reading (section 2.6).
- The snapshot.
StorageSnapshotJob(a Queueable) readsOrgLimitsfor data and file storage, estimates the data by area from a record count per object times 2 KB (Salesforce shows storage by object only on its Setup page, so the figure is an estimate), sumsContentVersionsizes (latest versions, grouped by file extension into kinds) withDocumentandAttachmentsizes, and reads the last 7 days of site usage from the saved report. The areas are Site content, API and rate limiting, Contact and testimonials and Delivery telemetry. No object,Leadincluded, gets a row of its own. A part that cannot be read is left out and the rest is saved. Every query runs in system mode, because the figures are org totals. - Storage.
StorageSnapshot.saveoverwrites oneStorage_Snapshot__crow (Key__c“current”,Captured_At__c,Payload__c). Only aggregates are kept. - When it runs.
IntegrationCacheRefreshScheduler, which already runs at :00, :15, :30 and :45, enqueues the job whenStorageSnapshot.isDue()says the stored snapshot is older than theStorage_Snapshot_Hourssetting (1, range 1 to 24). No new schedule or scheduled job slot is used. - How the footer reads it.
FooterController.getSystemSnapshot()callsStorageSnapshot.read(), which hands the aggregates to any caller, the site guest included. The guest has no access to the object, so awithout sharinginner class reads the row, and only the payload’s aggregates leave. No permission was added for the guest.Portfolio_Admingets the object and its two fields. - Site traffic is off. The 7 day site usage readings leave
read()only when theFooter_Site_Traffic_Visiblesetting is 1, and it ships at 0. - The usage report. The
Site_Usage_Historyreport type (the Site object joined throughUsageHistory) and the “Site usage, last 30 days” report in the private Portfolio Owner Reports folder, grouped by site, limit type and day with usage summed. Usage history is report only, so it cannot be queried with SOQL. - What production showed. Read on production on 2026-10-27, the Visualforce API site
Portfolioshowed Origin Bandwidth at most 625,486 in a day and Request Time at most 4,736, the LWR sitePortfolio1that serves every page showed no rows at all in 30 days, and neither site had Cache Bandwidth or Cache Page Views rows. The units are bytes and milliseconds according to the help article [unverified for this report]. - Open question. Whether LWR traffic counts toward the Developer Edition’s 500 MB a day per site, the separate 48 TB a year CDN allowance, both or neither is not known. No Salesforce source answers it, the limits articles never mention LWR, and the empty
Portfolio1rows are the only evidence. Whether LWR usage reaches the usage history at all is also not known. If the limit is passed the site stays up with no service level and shows the Limit Exceeded page if the platform serves it for an LWR site [unverified]. I decide what to do with that risk. - Not claimed. No storage figure from production is stated here, because the snapshot has not run there. The Storage line shows only once a stored snapshot exists.
2.24 Live Data Marker (c-data-source)
Every data driven section says plainly where its data comes from and when it was read. On the Salesforce site that is “rebuilt from Salesforce records each time the page loads, not a stored copy”. On the static site (deployed at its *.pages.dev address, cutover pending) the marker reads “From Salesforce, built
- The control.
<c-data-source source-keys="ResumeController_Roles,ResumeController_Skills"></c-data-source>sits in the section’s title row, beside View source where there is one.source-keysnamesQuery_Definition__mdtrecords.objectsnames objects instead, for a section that reads through GraphQL (the skills page passesSkill__c), and is served only when an active definition reads that object.hide-countsshows the sources without numbers, for a section where a row count would mislead: the numbers tiles, the GitHub and Jira caches (one row per refresh), and the cover letter’s grounding (a ranked handful, not every record). - What it shows. A button reading “Live from Salesforce” with up to three counts, such as “8 roles, 8 skills, 6 certifications”. Opened, a panel says the section is not a stored copy, lists each source by name with its count, and links to the full data model. When any source reads an object the ERD draws, a “See this section’s data model” button opens a modal dialog built like View source, holding
c-schema-visualizerwith the full data model and those objects highlighted. On a phone (under 768 px) the visualizer shows a text list, and with objects highlighted that list holds only those objects and the objects they link to directly (a platform self lookup does not count as a link), the section’s own objects first. Its caption says the objects are “listed” and no longer “highlighted among” the full count, and it leaves out its own heading because the dialog already has one. The dialog ends with a “See the full data model” link, shown under 768 px only. Desktop, tablet and the full data model page are unchanged. The visualizer renders only while the dialog is open, Escape, Close or the backdrop close it and return focus to the marker, and on a phone the dialog fills the screen. Every visible string is in theWORDINGblock ofdataSource.js. The button reportsaria-expanded, Escape or Close returns focus to it, and on a phone it wraps and the panel opens in the flow under it. A panel that would run past the right edge of the window opens from the button’s right edge instead. In every section the marker sits at the end of the heading row, and on a phone under the heading from the start of the line. On the roadmap and in the resume, cover letter and skills cards it follows View source. In those three cards, on narrower screens (under 1024 px for the resume, 768 px for the cover letter and skills cards), the pair opens the card body instead, throughc/mediaWatch. - How it’s built. The panel’s footer also holds a “How it’s built” link that opens the Technical Guide on
/docsin the same tab. The section is looked up by the first entry ofsource-keys, then by the first entry ofobjects, then falls back to this section, 2.24 (GUIDE_SECTIONSindataSource.js). - Read.
DataSourceController.getSourcesandgetObjectSources(@AuraEnabled(cacheable=true)) return, per active definition, a label, the object API name, whether the ERD draws that object (SchemaService.modelObjectNames(), read once per call) and aCOUNT()built from the definition’s own object and filter, at most 12 names a call. A definition whose filter uses a bind variable returns no count, because a plain count would not match what the section reads. System mode definitions are counted in a privatewithout sharingselector, asResumeControllerreads them, so the site guest sees the true count. The label is the definition’sVisitor_Label__cwhen set (“roles” for experiences, so two sources on one object can read differently), else the object’s plural label. Forobjects, the first active definition on that object with a visitor label supplies it. Descriptions, field lists and filters never leave the class. - Where it is placed. The resume, the home timeline and case studies, the testimonials wall, by the numbers, the Delivery changelog and roadmap, the skills page and the cover letter.
2.25 The Static Build and the Shims
The static site in web/ (ADR-041) compiles the same LWCs the Salesforce site runs. It is not a second copy.
- The build.
web/build/build.mjsreads the org through the export (or the sample fixtures inweb/fixtureswhen no build user secret exists), works out a read plan for each route from the bundler’s module graph, compiles one bundle per route from the route’s own view file with LWC OSS, and writes the pages toweb/dist. A planned read with no data fails the build.web/build/leak-scan.mjsthen scans the output for session ids, undeclared fields, email addresses and the terms in theSITE_DENYLISTsecret.npm test --prefix webruns the build’snode --testunits, andnode web/build/check-export-coverage.mjschecks that the allowlist inSiteExportCallsis exactly the set of build time calls the public pages import (web/build/calls.mjs). - The export.
SiteExportResourceruns one allowlisted, read only controller method per call and returns its result with null fields left out. 26 calls, 82 reads, 15 pages (12 routes plus 404, 429 and 503). The parity testsSiteExportResourceTestandSiteExportTestimonialParityTestcompare each call as the guest and as the build user. - The shims. Platform imports resolve to modules in
web/src/shims. Apex methods answer from build time data. GraphQL wires answer from queries run at build time.lightning-card,lightning-spinnerandlightning-iconare native stand ins, so a card has no header icon on the static site. The SLDS anddxpstylesheets are not loaded, because the components use native shadow and those sheets never reached inside them. - The request time seam. A request time Apex call rejects in the browser until a handler is set.
setRequestHandlerinweb/src/shims/apex.jsis where the Pages Functions plug in, and it turns a function error into the{ body: { message } }shape the components already handle.web/build/calls.mjslists the request time calls. c/siteHost. A small module tells a component which host serves it (IS_STATIC). On Salesforce nothing changes. On the static site the live data marker reads “From Salesforce, built- First section first. Each page paints its first section before the rest of its components load, which is the rule of ADR-014 applied to the static pages (section 2.11 has the local numbers).
- Request time features. Seven site actions go through Pages Functions with a kill switch each. Which features are
liveorrecordedis read from/api/features. A production host reads an unset switch aslive, and any other host is alwaysrecorded(web/functions/_lib/features.js). The five switches are set in[vars]inweb/wrangler.toml, and a test inweb/test/functions.test.mjsfails if one goes missing. The Maintenance Guide section 3.12 says how to flip a switch. - Honest labels. Only a section that really calls Salesforce at view time says “Live from Salesforce”.
- What is not carried over. The sign in, register, password and news views have no static page.
3. Quality Assurance Checklist
[!TIP] One command.
npm run pre-launch-check -- --target-org <alias>runs the release checks in order and stops at the first failure: Prettier on the files changed againstorigin/main, the code analyzer (npm run scan), a delta dry run withRunLocalTestsagainst the named org (scripts/validate.js), the Jest tests, and the Apex test and coverage gate (scripts/coverage-gate.js, 90%). A Lighthouse run follows and is optional. The org is required and never taken from the CLI default, because the main checkout’s default is production (scripts/pre-launch-check.js). Name a scratch org, never production, since the coverage step runs every local test in the org it is given.
3.1 Key Verification Items
- LCP: Run a Lighthouse mobile audit (three runs per route, medians) and compare with the 2.5 s target and the recorded gate in §2.11.
- Coverage: every Apex test passes and org wide coverage is 90% or more (
npm run sf:coverage -- --target-org <alias>).pr.ymlenforces the same gate on every pull request. - Security: no Critical or High violation from Salesforce Code Analyzer (
npm run scan). - Internal references:
npm run refs:check(scripts/check-internal-refs.js) exits 0. It fails when a comment, a metadata description or a help text underpackages/, or any text indocs/,README.mdorSTATUS.md, names a build process code instead of saying what the code does. It scans the same pathspr.ymldoes, and the pre-commit hook runs it on each staged file under those paths. It reads only prose (comments per language,descriptionandinlineHelpTextin XML,descriptionvalues in JSON and YAML, the whole of a Markdown file), so a hex colour, anh1tag or an id in test data never counts, and it skips the vendored third party bundles it lists by name. An exception goes inscripts/internal-refs-allowlist.jsonwith the path, the exact line and a reason, and the list is empty today. Its own tests arenode --test scripts/check-internal-refs.test.js, andpr.ymlruns both inquality-check. - CI parity:
npm run ci:local(scripts/ci-local.js) passes before a push. It runs the same commands as thequality-checkjob inpr.yml, for the files changed againstorigin/main, and.husky/pre-pushruns it on every push. A SKIPPED step is not a pass, so read the summary.scripts/ci-local.test.jsfails whenpr.ymlgains a command the runner lacks (Maintenance Guide §3.1). - Live smoke test: after a production deploy, the Live Smoke Test passes (Maintenance Guide §3.2). By hand:
SITE_BASE=<site root> node scripts/live-smoke.js, with Playwright and its Chromium and WebKit browsers installed. - Accessibility: confirm nothing animates (the skills graph and the ERD are drawn once, after the layout finishes), so no pause control is needed (SAS Appendix G.2). Then confirm each route has a
mainlandmark and a skip to content link as its first stop, and run the screen reader checks listed in SAS Appendix G.6, which are mine to do on NVDA and VoiceOver. - Degradation Test: turn on “Simulate an outage” in the roadmap card (it drives Latest Updates too). Verify the cached data stays on screen with a “Simulated” badge (status
SIMULATED) and no request leaves for GitHub or Jira. - Limit Visibility: the footer shows CPU, heap and SOQL use against their limits. These are the figures of the
FooterController.getSystemSnapshotcall itself, so they cannot reflect another operation. Verify the line renders, and that it reads “status unavailable” when the call fails. - Analytics: Verify api_test_executed event fires in GA4 Real-Time view when using the API Tester. Until the production check after the deploy, delivery is proven only on a scratch copy of the site (§2.8).
- Verify the Skill Graph uses
lightning/uiGraphQLApi(the Network tab shows a/graphqlcall to the Salesforce domain). The Roadmap reads Apex over the cache (DeliveryController.getRoadmap), so it makes no GraphQL call - AI fallback: with the Gemini API unavailable,
POST /papi/ai/generatewithtype: CoverLetterreturnsprovider: LocalandfallbackOccurred: true, and the site page shows the template letter with the reason. After three Gemini failures the footer shows “Gemini: circuit open until HH:MM” and the next letter skips Gemini.
3.4 Concrete Testing Example
Here is a real test from the repository, GitHubCacheRefreshJobTest.shouldInsertNothingWhenGitHubFails (packages/portfolio-site/main/default/classes/GitHubCacheRefreshJobTest.cls). It mocks a GitHub outage and proves the degraded mode in section 1.1: the job makes one callout, stores nothing, the page keeps serving the last good commits, and the github circuit breaker counts the failure. useRepo and newestRow are helpers in the same class, and CountingMock is its HttpCalloutMock, which counts the calls it answers.
@IsTest
static void shouldInsertNothingWhenGitHubFails() {
useRepo('o/r');
CountingMock mock = new CountingMock(503, '{"message":"down"}');
Test.setMock(HttpCalloutMock.class, mock);
Test.startTest();
String outcome = GitHubCacheRefreshJob.refresh();
Test.stopTest();
Assert.areEqual(GitHubCacheRefreshJob.OUTCOME_FAILED, outcome, 'Outcome');
Assert.areEqual(1, mock.calls, 'The call was made');
Assert.areEqual(
1,
[SELECT COUNT() FROM Github_Cache__c],
'Nothing inserted'
);
Assert.isTrue(
newestRow().JSON_Payload__c.contains(GOOD_SHA),
'The newest good row is untouched'
);
Assert.areEqual(
GOOD_SHA,
DeliveryController.getRecentCommits(null).items[0].sha,
'The page keeps serving the last good commits'
);
Assert.areEqual(
1,
CircuitBreaker.snapshot()[1].failures,
'The github breaker counted the failure'
);
}