Salesforce Platform Architect Portfolio

Governance, Architecture, and Living Documentation

Technical Guide

Table of Contents


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_API Named Credential, authenticated by the GitHub_Auth External Credential (a read-only token in the GitHub_Principal principal). Authenticated calls get 5,000 requests an hour.
  • Caching Strategy (ADR-007):
    • IntegrationCacheRefreshScheduler runs at :00, :15, :30 and :45 and enqueues GitHubCacheRefreshJob (Queueable, Database.AllowsCallouts, because scheduled Apex cannot call out synchronously).
    • The job reads the latest commits on main and inserts one row into the Github_Cache__c custom object, with the mapped commit list in JSON_Payload__c.
    • The same scheduled run also enqueues SourceCacheRefreshJob when a View source file is due, which fills Source_Cache__c from the GitHub contents API (section 2.14). It needs no schedule of its own.
    • Guests never call out. The c-changelog component reads the newest row through DeliveryController.getRecentCommits(simulate).
  • Degraded Mode:
    • A failed refresh inserts nothing, so the last good list keeps showing with its age.
    • Three consecutive failures open the github circuit breaker for 30 minutes, refreshes are skipped while it is open, and DeliveryController reports DEGRADED.
    • The cleanup batch keeps the newest row per cache object, so an outage never empties the page.
  • Logging. When no Portfolio_Config__mdt record names a repository, and when a refresh fails, GitHubCacheRefreshJob.refresh writes one line through PortfolioLog.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 the github breaker.

1.2 Jira Integration (Roadmap)

  • Authentication: Basic Auth through the Jira_API Named Credential (Jira_Auth External Credential, Jira_Principal principal).
  • Refresh: JiraCacheRefreshJob, enqueued by the same scheduler, runs the JQL project = <key> AND labels = "portfolio-roadmap" ORDER BY updated DESC, where the key is Portfolio_Config__mdt.Jira_Project_Key__c (SPAP), and inserts one Jira_Cache__c row from up to 100 issues. JiraService.fetchRoadmap posts to /rest/api/3/search/jql, follows nextPageToken and stops at MAX_RESULTS (100) issues, the last page or MAX_PAGES (5) requests, whichever comes first. Only issues labelled portfolio-roadmap become public (my decision, 2026-10-21).
  • Component: c-roadmap-viewer reads the newest row through DeliveryController.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_Limit setting, section 1.6, sent as columnLimit) with its own “Show all N” button and state, which keeps aria-expanded and the count in the column heading. Done shows its newest 8 by updatedAt, 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 opens roadmapViewer.js, roadmapViewer.html and DeliveryController.cls (section 2.14).
  • Outage before and after. While the feed’s status is SIMULATED, c-roadmap-viewer and c-changelog each place c-outage-compare above their content (showCompare). It does not show for DEGRADED, 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’s ageMinutes or else fetchedAt. The two numbers come from the settings. DeliveryController.getRoadmap and getChangelog put failureThreshold and openMinutes on the feed, read from CircuitBreaker.failureThreshold() and openMinutes() (the Circuit_Failure_Threshold and Circuit_Open_Minutes settings, with the CircuitBreaker defaults of 3 and 30 when a record is missing). c-roadmap-viewer and c-changelog pass them to c-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_Access keeps them too, by my choice. Portfolio_Guest has neither.
  • Degraded Mode:
    • After three consecutive failures, the jira circuit breaker skips Jira refreshes for 30 minutes, and DeliveryController reports DEGRADED while 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”.
  • Logging. JiraCacheRefreshJob.refresh logs the same two cases (no config record, a failed refresh) through PortfolioLog.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). AgentforceService runs a Prompt Builder template. The decision of 2026-10-02 to defer it is lifted.

  • One dispatch. ContentGenerationController.dispatch tries 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 short detail such as HTTP 503 or the exception type, never a key or a URL with a query string. While the gemini or agentforce circuit breaker is open, that provider is skipped. Breaker state is written after every provider has been tried.
    • Agentforce is consulted only when AgentforceService.isAvailable is true: the Agentforce record of AI_Provider_Config__mdt has Enabled__c set, and the running user is not a site guest. The record ships with Enabled__c true, Timeout_Ms__c 20000, Hourly_Budget__c 120 and Model_Or_Template__c Portfolio_Cover_Letter. AgentforceService.generateContent runs that Prompt Builder template through ConnectApi.EinsteinLLM.generateMessagesForPromptTemplate, behind the PromptTemplateClient interface (the real client is EinsteinPromptTemplateClient, which names the application PromptTemplateGenerationsInvocable). 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 is sfdc_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__c calls per UTC hour (AgentforceServiceBudget), under the Developer Edition’s 150 LLM generations an hour. The count is one API_Rate_Window__c row with the key agentforce-hour|yyyyMMddHH, read before the template runs and written with the breaker results after the last provider. Past the budget the attempt is budget_exhausted and 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 the Gemini record, through the Gemini API and the Gemini_API Named 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. GeminiService reports a failure as text starting Error: Unable to generate content. or Exception: , which the dispatch treats as a failed attempt.
    • The Local Template serves a cover letter when Gemini fails. LocalTemplateService builds 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 503 ALL_PROVIDERS_UNAVAILABLE.
  • Callers. PAPIAiGenerate.generate (POST /papi/ai/generate) calls the dispatch for outside callers with a Bearer token and returns provider, model, fallbackOccurred, latencyMs, attempts and promptShown. The site’s cover letter page (coverLetterGenerator) calls ApiLabController.generateCoverLetter. While the Agentforce switch is on and its breaker is closed, that method first posts to POST /papi/ai/generate with provider set to Agentforce through the Portfolio_API Named 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) with ApiRequestContext.rateLimiter().checkKey before the dispatch and writes the count with ApiRequestContext.commitRateCounts() after it. The key has the papi-ai prefix 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 PortfolioLog with the path aura:ApiLabController.generateCoverLetter.
    • Portfolio_Guest holds the Gemini_Auth-Gemini_Principal principal for this. Agentforce is never consulted in process for a guest, only through the API call above.
    • The Agentforce call gets the Agentforce record’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/profileLinks for their links (LinkedIn, GitHub, Trailhead, Portfolio, Contact last). The footer’s “AI requests today” counts a letter that went through the API once (FooterController takes off the site-ai-proxied:cover-letter marker the AI Lab adds when the API counted the letter).
    • Grounding as the guest goes through a without sharing selector 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/generate through the Portfolio_API Named 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.
  • 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. PortfolioGroundingService adds 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 by generateCoverLetter and by ApiLabController.generateCoverLetter. Its lines, in order: the instruction, then the word limit line (coverLetterLengthLine, built from Cover_Letter_Min_Words (250) and Cover_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 is Instruction__c of the AI_Prompt__mdt record Cover_Letter, read with SOQL because getInstance returns only the first 255 characters of a long text field, and COVER_LETTER_INSTRUCTION in 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 a Length line (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_Length bounds only the maxLength a caller of POST /papi/ai/generate may ask for, and that endpoint builds its own prompt.
  • Persona. PortfolioGroundingService.inferPersona reads 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: maxOutputTokens from Ai_Max_Output_Tokens (8192, because the model’s reasoning counts against the limit and a limit hit while reasoning returns no letter) and temperature from Ai_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 detail when 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 event ai_generation_completed, and a request with no letter sends ai_complete_failure, both through the head script (section 2.8).
  • Footer. ContentGenerationController.providerStatus() returns each provider’s switch and state (on, off, not built) and circuitOpenUntil while 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 a GenAiPlannerBundle on 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, PortfolioAgentProjects and PortfolioAgentCredentials. Each reads through QueryDefinitions in system mode (the five PortfolioAgent* records, section 1.7), returns JSON, and passes every text through PortfolioAgentText.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. The Portfolio_Guide_Agent_Actions permission set grants class access to the three actions and nothing else, for the agent’s user.
    • The agent metadata needs API 65.0, so sourceApiVersion is 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.
    • PortfolioAgentService is 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 template AgentforceService runs.

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() returns v1 or v2 and isV2() is the shortcut. A missing X-API-Version is v1. v1 and v2 are accepted in any letter case, so V2 works, and anything else is a 400 that says to send v1 or v2. Every response carries X-API-Version in lower case with the version served and Vary: X-API-Version, which appendHeader adds to any value already set. The Link header 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 honours Vary is [unverified].
  • v2 shape. For the record endpoints, SAPITransformer.toV2 shapes a body recursively. It drops id, each xId that has an xKey beside it, and accomplishments. PAPITransformer.toV2 delegates 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, experienceKey and certificationKey take 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 Link with rel="successor-version" to https://ryanbumstead.com/api-lab/reference?version=v2, Deprecation (RFC 9745, @ and unix seconds) once Api_V1_Deprecation_Date is set, and Sunset (RFC 8594, an HTTP date) once Api_V1_Sunset_Date is set, with rel="deprecation" and rel="sunset" entries in Link for those dates (ApiRequestContext.addRetirementHeaders). On an old /v1/ URL the same method adds Link with rel="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 errorCode GONE and a message that gives the retirement date and says to send X-API-Version: v2, with the v2 reference in help (isGone). v2 never returns it. The three exceptions are POST /papi/auth/token, /sapi/health and /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 are Portfolio_Setting__mdt records read as yyyy-MM-dd text, 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_Date an old /v1/ URL answers 410 GONE with 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 the PAPIHealth self call send X-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 through PortfolioLog under the correlation id and answers 500 with a fixed message that holds the id. A failure while begin() 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’s APEX_ERROR. The rate count write in ok and error is 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, no X-Request-Id rule 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 by ApiSite.tokenUrl: Site.getBaseSecureUrl() inside a site request, else the org’s My Domain host with .my.salesforce.com replaced by .my.site.com, plus the site’s path prefix, in the form Api_Versionless_Urls selects. It is never ryanbumstead.com, which redirects into the site, and a client that follows a redirect can turn the POST into a GET.
  • Retryable and Retry-After. retryable is true only when the same request can succeed later with no change. A 503 that can recover and has no Retry-After gets 60 seconds from error, AI failures set their own (300 seconds, or the rest of the hour for the Agentforce budget), and the missing token credential is retryable: false with 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. offset above 2000 is a 400, because the platform allows no larger SOQL offset (ApiRequestContext.MAX_OFFSET).
  • Persona. persona matches in any letter case on every route and /papi/resume/generate echoes it as Admin, Developer or Architect.
  • The API Lab. The Lab checks the email by the server’s own pattern and checks the purpose before it sends. ApiLabController logs 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):

  1. Integration user (portfolio-api@ryanbumstead.com in the production org) on the Portfolio API Integration profile. 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.
  2. Integration_Access assigned to the integration user. It is the only source of data access: read-only on Account, 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__c and Testimonial__c, read on UserExternalCredential, 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 except PAPIAuthToken, which the integration user never needs) and External Credential principal access to Portfolio_API_Auth-Portfolio_API_Principal, without which PAPIHealth reports credential: DOWN, plus the Gemini_Auth, GitHub_Auth and Jira_Auth principals (ADR-027 amendments).
  3. “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.
  4. 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 principal Portfolio_API_Principal: the app’s consumer key and secret. Used through the Portfolio_API Named Credential.
    • Portfolio_API_Token_Auth (Custom, in source), named principal Portfolio_API_Token_Principal: authentication parameters Client_Id (the consumer key) and Client_Secret (the consumer secret). TokenExchangeService.exchangeToken sends them as merge fields in the token request body, because Apex cannot read the secret of an OAuth credential.
  5. Named Credentials (both in source, both the My Domain root https://ryan-bumstead-dev-ed.develop.my.salesforce.com):
    • Portfolio_API, authenticated by Portfolio_API_Auth. Callers append /services/apexrest/....
    • Portfolio_API_Token, using Portfolio_API_Token_Auth, merge fields allowed in the body, no generated Authorization header. Used only for /services/oauth2/token.
  6. Assign Portfolio_Guest to 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, SourceController and TestimonialController, plus four REST classes, the token endpoint PAPIAuthToken, the two routers PAPIRouter and SAPIRouter (each turns the guest away with a 401 on every route but the token route) and the spec endpoint OpenApiDocument
    • read on Experience__c, Experience_Skill__c, Project__c, Project_Asset__c, Project_Skill__c and Skill__c, create and read on Testimonial__c, and read on UserExternalCredential
    • read on Experience__c.Start_Date__c and Experience__c.End_Date__c, which the skills matrix needs for “years used”, on Skill__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_Principal and Gemini_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.

  7. Schedule the cache cleanup job daily at 03:00 org time (decision 2026-10-20). CacheCleanupScheduler is not scheduled by any deploy. CacheCleanupScheduler.execute checks CacheCleanupSettings.isEnabled() and then calls CacheCleanupOrchestrator.run(), which starts GithubCacheCleanupBatch and, through CacheCleanupOrchestrator.runRateWindowCleanup(), CacheCleanupRateWindowBatch. That batch deletes API_Rate_Window__c rows whose Window_Start__c is older than Cache_Cleanup_Settings__mdt.Rate_Window_Retention_Days__c (Default record: 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'.

  8. Confirm SAPIResume is 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 query SELECT Name FROM ApexClass WHERE Name LIKE 'SAPIResume%'. If it returns rows, delete both classes with a destructive deploy.
  9. Assign Portfolio_Admin to administrators who need to report on API_Access_Request__c (the token audit trail), API_Rate_Window__c and Pipeline_Run__c. It grants read and View All on the first two, read on Pipeline_Run__c, and read on their fields. Without it the fields are not readable, even in SOQL. It also grants the GitHub_Auth-GitHub_Principal and Jira_Auth-Jira_Principal principals, which the admin who schedules the cache refresh needs (my decision, 2026-10-21). Integration_Access keeps those two principals as well.
  10. Token settings. Portfolio_Config__mdt.Ryan_Bumstead.Token_Daily_Cap__c sets the daily evaluator token cap (TokenExchangeService.dailyCap, default 50 when blank); Last_Secret_Rotation__c records the last rotation date.
  11. Governance publisher. CI publishes Governance_Event__e as each workflow’s existing JWT user (deploy.yml and keep-alive.yml). Assign Governance_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.
  12. 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, :30 and :45, each enqueuing GitHubCacheRefreshJob and JiraCacheRefreshJob. With no Portfolio_Config__mdt record (a scratch org), each job logs and exits.
  13. 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.
  14. Graph engine resource. D3_Library (eleven d3 modules, 81,482 bytes in source, Public cache) replaces G6_Library. The delta deploy on main removes G6_Library and c/g6Loader as post-destructive changes (deploy.yml, “Generate Delta”). A full deploy does not delete anything, so after one, check with the Tooling query SELECT 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.
  15. Employer Hub demo objects. The four EH_ objects ship with the EH_Demo_Read permission 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).
  16. 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 at Portfolio_Contact_Auto_Response_Html in 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.
  17. Testimonial approvals. The Testimonial Approval process, its field update, the Portfolio_Testimonial_Approval_Request template 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 to System.debug when it is not (ADR-013 amendment on an optional install behind a seam). It finds Nebula’s System.Callable entry point, CallableLogger, at run time with Type.forName (namespace Nebula first, 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, ApiRequestContext calls RequestUtil.logRequest, which writes api 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 path aura:ApiLabController.generateCoverLetter.
  • Never fails a request. Nebula reports its own failures as isSuccess false, and the seam then falls back to System.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) or Text_Value__c (Text 255) holds the value, Min_Value__c and Max_Value__c (Number 18,4) its safe range, and Description__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) has getInteger(developerName, fallback), getDecimal(developerName, fallback) and getText(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 its Min_Value__c to Max_Value__c range gives the fallback, so a mistyped record never breaks a request. getInteger cuts a decimal to its whole part and gives the fallback for a number too large for an Integer. getText gives 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.overrides map sets a value without a record, and overrideNumber(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_Hours and Footer_Site_Traffic_Visible. Api_V1_Deprecation_Date and Api_V1_Sunset_Date came with API v2, and Api_V1_Path_Deprecation_Date and Api_V1_Path_Sunset_Date came with the versionless URLs. The last two ship empty. Portfolio_Config__mdt.Token_Daily_Cap__c still wins over Token_Daily_Cap_Default when 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 public maxPageSize(), defaultPageSize() (never above the max), sapiLimitPerMinute(), papiLimitPerMinute() and aiLimitPerMinute(). The 400 message for a bad limit names the values in force. ApiLabController checks the site cover letter’s bucket against aiLimitPerMinute().
    • TokenExchangeService: requestsPerMinute() and the daily cap default (the config cap first).
    • PAPIAiGenerate: the three maxLength bounds, with the default held inside the other two, and its 400 message names the bounds in force. PAPIHealth: the credential callout timeout.
    • CircuitBreaker: failureThreshold() and openMinutes(). 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 in c/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 to c/roadmapViewer as columnLimit, 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 as tabLimit.
    • getInteger drops 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__c and Filter__c (Long Text 4000), Order_By__c, Row_Limit__c, Access_Mode__c (USER_MODE or SYSTEM_MODE), Active__c and Description__c. The DeveloperName is Class_Purpose, for example SAPISkill_List.
  • The service. QueryDefinitions (inherited sharing) has run(name, binds, fallback), runPage(name, binds, fallback, pageSize, pageOffset) and count(name, binds, fallback). The fallback is a QueryDefinitions.QueryDefault built 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), with hasX false 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 whose Fields__c or Filter__c reaches 255 characters are read again in full with one SOQL query, because getAll() cuts long text there. Today that is SAPIProject_List and SAPIExperienceHighlight_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.Provider and returns its defaults by record name. validateAll() finds the providers through ApexTypeImplementor, checks every record and runs each one that passes once, and everyDeployedRecordPassesValidation (in SAPIQueryDefinitionsTest and SiteQueryDefinitionsTest) and ApiQueryDefinitionsTest.everyApiRecordPassesValidation fail when it returns anything. everySapiRecordShipsEqualToItsDefault, everyApiRecordShipsEqualToItsDefault and everySiteRecordShipsEqualToItsDefault compare 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, plus SAPIProject_Detail for a single project). The Process API and the API Lab options have 27: PAPIProfile, PAPIResume, PAPIProjects, PAPITestimonialFeed and ApiLabOptionsController. 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) and PortfolioStatsController (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, /projects and /testimonials now 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 PAPIProfile and PAPIResume and the roles in ResumeController, 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. PortfolioAgentService keeps 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 read FOR UPDATE (RateWindowLimiter), the counters in SubmissionGuard and TokenExchangeService, CircuitBreaker, the cache cleanup batches, GovernanceEventHandler, ContactFormLeadWriter, LeadReminderService, and every read of custom metadata or a setup object. ApiLabController, PAPIHealth, FooterController, DecisionMatrixController and SourceCacheRefreshJob hold no content read, so they did not change.
  • Tests. QueryDefinitions.overrides stands in for a deployed record, ignoreDeployedRecords hides the deployed ones and providerOverride sets the providers. QueryDefinitionsTest covers 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__c with Category_Order__c, its Project_Skill__c and Experience_Skill__c junctions, and each linked role’s Start_Date__c, End_Date__c and Is_Current_Role__c, through the lightning/uiGraphQLApi wire adapter. The site guest reads the two dates through Portfolio_Guest field permissions. Without them the query fails validation for the guest.
  • Engine contract (c/graphEngine):
    • loadGraphEngine(component) returns a Promise resolving to window.d3. It loads D3_Library through loadScript once 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 runs forceManyBody, forceCollide, forceLink, forceX and forceY toward options.anchors[node.group] (groups on a ring when absent) for a fixed number of ticks with a seeded random source, keeps every node inside options.width by options.height, and returns { nodes, edges } with x and y on each node and x1 y1 x2 y2 on each edge. The same input gives the same output.
  • Engine resource: D3_Library holds 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.js pins each segment’s SHA-256 to its npm package file.
  • Pan, zoom and drag: attachPanZoom(d3, svg, options) and attachNodeDrag(d3, elements, options) in c/graphEngine wire 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, and fitTo is 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 as translateExtent), 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, through panBy, 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.
  • 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 as Experience_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 sends skills_persona_filtered.
  • View source: a View source button at the top of the component opens its skillNetwork.js, skillNetwork.html and graphEngine.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_changed with the view on every switch, skill_selected with the skill and its years on a selection, and graph_layout_changed with action set to drag, fit or reset_layout, graph_zoom_used with control set to in, out or reset for the buttons, wheel when a Ctrl wheel or trackpad pinch ends, pinch once per two finger touch gesture, and keyboard on 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, through c/documentPdf, the module the cover letter shares (section 2.22). Moving that code out of resumeFormat.js changed 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 33 resumeBuilder tests 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 getResume shows 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.js sorts the roles once in buildViewModel: 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. getResume carries isPartTime on each role (Experience__c.Is_Part_Time__c, never null, from Query_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.html and ResumeController.cls (section 2.14).
  • Logic: The underlying Apex logic filters Experience_Highlight__c records 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 whose Skill__c.Persona_Tag__c includes 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 PAPI GET /papi/resume/generate applies 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__c separates 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 from Portfolio_Config__mdt.Credential_Verification_URL__c, a Text field so the value renders exactly as stored, through ResumeController as credentialVerificationUrl. With no value set, no link shows.
  • Timeline. ResumeController.getTimeline() returns work, education, certifications, verificationUrl, tabLimit and salesforceStartYear through the same without sharing, system mode selector as the resume, in a fixed shape with no email, phone, address or Owner_* field. Roles come current first, then by end year, start year and full time before part time, schools newest completion first. certifications lists only the current ones (Is_Retired__c false), newest earned first, each with its name, issuer, earned date and badgeFile, the file name inside the Certification_Badges static resource from Certification__c.Badge_File__c, or null when it is blank. Each role in work also carries isPartTime (from Experience__c.Is_Part_Time__c, never null) and highlights, up to three short lines in Sort_Order__c order, each the highlight’s Timeline_Text__c (Text 70) when set and its Description__c otherwise. highlights is empty for a role that ended before salesforceStartYear. These are additions only, so getResume keeps its shape. verificationUrl is Portfolio_Config__mdt.Credential_Verification_URL__c, tabLimit is the Timeline_Tab_Limit setting (section 1.6), 5 when the record is missing or out of its range, and no component reads it since the tabs were removed. salesforceStartYear is the Salesforce_Start_Year setting (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} and career_timeline_toggled {expanded} from the career line (section 2.12). Nothing sends timeline_tab_changed, timeline_expanded, career_timeline_filtered or career_timeline_show_all now.
  • 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 testimonial function 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. A recorded switch 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 fieldset whose legend reads 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 the Testimonial_Fragment__mdt set, and switching it clears the chosen phrases. A Recruiter, or a Fan or visitor, sees only the outcomes whose audience is Anyone.
  • The server. TestimonialController accepts only a known fragment of the chosen vibe, stores the composed sentence in Context__c and the fragment names and the note in fields of their own, runs SubmissionGuard (honeypot, minimum time, blocklist, 5 a minute across all visitors on the testimonial-submit key) and never sets Approved__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. TestimonialSubmissionTrigger runs as the Automated Process user, and TestimonialApprovalSubmitter submits 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 sets Approved__c through the Testimonial_Set_Approved field 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-wall on /testimonials shows approved testimonials from TestimonialController.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 the Guest_Read_Testimonials sharing rule, and the restriction rule hides unapproved ones (ADR-012 amendment).
  • Analytics. Each submit the server answers sends testimonial_submitted with the vibe and the outcome (accepted, rate_limited or invalid), and nothing the visitor typed or chose. Switching the wall’s filter sends testimonials_filtered with the new filter, all or professional.

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 on Account alone 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.js on 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.html and SchemaService.cls (section 2.14).
  • Analytics: erd_scope_changed with the scope, erd_node_selected with the object (section 2.8).
  • Tests: SchemaServiceTest proves, among others, that Project_Skill__c to Project__c is a master-detail edge, the four hidden objects are absent, an override moves an object, an EH_ 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-status function (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. With FEATURE_LIVE_READS set to recorded it stays on the build snapshot.

  • No browser subscription. lightning-emp-api is unsupported in LWR sites, and a guest has no token for Pub/Sub API, so the component imports no lightning/empApi and opens no channel (ADR-019 amendment on the event, the subscriber and the page).
  • Server side. CI publishes Governance_Event__e after a production deploy and after the daily heartbeat. GovernanceEventTrigger hands each batch to GovernanceEventHandler.handle, which upserts a Pipeline_Run__c record by Run_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 calls refreshApex when the tab becomes visible again (visibilitychange). There is no setInterval and no setTimeout loop.
  • Items. “Production deploy green”, “Org heartbeat today” (a heartbeat more than 26 hours old, the Heartbeat_Stale_Hours setting, is out of date) and “Integration caches under 30 minutes old” (the Governance_Cache_Minutes setting), each worked out in GovernanceController from evidence only: no run is “No signal yet”, a Failure is “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 a time element. Nothing is kept in localStorage.

[!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-box function (cached 60 seconds), and with FEATURE_LIVE_READS set to recorded it 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 and FooterController, at the caption’s 13 px through --view-source-size and --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 the CI_BADGES list in footer.js so 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 /contact page ({site base}/contact), built from the site base path in footer.js.
  • Analytics events. In the same caption row, after View source, c-analytics-events renders 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 only gitHubUrl and linkedInUrl from Portfolio_Config__mdt. It never selects my email or phone.
  • Deferred load (ADR-014): the component paints first and calls FooterController.getSystemSnapshot() from requestIdleCallback, 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, getQueries and 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’s API_Rate_Window__c rows whose key starts papi-ai:, through a without sharing inner 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 sends finops_quota_visibility.
    • Org quotas: DailyApiRequests and DailyAsyncApexExecutions from OrgLimits.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 failed OrgLimits read leaves the list empty, so the footer never shows a guessed number. DataStorageMB is 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/graphqlProbe with a dynamic import and times one small read (the first Skill__c Id) 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 in connectedCallback, 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/graphql since 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).
  • 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 sends storage_panel_opened once 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 the GitHub_Badge CSP 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 exports trackEvent(name, params) and EVENTS, the one event list. New events are appended to EVENTS, and nothing else in it changes.
  • This visit’s events. c/analyticsService also 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_script when the head script took it, gtag when the service called window.gtag itself, or not_sent when the page had no analytics tag. recentEvents() returns copies and onAnalyticsEvent(listener) tells a listener about each new event. The list is gone on reload. c-analytics-events lists them live in a dialog opened from the footer (section 2.6), and opening it sends analytics_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 sandbox window.gtag reads as undefined. So trackEvent dispatches a cancelable portfolio-analytics event 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), trackEvent calls window.gtag directly 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_used from both buttons, view_source_opened, view_source_file_selected, testimonials_filtered and contact_form_submitted with 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}, where outcome is live, template_unreachable or template_slow, and ai_complete_failure {message_kind}, where the kind is rate_limited, no_provider, invalid_input or other, 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 (the changelog source 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, and career_timeline_toggled {expanded: true or false} from Expand all and Collapse all (section 2.12). career_timeline_filtered and career_timeline_show_all stay 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, and tour_finished, no parameters, when the ending shows. tour_opened stays in the event list for past data and is no longer sent
    • Cover letter: cover_letter_downloaded {provider: Agentforce, Gemini or Local} and cover_letter_copied {provider}, never the job text or the company (section 2.22)
    • API Lab and API reference: postman_collection_downloaded {source: api_lab or reference}, postman_run_clicked {source} and curl_copied {example: token, health or data}, sent after a successful copy only (section 2.15)
    • Footer: storage_panel_opened, no parameters, once per page view when the panel first opens, and analytics_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_limited or invalid} (section 2.16), testimonial_submitted {vibe, outcome} and testimonials_filtered {filter: all or professional} (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, pinch or keyboard} (section 2.1)
    • Layout: motion_toggled stays 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 exported gtag('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, and https://www.googletagmanager.com is on the site’s script allowlist (Experience Builder “Trusted Sites for Scripts”), kept in source in the site bundle at digitalExperiences/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 a g/collect hit reached google-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:url and canonical are set by that view’s SEO assistant and reach the page when scripts run (the rendered page at /ai-lab shows its own og:title, og:url and canonical). Read with a LinkedIn user agent on the integration site on 2026-10-27, /ai-lab and 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 raw title or og tags 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: a description, the Open Graph tags (og:type website, og:site_name, og:title, og:description, og:url https://ryanbumstead.com/, og:image with og:image:width 1200, og:image:height 630 and og:image:alt) and the Twitter card tags (twitter:card summary_large_image, twitter:title, twitter:description, twitter:image). The image is the Portfolio_Share_Image static 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 :root in 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 portfolioThemeLayout sets data-theme="light" or "dark" on the html element and on its own host, which overrides the media query, and stores the choice under the localStorage key portfolio-theme. No stored value means “follow the system”. Changing it sends the GA4 event theme_changed. No other component reads that key or sets the attribute.
  • Motion. The site has no motion button. c/motionPreference follows the visitor’s system “reduce motion” setting alone: isReduced() reads it, and onMotionChange tells a component when it changes. The layout mirrors it on its own host as data-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 the portfolio-motion key 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 in styles.css and mirrored on the :host of portfolioThemeLayout. 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_Fonts static resource, loaded by @font-face with font-display: swap from 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 from c/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 carries aria-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. portfolioThemeLayout places c-tour-bar under 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’s h1 when a tour stop arrives.
  • The content region holds one screen. .region-content has min-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 /delivery from 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-page on 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 page BandwidthExceeded, 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 (the Portfolio_Favicon static 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. portfolioThemeLayout shows it once the page has scrolled past the home hero, or one screen height (BACK_TO_TOP_SCREENS 1, 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 sends back_to_top_used. The footer’s own “Back to top” link sends the same event.
  • Removed. The unused placeholderTerminal component, a pre-launch splash that no page placed. The SiteSamples static resource stays, because the site’s Visualforce error pages (for example FileNotFound and InMaintenance) and SitePoweredBy load it.
  • Shared pillar list. c/pillarData exports PILLARS and pillarByLetter, 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. The Project__c.Pillar_Label__c formula, which the API returns as pillarLabel, and the pillar page descriptions in the site views repeat the names, so pillarData/__tests__/pillarSources.test.js fails when either disagrees with c/pillarData. That test keeps “Nonprofit Solutions” as a former name.

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, and hasPlaceholders. It reads three Custom Metadata Types in the nonprofit-literacy package 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: computeTotals sums weight times score per option, rankOptions orders them, and findFlip searches 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 every Default_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_changed with the criterion, matrix_reset.
  • View source: a View source button at the top opens decisionMatrix.js, decisionMatrix.html and DecisionMatrixController.cls (section 2.14).
  • Tests: DecisionMatrixControllerTest fails 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/footer imported lightning/uiGraphQLApi statically, so about 237 KB of GraphQL adapter modules were preloaded with the layout on every route before first paint. The probe now lives in c/graphqlProbe, which the footer loads with a literal import("c/graphqlProbe") after load plus idle and renders through lwc:component (the footer declares lightning__dynamicComponent). Rendering the child behind lwc:if was 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/js after the window load event (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 /resume LCP 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/js script, 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-resources is 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: auto on 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. /delivery LCP 6496 and 6537, FCP 5170 and 4998, TBT 620 and 328. CLS read 0 in both. Only the /delivery TBT 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/dataSource imports c/schemaVisualizer with a literal import() and renders it through lwc:component, and its bundle declares lightning__dynamicComponent, the same pattern as the footer’s probe.
    • The project gallery’s GraphQL read moved into a child, c/projectAssetGalleryData, which c/projectAssetGallery loads after load plus idle. A skeleton holds the gallery’s space until the read settles. The gallery no longer imports lightning/uiGraphQLApi itself (ADR-021 amendment). The two pillar views carried about 1,165 KB uncompressed of the GraphQL adapter stack because of that import.
    • c/viewSource formats its fetch time with Intl.DateTimeFormat in a time element, in place of lightning-formatted-date-time, which pulled the i18n library into the layout bundle through this component. The site now uses lightning-formatted-date-time nowhere.
    • 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.
  • 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-extensions from 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 a lightning-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 five lightning-card uses with my own markup, copy the used rules into styles.css, check lightning-spinner and lightning-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. /skills still 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 is Contact.Hero_Line__c (Text 160) and the line under the headline is Contact.Value_Sentence__c (Text 255). ResumeController.getHeader returns both, trimmed, as heroLine and valueSentence. The title line shows only when it is set. The line under the headline falls back to the constant WHY_FALLBACK in heroBanner.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 from PROOF_POINTS in c/pillarData without waiting on Apex. Then the year comes from getTimeline (salesforceStartYear) and the count is the number of certifications in that same read, which leaves retired ones out. The constant ACTIVE_CERTIFICATIONS stays 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 from c/pillarData), sits on its own row after the counts. It starts the tour (startTour in c/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 reads ProjectCardController.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 from Trailhead_URL__c through getHeader as trailheadUrl), shown only when set and safe, in a new tab.
  • Career line. c-career-line is the work timeline, under the case studies, from ResumeController.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 before Salesforce_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-expanded flips, aria-controls names 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” when isPartTime is 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/motionPreference and 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-band follows 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 cacheable getTimeline read. 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-grid wires ProjectCardController.getHomeCaseStudies, which returns at most three cards: the Project__c rows with Home_Sort__c set, lowest first and then by name, in system mode. It never reads Is_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 a Query_Definition__mdt record equal to its code default (ProjectCardController_HomeCaseStudies, section 1.7). The card fields are Home_Title__c (Text 120, shown in place of the name when set), Client_Line__c (Text 120) and Home_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 from Client_Line__c and 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 /delivery in the seed data. Following the button sends case_study_link_followed with 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 id case-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 the SAPI_Spec and PAPI_Spec resources, “AI providers” counts the providers ContentGenerationController.providerStatus() reports as on, and “Apex tests” reads Tests_Passed__c and Org_Coverage__c from the newest Pipeline_Run__c with Status__c Success and a test count, which CI writes after each production deploy. The number replaces the first number in Headline_Metric__c and 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 shows NaN or 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__c and Case_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 retained deploy runs in Pipeline_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-testimonials reads TestimonialController.getHomeTestimonials, which returns nothing until Home_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-call is 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-section is no longer on the home page. Since 2026-10-30 it is on the Docs page (/docs). Since 2026-10-31 c-docs-viewer places 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 only c-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 in c-tour-bar (section 2.20). The section is one card for each live pillar from c/pillarData, in the case study card style: the pillar’s stat as the big number with its label when ProjectCardController.getPillarCards has 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 an h2, because c-docs-viewer holds the page’s one h1 in every state (the list, an open document and “Document not found”), so the outline never skips a level. It takes its look from the shared c/sectionHeading style, 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 /docs a click on it sets the address with history.pushState and fires popstate, which c-docs-viewer already 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 sends case_study_opened. A failed read leaves the cards as the static c/pillarData draws them.
  • Card images. Project_Card_Images holds one 640 by 360 WebP per pillar (pillar-a.webp to pillar-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 as resource:Project_Card_Images/pillar-x.webp, resolved by c/externalUrl toSiteAssetUrl.
  • 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_all and tour_opened are no longer sent. start_path_followed stays in EVENTS because 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.yml the 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 is scripts/cache-warmer.js. Its scheduled handler runs on a cron trigger every 5 minutes (*/5 * * * *) and sends one GET to TARGET_URL, https://ryanbumstead.com/, with Cache-Control: no-cache and Pragma: 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-cache headers 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.yml runs wrangler deploy --config config/wrangler.toml on a push to main that changes either file, or by hand, with the CLOUDFLARE_API_TOKEN secret.
  • Not yet checked. TARGET_URL is 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>. sourceKey is its required public property, and label is an optional one for the text on the button. It renders a real button labelled “View source”, or with the label when one is set (the footer’s analytics one reads “View analytics source”), with a 44 px target. Pressed, it opens a native <dialog> with showModal(), 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 in c-code-viewer with a link to it on GitHub that opens in a new tab with rel="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-size and --view-source-padding to 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 passes flat to c-code-viewer, which then draws no card and no padding, and c-view-source takes 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-wrap with overflow-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 describeFile in viewSource.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, from GUIDE_SECTIONS. A key not listed there shows no link.
  • Files per key. Source_File__mdt records (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 .js and .html and its main Apex class, or graphEngine.js for skillNetwork, which has no Apex. The keys are skillNetwork, schemaVisualizer, decisionMatrix, apiTester, resumeBuilder, roadmapViewer, footer, coverLetterGenerator and siteAnalytics. The siteAnalytics key lists analyticsService.js, analyticsEvents.js and analyticsEvents.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)) returns path, label, language, content, htmlUrl, fetchedAt and truncated per file, in sort order, from Source_Cache__c only. The guest has no permission on Source_Cache__c, so the controller reads it through a without sharing selector in system mode that returns those fields for the configured paths only. SourceControllerTest runs as the site guest and proves the read makes no callout.
  • Analytics. Opening the dialog sends view_source_opened with the source key. Choosing another file sends view_source_file_selected with the key and the file’s label.
  • Fill. SourceCacheRefreshJob (Queueable, callouts allowed) reads each configured path at main through GitHubService and the GitHub_API Named Credential and upserts one Source_Cache__c row per path. IntegrationCacheRefreshScheduler enqueues 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 (the Source_Refresh_Hours setting), 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 the github circuit 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-2 and 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 marked truncated. 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/prismHighlighter is the site’s one Prism module. loadPrism loads the Prism_JS resource once per page, highlight builds HTML only from escaped text (Prism tokens for a known grammar, the clike grammar for an unknown one, escaped plain text if Prism is missing or throws), and languageForPath maps .cls and .trigger to Apex, .js to JavaScript, .html and .xml to markup, plus CSS, YAML and JSON. Prism_JS carries Prism 1.30.0’s Apex, SQL, JSON and YAML grammars. c-code-viewer uses 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 hides Source_Cache__c through the Hide_Source_Cache record of Schema_Visualizer_Object__mdt.
  • Not yet checked in production: the first refresh through GitHub_API runs 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 ApiLabController as the guest. On the static site the Lab’s requests go through the lab-invoke and lab-token functions to the site actions endpoint, after the function checks the path against the API Lab catalog. With FEATURE_API_LAB set to recorded the 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/specs holds four YAML specs: salesforce-sapi-v2.yaml and portfolio-papi-v2.yaml at 2.1.0, and the deprecated v1 pair salesforce-sapi.yaml and portfolio-papi.yaml at 1.4.0. npm run specs:sync (scripts/sync-specs.js) generates every copy: the SAPI_Spec_V2 and PAPI_Spec_V2 YAML static resources the API reference reads (and SAPI_Spec and PAPI_Spec for v1), the SAPI_OpenAPI_V2 and PAPI_OpenAPI_V2 JSON resources /openapi.json serves (and SAPI_OpenAPI and PAPI_OpenAPI for v1), the API_Lab_Catalog resource the Lab reads, and the Postman collection, built from v2. The catalog’s entries hold the v2 params and responses and keep v1Params and v1Responses for the Lab’s v1 option. The v1 files are listed in V1_SPECS in 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:check writes nothing and fails when a copy differs, and a Jest test in apiReference fails the same way.
  • Path list. ApiLabController allows only the paths in API_Lab_Catalog, built from the specs’ GET operations, so a new spec path appears in the Lab with no code change. /papi/auth/token and /papi/ai/generate are 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 limit and offset, 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 from ApiLabOptionsController.getOptions() (cacheable): a name sends itself, a record’s name sends its Id, and persona and category list 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” follows X-Has-More. The response body is highlighted by c/prismHighlighter and checked against the path’s response schema: which documented keys are present, missing, or not in the spec. Each call sends api_test_executed with the path, the status and param_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 real button with aria-expanded and aria-controls. Opening or closing one sends api_lab_section_toggled. Desktop shows every section open, as before. A version picker offers “v2” (the default) and “v1 (deprecated)”. The Lab calls ApiLabController.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 (params for v2, v1Params for 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 include X-API-Version, Deprecation, Sunset and Link (RETURNED_HEADERS). ApiLabOptionsController.getOptions() returns the same public key lists under the *Id names for v1 and the *Key names for v2, so a picker never holds a Salesforce Id. The curl examples send X-API-Version for the picked version. ApiLabController.invoke stays and calls v1, for a caller built before invokeVersioned.
  • The reference. c-api-reference renders Redoc inside the component. Redoc looks its sections up on document, 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|v2 beside ?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) takes spec=sapi|papi and version=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 by scripts/build-postman-collection.js from the v2 spec (portfolio-papi-v2.yaml, and the collection sends X-API-Version: v2) with openapi-to-postmanv2, an exact dev dependency whose version lives only in package.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. --check writes nothing and exits 1 when the committed file is stale. The post step also adds the collection variables baseUrl and accessToken, bearer auth on the collection from ``, and a first request “Get an evaluator token” whose test script saves access_token and instance_url into those variables. The file never holds a token. The file downloads as ryan-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_Url holds an https link on postman.com or getpostman.com, read through ApiLabController.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_url and 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, and curl_copied sends 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 by sync-specs.js) stays in the repo for the System API. Run POST /auth/token first 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.js no longer draws Redocly’s hosted logo, which the site’s content security policy blocked. Before the change every visit to /api-lab/reference logged 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 in THIRD_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 contact function after a Turnstile check. The function sends a hash of the visitor’s address, made with a secret salt, and SubmissionGuard uses it for a block rule (an active Submission_Block__c of 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 Contact route (/contact, page title “Contact Ryan Bumstead”, its own description) holds c-contact-form with 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-call takes 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 /#contact still works: when the home page’s address ends in #contact, on load or later, c-contact-call sends the visitor on to /contact, replacing the history entry so Back does not land on the redirect again.
  • The form. c-contact-form sits under a heading with id="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_submitted with only its outcome: accepted, rate_limited (the daily cap) or invalid. Nothing the visitor typed goes with it.
  • As the guest. ContactFormController validates the input and runs SubmissionGuard.check, then publishes one Contact_Form_Submission__e. The guest has no Lead permission at all.
  • The Lead. ContactFormSubmissionTrigger runs as the Automated Process user and calls ContactFormLeadWriter, which inserts each Lead in system mode with Database.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. LeadSource is “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, ContactFormLeadWriter sends each Lead’s owner one email rendered from Portfolio_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 the Portfolio_Admin users, 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. SubmissionGuard drops 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 active Submission_Block__c row (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__c rows with the reason, and the Portfolio_Admin users get a custom notification. The visitor never sees an error.
  • Reminders. LeadReminderService stamps Reply_Reminder_1_At__c and Reply_Reminder_2_At__c at one and two business days after arrival, using the org’s default Business Hours, and the Lead_Contact_Form_Reminders flow sends the Portfolio_Contact_Reminder custom notification at those times unless the Lead’s status has moved on.
  • Block sender. The “Block sender” quick action on Lead (Lead_Block_Sender screen flow, SubmissionBlockAction) adds the Lead’s email or domain to Submission_Block__c with 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__c and Contact_Form_Failure__c have tabs and are admin only, through Portfolio_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 emailSenderName in networks/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. /docs lists the documents. /docs?doc=sas and /docs?doc=adr-027 open 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, and getDoc(docKey) returns one document with its parts joined, both cacheable and read from Source_Cache__c through the same system mode selector as View source. The documents are the Source_File__mdt records with Source_Key__c docs, listed by Sort_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 record Source_File.docs_technical_guide, and the group function lists it under Specifications with no code change. SourceCacheRefreshJob fills them from the repository’s main branch once a day, never from GitHub on page load. A push to main that changes docs/** also runs the Deploy to Production workflow’s docs-cache job: after the deploy job it waits 60 seconds, because the GitHub contents API caches for 60, runs scripts/refresh-docs-cache.apex in 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_Library static resource) is used. docsRenderer builds every element itself with createElement from 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 and role="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 is https, another listed document on this site, or a heading of the same document. A Mermaid block is drawn ahead of a deploy by scripts/render-mermaid.js, as a light and a dark SVG named by a hash of the block’s source, in the Mermaid_Diagrams static resource. The drawings are never committed, only the resource’s placeholder.txt is. 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_opened with 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__c rows 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 (the PortfolioStatsController_* records, section 1.7) and is counted in Apex. The issued token read also takes CreatedDate, and tokensSince is 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__c and Usage_Counter__c have no guest grant, so their reads run in system mode inside a without sharing selector that returns counts.
  • Usage counters. UsageCounterWriter keeps one Usage_Counter__c row per UTC day and kind (Cover_Letter, Api_Lab_Request), keyed “kind:yyyy-mm-dd” in Key__c. It reads the day’s row with FOR UPDATE, adds one and writes it, and ApiLabController calls 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__e and Pipeline_Run__c carry Jest_Tests_Run__c and Jest_Tests_Passed__c, twins of the Apex count fields, and GovernanceEventHandler copies 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 a portfolio-tour-start document 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-bar is placed by portfolioThemeLayout. 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 is tour in c/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. tourMinutes on each pillar in c/pillarData is A 2, B 3, C 1, D 3 and E 1. TOUR_MINUTES is 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-me document event with the stop letter (showMe in c/tourShowMe). The one component that owns the stop’s control listens (onShowMe), scrolls to its own element marked data-tour-anchor and focuses it (revealAnchor). A is the first image link in c-project-asset-gallery, B the first weight slider in c-decision-matrix, C the Execute button in c-api-tester, D the job description field in c-cover-letter-generator and E the In Progress tab or column in c-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 h1 on 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} and tour_finished (section 2.8).
  • Smoke. scripts/live-smoke.js walks all five stops and the ending, and scripts/check-smoke-routes.js and 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 uses nc-control, nc-button (nc-button_secondary for a secondary action), nc-field, nc-label, nc-hint, nc-error and nc-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 for and id, required with an aria-hidden asterisk, the email check announced to screen readers (aria-invalid, aria-describedby), keyboard use and a focus ring. A native select follows the theme through color-scheme, set from the --pf-color-scheme token (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.js maps c/nativeControls to 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-letter function, which calls the site actions endpoint as the edge user. The Agentforce, Gemini and Local Template chain is the same code in Apex. With FEATURE_COVER_LETTER set to recorded the 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-generator shows 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 from c/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>.pdf when 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/documentPdf loads jsPDF from jsPDF_Library and the two Inter files from Portfolio_Fonts on 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} and cover_letter_copied {provider}, where provider is 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-box function with the rest of the live reading (section 2.6).

  • The snapshot. StorageSnapshotJob (a Queueable) reads OrgLimits for 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), sums ContentVersion sizes (latest versions, grouped by file extension into kinds) with Document and Attachment sizes, 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, Lead included, 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.save overwrites one Storage_Snapshot__c row (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 when StorageSnapshot.isDue() says the stored snapshot is older than the Storage_Snapshot_Hours setting (1, range 1 to 24). No new schedule or scheduled job slot is used.
  • How the footer reads it. FooterController.getSystemSnapshot() calls StorageSnapshot.read(), which hands the aggregates to any caller, the site guest included. The guest has no access to the object, so a without sharing inner class reads the row, and only the payload’s aggregates leave. No permission was added for the guest. Portfolio_Admin gets the object and its two fields.
  • Site traffic is off. The 7 day site usage readings leave read() only when the Footer_Site_Traffic_Visible setting is 1, and it ships at 0.
  • The usage report. The Site_Usage_History report type (the Site object joined through UsageHistory) 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 Portfolio showed Origin Bandwidth at most 625,486 in a day and Request Time at most 4,736, the LWR site Portfolio1 that 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 Portfolio1 rows 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-keys names Query_Definition__mdt records. objects names objects instead, for a section that reads through GraphQL (the skills page passes Skill__c), and is served only when an active definition reads that object. hide-counts shows 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-visualizer with 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 the WORDING block of dataSource.js. The button reports aria-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, through c/mediaWatch.
  • How it’s built. The panel’s footer also holds a “How it’s built” link that opens the Technical Guide on /docs in the same tab. The section is looked up by the first entry of source-keys, then by the first entry of objects, then falls back to this section, 2.24 (GUIDE_SECTIONS in dataSource.js).
  • Read. DataSourceController.getSources and getObjectSources (@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 a COUNT() 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 private without sharing selector, as ResumeController reads them, so the site guest sees the true count. The label is the definition’s Visitor_Label__c when set (“roles” for experiences, so two sources on one object can read differently), else the object’s plural label. For objects, 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.mjs reads the org through the export (or the sample fixtures in web/fixtures when 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 to web/dist. A planned read with no data fails the build. web/build/leak-scan.mjs then scans the output for session ids, undeclared fields, email addresses and the terms in the SITE_DENYLIST secret. npm test --prefix web runs the build’s node --test units, and node web/build/check-export-coverage.mjs checks that the allowlist in SiteExportCalls is exactly the set of build time calls the public pages import (web/build/calls.mjs).
  • The export. SiteExportResource runs 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 tests SiteExportResourceTest and SiteExportTestimonialParityTest compare 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-spinner and lightning-icon are native stand ins, so a card has no header icon on the static site. The SLDS and dxp stylesheets 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. setRequestHandler in web/src/shims/apex.js is where the Pages Functions plug in, and it turns a function error into the { body: { message } } shape the components already handle. web/build/calls.mjs lists 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 live or recorded is read from /api/features. A production host reads an unset switch as live, and any other host is always recorded (web/functions/_lib/features.js). The five switches are set in [vars] in web/wrangler.toml, and a test in web/test/functions.test.mjs fails 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 against origin/main, the code analyzer (npm run scan), a delta dry run with RunLocalTests against 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.yml enforces 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 under packages/, or any text in docs/, README.md or STATUS.md, names a build process code instead of saying what the code does. It scans the same paths pr.yml does, and the pre-commit hook runs it on each staged file under those paths. It reads only prose (comments per language, description and inlineHelpText in XML, description values in JSON and YAML, the whole of a Markdown file), so a hex colour, an h1 tag or an id in test data never counts, and it skips the vendored third party bundles it lists by name. An exception goes in scripts/internal-refs-allowlist.json with the path, the exact line and a reason, and the list is empty today. Its own tests are node --test scripts/check-internal-refs.test.js, and pr.yml runs both in quality-check.
  • CI parity: npm run ci:local (scripts/ci-local.js) passes before a push. It runs the same commands as the quality-check job in pr.yml, for the files changed against origin/main, and .husky/pre-push runs it on every push. A SKIPPED step is not a pass, so read the summary. scripts/ci-local.test.js fails when pr.yml gains 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 main landmark 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.getSystemSnapshot call 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 /graphql call 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/generate with type: CoverLetter returns provider: Local and fallbackOccurred: 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'
  );
}