Maintenance & Operations Guide
Table of Contents
- 1. Security Rotation Policy (CI/CD)
- 2. Environment Hygiene (The “Clean Sweep”)
- 3. DevOps Architecture
- 3.1 Local Development Commands
- 3.2 Pipeline Triggers
- 3.3 Pipeline Strategy: Delta Deployment
- 3.4 Org Persistence
- 3.5 Changing an Operational Setting
- 3.6 Changing a Query Definition
- 3.7 Site Content the Owner Edits
- 3.8 Owner Emails and Testimonial Approvals
- 3.9 The Postman Collection and the Run in Postman Link
- 3.10 Setting the API v1 Dates and Retiring v1
- 3.11 Retiring the Old /v1/ URLs
- 3.12 The Static Site: Workflow, Secrets and Kill Switches
- 4. Disaster Recovery
- 5. Observability & Monitoring
Project: Salesforce Platform Architect Portfolio
Owner: Ryan Bumstead
Version: 1.0
Date: MVP – Q1 2027
This document outlines the operational procedures required to maintain the security, stability, and “Green Build” status of the portfolio architecture.
1. Security Rotation Policy (CI/CD)
To demonstrate enterprise compliance standards, the JWT authentication keys used for GitHub Actions must be rotated annually.
- Rotation Schedule: Every 13 months (400-day certificate validity) with mandatory 30-day overlap window.
- Execution window: within 30 days of certificate expiry or during Q4 maintenance, whichever comes first
- Key Strength: 4096-bit RSA.
- Rationale: Eliminates calendar creep and guarantees zero risk of expired credentials
1.1 Rotation Procedure
1. Generate New Keys
Run the following OpenSSL commands in your terminal:
Click to view OpenSSL Commands
```bash # 1. Generate 4096-bit Private Key openssl genrsa -des3 -passout pass:SomePassword -out portfolio.pass.key 4096 # 2. Strip Password (for CI/CD Server) openssl rsa -passin pass:SomePassword -in portfolio.pass.key -out portfolio.key # 3. Generate Certificate (Valid for ~13 Months) openssl req -new -key portfolio.key -x509 -days 400 -out portfolio.crt ```2. Update Salesforce Connected App
- Log in to the Dev Hub Org.
- Navigate to App Manager > GitHub Actions CI > Manage.
- Click Edit Policies.
- Under Digital Signatures, upload the new portfolio.crt file.
- Save. (Note: Updates can take 2-10 minutes to propagate).
3. Update GitHub Secrets
- Navigate to Repo Settings > Secrets and variables > Actions.
- Update
SFDX_JWT_KEYwith the content of the new portfolio.key.
3. Gemini API Key: Rotate every 90 days. The key is not on the Gemini_API Named Credential. It is the API_Key authentication parameter of the Gemini_Principal principal on the Gemini_Auth External Credential, which the Named Credential sends as the x-goog-api-key header. Setup → Named Credentials → External Credentials → Gemini_Auth → principal Gemini_Principal → Edit → set API_Key to the new key → Save. Then generate one cover letter on the site and check the badge names Gemini, not the Local Template.
1.2 External Client App Secret Rotation
The portfolio APIs authenticate with OAuth 2.0 Client Credentials issued by the Portfolio API External Client App (ADR-027). The consumer secret lives in the External Client App and in two External Credential principals:
Portfolio_API_PrincipalofPortfolio_API_Auth(OAuth), used by thePortfolio_APINamed Credential for the API Lab proxy and thePAPIHealthcredential check;-
Portfolio_API_Token_PrincipalofPortfolio_API_Token_Auth(Custom), whoseClient_IdandClient_SecretparametersTokenExchangeService.exchangeTokensends to the token endpoint to vend evaluator tokens (ADR-028 amendment). - Rotation Schedule: every 90 days, or immediately on suspected exposure.
- Blast radius: rotating the secret invalidates both principals until they are updated, and tokens already issued keep working until the shared session ends after 30 minutes without use.
Procedure
- Setup → External Client App Manager → Portfolio API → Settings → OAuth Settings → Consumer Key and Secret → Rotate (or Regenerate). Copy the new secret once.
- Setup → Named Credentials → External Credentials → Portfolio_API_Auth → principal Portfolio_API_Principal → Edit → paste the new secret → Save.
- Setup → Named Credentials → External Credentials → Portfolio_API_Token_Auth → principal Portfolio_API_Token_Principal → Edit → set the
Client_Secretparameter to the new secret (andClient_Id, if the consumer key changed) → Save. - Verify the outbound credential: call
GET /papi/healthwith a Bearer token and checkdependencies.credentialisUP.PAPIHealth.checkCredentialmakes one GET throughPortfolio_APIto/sapi/healthand reportsUPonly on HTTP 200. On failuredetails.credentialsaysanswered HTTP <status>,could not connectornot set up. It needs the caller to have principal access toPortfolio_API_Auth-Portfolio_API_Principal(granted to the integration user byIntegration_Access). From the site’s API Lab the check does not make that callout: the Lab’s call already came throughPortfolio_APIand marks itself withX-Portfolio-Caller: api-lab, sodetails.credentialreadsin use, this request came through it. A second callout from inside that request would be the org calling itself in a loop, which Salesforce refuses, and used to showcredential DOWNand an overallDEGRADEDin the Lab though nothing was wrong. - Verify token vending: run
scripts/api-smoke.shwith your own email; the token request must return 200. - Record the rotation date in
Portfolio_Config__mdt.Ryan_Bumstead.Last_Secret_Rotation__c.
Revocation (kill switch)
- Setup → External Client App Manager → Portfolio API → Deactivate. Every outstanding Bearer token, including evaluator tokens issued by
POST /papi/auth/token(ADR-028), stops working immediately. Reactivate after rotating.
2. Environment Hygiene (The “Clean Sweep”)
The Developer Edition org shares space with Trailhead challenges and standard Salesforce metadata. To prevent “Pollution” from failing the CI/CD pipeline, strict separation is enforced.
2.1 Organization Rules
- Authorized Code: Only metadata present in
packages/is authorized. - Standard Sites: Authenticated users are out of scope, so no portfolio feature uses a login. The site template’s own routes (
/login,/SelfRegister,/ForgotPassword,/news) and their standard controllers (for exampleLightningLoginFormControllerandCommunitiesSelfRegController) are still in source and on the site. I chose to leave them in place (2026-10-23).
2.2 Emergency Cleanup Protocol
If standard Visualforce pages or Apex classes lock the deployment (Dependency Hell), use the “Lobotomy Strategy”:
- Decouple: Overwrite the blocking Visualforce Page with empty code to remove Apex references:
<apex:page>
<h1>Feature Disabled</h1>
</apex:page>
- Deploy: Push the empty page to break the dependency.
- Delete: Run the
sf project delete sourcecommand for the Apex Class.
3. DevOps Architecture
3.1 Local Development Commands
To prevent “Commit Shame” (failing pipelines after pushing), developers must execute validation scripts locally before pushing to GitHub. The project uses npm scripts to abstract complex CLI commands:
npm run scan: Executes the Salesforce Code Analyzer (PMD/ESLint) and outputs a human-readable scan-results.html report (visualized in browser). Success Criteria: no violation at severity 1 or 2 (--severity-threshold 2)npm run delta: Generates the incremental package.xml in thechanged/folder by comparing local changes against origin/main.npm run delta:validate -- --target-org <alias>: runs the delta generation and then a dry run deployment of the delta withRunLocalTeststo the named org (scripts/validate.js). There is nonpm run validatescript.- Note:
scripts/validate.jsexits cleanly when the delta is empty, and sets the manifest API version fromsfdx-project.json. The org is required: the script never uses the CLI default.
- Note:
npm run sf:coverage -- --target-org <alias>: runs every local Apex test in the named org and fails on a failed test or org wide coverage under 90% (scripts/coverage-gate.js).npm run specs:syncandnpm run specs:check: regenerate every copy of the API specs frompackages/integration-api/specs, or fail when a copy differs (scripts/sync-specs.js). Edit the YAML there, never a copy. The pre-commit hook runs the sync, the API docs and the Postman collection for a staged spec and stages what they write (scripts/regenerate-spec-outputs.js, section 3.9).npm run docs:counts: fails when the ADR count in README or STATUS, or an ADR link in the indexes, disagrees withdocs/adr(scripts/check-doc-counts.js).npm run refs:check: fails when a comment, metadata description or help text underpackages/, or any text indocs/,README.mdorSTATUS.md, names a build process code. It scans the same paths as CI, and the pre-commit hook runs it on staged files there. It reads only prose and skips vendored bundles. Exceptions go inscripts/internal-refs-allowlist.jsonwith a reason (Technical Guide §3.1).npm run ci:local: the “before you push” command. It runs on your machine every check thequality-checkjob inpr.ymlruns, in the same order and with the same arguments (scripts/ci-local.js). The checks are the secret scan, Jest, the API docs check, internal references, smoke routes, the Postman collection, Mermaid diagrams and the Code Analyzer. By default it runs only the steps whose files changed againstorigin/main(the merge base, plus staged, unstaged and new files).npm run ci:local -- --allruns every step andnpm run ci:local -- --listprints the steps and which would run. Every selected step runs even after one fails, so one pass shows every failure, and the exit code is 1 if any failed.- Pre-push hook:
.husky/pre-pushrunsnode scripts/ci-local.js --changedand blocks the push when a step fails. The--no-verifyflag on the push skips it, for an emergency, and that call belongs to the owner. - Skips: a few commands need something a machine may lack, so the runner says SKIPPED and lists each skip again in the summary. A skip is not a pass. The Postman check needs the pinned
openapi-to-postmanv2dev dependency (runnpm install). The Postman probe needs the network. The Mermaid render needsMERMAID_CLI_DIR(the one line install is inpr.yml) and a Chrome or Edge, which the runner looks for throughPUPPETEER_EXECUTABLE_PATHand the usual Windows paths. The Mermaid tests and the alt text check always run. - Drift test:
node --test scripts/ci-local.test.jsreadsquality-checkinpr.ymland fails when a command there has no step inci-local.js. Add the step in the same change as the workflow edit. - Git worktrees: the hooks live in
.husky/_, whichnpx huskygenerates and git does not track, so a new worktree has none and git runs no hooks there at all. Runnpx huskyonce in each new worktree. In a worktree with no org pinned (no localsf config set target-org), the pre-commit hook skips the Salesforce validate step and says so, because the integration deploy and CI validate backend changes. The main checkout validates as before.
- Pre-push hook:
npm run pre-launch-check -- --target-org <alias>: the release checks in one command (Technical Guide §3).
3.2 Pipeline Triggers
- PR Validation (pr.yml): Runs on pull requests to
mainthat modifypackages/**,sfdx-project.json,package.json,scripts/**orpr.ymlitself. One run per PR at a time: a new push cancels the run still going, and a label event runs in its own group, so it never cancels a real run.- Quality Gate: every Apex test passes and org wide coverage is 90% or more (
scripts/coverage-gate.js --min 90). - Security Gate: no Critical or High violation from Salesforce Code Analyzer (
npm run scan), plusnpm run scan:secrets, the Jest tests andnpm run docs:verify. - Internal references: the
quality-checkjob runs the gate’s own tests and thenscripts/check-internal-refs.jsoverpackages/, the docs, README and STATUS, and fails the PR on any hit. The generated API pages indocs/api/oasare skipped, and a real false positive goes inscripts/internal-refs-allowlist.jsonwith its reason.
- Quality Gate: every Apex test passes and org wide coverage is 90% or more (
- Static Site (site.yml): builds the static front end in
web/from the org’s allowlisted export, deploys it to the Cloudflare Pages projectryanbumsteadand is described in section 3.12. It runs when Deploy to Production calls it after its deploy, smoke and docs cache jobs, on a push tomainthat changes onlyweb/**or the workflow, on every pull request that touchesweb/**, the site’s components, static resources, digital experience files or the docs, every six hours, and by hand. - Docs Internal References: a pull request to
mainthat changes only the docs, README or STATUS does not start PR Validation, so this workflow runs the same gate and its tests on its own, with no scratch org.- Site guest access: after the full deploy to the scratch org and before the tests, the job assigns
Portfolio_Guestto the scratch site’s guest user, as production’s guest already holds it. Guest test classes then never race each other to assign it. - Production check only: in scratch mode, after the scratch tests, the job builds the PR’s delta against
origin/<base>and validates it against production as a dry run withRunLocalTests. A component or a test that fails only in production stops the PR before the merge. It deploys nothing, and a PR with no metadata change skips it. In fallback mode the fallback step already runs this check.
- Site guest access: after the full deploy to the scratch org and before the tests, the job assigns
- Production Deploy (deploy.yml): Runs on a push to
mainthat modifiespackages/**(bothportfolio-siteandintegration-api),sfdx-project.json,package.jsonordocs/**, or by hand in delta, full or destructive mode. After a real deploy it runs every local test in production, then the Jest suite on the merged commit, and publishes aGovernance_Event__ewith the run, the commit and, when each run returned a summary, the Apex tests run and passed, org wide coverage and the Jest tests run and passed. A Jest failure is reported in the run and never undoes the deploy. It then publishes the Experience Cloud site and waits on the publish job until it reports Complete, so the smoke that follows checks what was just shipped. A publish that fails or runs past five minutes fails the run. A failed deploy publishes a Failure event. A push with no metadata change deploys nothing and publishes nothing.- Live smoke test: after a deploy that changed production, the
smokejob runs the Live Smoke Test workflow below. The run summary says Passed, Failed or Not run. - Docs cache: when the push changed
docs/**, thedocs-cachejob runs after the deploy job. A docs only push finds no metadata in its delta and deploys nothing first. The job waits 60 seconds, because the GitHub contents API caches for 60, runsscripts/refresh-docs-cache.apexin production as the workflow’s signed in admin, waits for the queued job, and fails unless every docs record reads 200 since it started. A failed refresh keeps the last good content on/docs. - Destructive mode (by hand only): removes from production the components named in a committed manifest folder under
manifest/destructive/(destructive_manifest_dir, holding an emptypackage.xmland adestructiveChanges.xml). It refuses any other folder and apackage.xmlthat names types, lists what it will delete, runs a check only deploy first and then the real one. It exists for a component deleted in source that no delta will ever carry.
- Live smoke test: after a deploy that changed production, the
- Postman Sync (postman-sync.yml): starts when a Deploy to Production run that a push to
mainstarted completes with success, and can be run by hand. It checks out the deployed commit, runsscripts/build-postman-collection.js --checkso a stale collection fails the run, thenscripts/postman-sync.js push, which needs thePOSTMAN_API_KEYsecret and thePOSTMAN_COLLECTION_UIDrepository variable. It is its own workflow, not a job inside deploy.yml, because GitHub issues badges per workflow and the site footer shows its badge. A smoke failure fails the deploy run, so the sync waits for the next good deploy or a run by hand. - Live Smoke Test (smoke.yml): called by Deploy to Production, run on its own once a day at 09:30 UTC (after the 08:00 heartbeat) so a break between deploys is caught, and runnable by hand from the Actions tab. GitHub emails the repository owner when a scheduled run fails. It runs
scripts/live-smoke.jsagainst the live site. Every route must answer 200 and show its key components, with no console error outside a short known list in the script, in Chromium and WebKit at 1400 by 900 and 390 by 844. It is read only: it loads pages, submits no form, clicks nothing and blocks Google Analytics so it never counts as a visitor. A failed row is loaded once more in a fresh context before it counts. On a failure it fails the run, uploadssmoke.json, and publishes aGovernance_Event__ewith workflowsmokeand status Failure, which lands as aPipeline_Run__crow keyedsmoke:<run id>. It undoes nothing. A pass writes no row.
3.3 Pipeline Strategy: Delta Deployment
To ensure efficiency and avoid hitting Salesforce Metadata API limits, the pipeline utilizes a “Delta Deployment” strategy using sfdx-git-delta.
- Validation (pr.yml): creates a one day scratch org and deploys the whole of
packagesto it, then runs every local test through the coverage gate. Only when the scratch org cannot be created does it fall back to a delta: the diff between the PR branch andorigin/<base>, deployed to production as a dry run withRunLocalTests, which changes nothing. - Production (deploy.yml): calculates the diff from
github.event.before, the commitmainpointed at before the push, toHEAD, so a push of several commits deploys all of them.HEAD~1is used only when the event has no earlier commit. It deploys strictly the changed metadata to the Production environment. - Destructive Changes: The pipeline is configured to respect
destructiveChanges.xml. If metadata is deleted from the repo, sfdx-git-delta will generate the destructive manifest, and the pipeline will automatically delete those components from the Org during deployment. A delta that only deletes still passes its emptypackage.xml, because the destructive flags need a manifest. A deletion that no delta carries, such as a component removed in a commit whose deploy has already run, goes through the hand run destructive mode in §3.2 with a manifest committed undermanifest/destructive/. - Cross-Platform Compatibility: The
package.jsonscripts useshxfor shell commands such as mkdir and rm, so they run the same way on both Windows (Local Development) and Ubuntu Linux (GitHub Actions Runners).
3.4 Org Persistence
- Heartbeat (keep-alive.yml): Runs daily at 08:00 UTC to execute a SOQL query against the org.
- Purpose: Prevents the Developer Edition from being marked inactive and deleted by Salesforce.
3.5 Changing an Operational Setting
Page sizes, rate tiers, the token rate and cap default, circuit breaker limits, form guard limits, source refresh limits, governance freshness windows, AI length bounds, the health check timeout, the roadmap and timeline display counts, the cover letter word range, the Gemini output token limit and temperature, and the home page testimonial minimum are Portfolio_Setting__mdt records (Technical Guide §1.6, ADR-032).
- To change a limit, edit the record’s Number Value in Setup > Custom Metadata Types > Portfolio Setting > Manage Records. It takes effect on the next request, with no deploy. That is true of every limit the org applies at request time. A setting that shapes content the static site reads at build time, such as a display count or the cover letter word range shown on the page, reaches the static site only at its next build (as of 2026-11-02, the site workflow runs after every production deploy and every six hours, or run it by hand).
- Keep it inside the record’s Min Value to Max Value. A value outside that range is ignored and the code default applies, and so does a cleared value or a deleted record. Its Description says what it controls and what is safe. A fraction is dropped (2.75 reads as 2).
- Commit the same value to the record’s file under
packages/portfolio-site/main/default/customMetadata/. A full mode deploy writes every record in source, and a delta that touches the file writes it too, so a value changed only in Setup can be put back to the shipped one. Token_Daily_Cap_Defaultapplies only whilePortfolio_Config__mdt.Token_Daily_Cap__cis blank. To pause or lower token vending, set the config cap (§4.4).
3.6 Changing a Query Definition
[!NOTE] Per host, as of 2026-11-02. A change reaches the Salesforce site on its next request. It reaches the static site at its next build (section 3.12).
The portfolio’s content reads (the SAPI lists, the Process API, the API Lab options and nine site classes) are Query_Definition__mdt records, one per read, each checked against the query its class holds in code (Technical Guide §1.7, ADR-033).
- A record can only narrow its read. Its filter is added to the class’s filter, the smaller row limit applies, and a field outside the class’s allowlist, a missing required field, an unknown bind, a weaker access mode or a filter that does not parse makes the class run its own query instead. So a record that seems to change nothing has probably failed a check.
- Edit the record in Setup > Custom Metadata Types > Query Definition > Manage Records, or in its file under
packages/portfolio-site/main/default/customMetadata/. Write an optional filter as(IsDeleted = :hasX OR Field = :x), using only the binds the class already supplies. - Run
SAPIQueryDefinitionsTest,ApiQueryDefinitionsTestandSiteQueryDefinitionsTest. Their validation tests list every record that fails a check, with the reasons. Their ships equal tests fail for any record that differs from its class, so a deliberate change to a shipped record needs the class’s default changed to match, which is a code change. - Commit the change. A full mode deploy writes every record in source, so a change made only in Setup can be put back.
3.7 Site Content the Owner Edits
[!NOTE] Per host, as of 2026-11-02. Everything below shows on the Salesforce site as soon as the page loads. On the static site it shows after the next build, which the site workflow runs after every production deploy and every six hours, and by hand (section 3.12). After a content edit I run the workflow and read the built time the section shows.
- The title line and the value sentence. Edit Hero Line (the title line under the name) and Value Sentence (one sentence on the value delivered) on my Contact. Each shows on the home page only while it is set.
- The proof strip. “On Salesforce since 2017” and the active certification count are one list,
PROOF_POINTSinc/pillarData. Check both every quarter, and change the count and its test in the same commit. - A home case study. The home page shows up to three
Project__crows that have Home Sort set, lowest first. To add one, create the row with its Home Title (optional), Client Line, Case Tags, headline number and label, Challenge, Solution, Business Value and link, and give it a Home Sort. To reorder, change the Home Sort numbers. To retire one, clear its Home Sort. Clear every Home Sort and the section disappears with no empty heading. A row with a Home Sort and no Pillar never appears as a pillar card. The featured flag is not used here and changing it does not move a card. The text reaches production through a script I run, not through CI. A card’s number can be computed from a live source (Technical Guide §2.12), which is better than a typed number that goes stale. - When home testimonials appear. Edit Home testimonials minimum in Portfolio Setting (§3.5). The home page shows up to three approved testimonials once that many are approved.
- The cover letter instruction. Edit Instruction on the Cover Letter record of the AI Prompt custom metadata type, and commit the same text to
AI_Prompt.Cover_Letter.md-meta.xml. A blank or missing record falls back to the text inContentGenerationController. The word range is theCover_Letter_Min_WordsandCover_Letter_Max_Wordssettings, and the prompt turns them into a word limit line with the maximum as a hard limit and a fixed five paragraph shape. Keep the line from starting with “Length”: the PublishedPortfolio_Cover_Lettertemplate adds its own sentence for aLengthline, and a changed template needs a new version made in Prompt Builder, not a deploy. - A document on
/docs. Each listed document is aSource_File__mdtrecord withSource_Key__cdocs, itsPath__cin the repository, itsLabel__cand itsSort_Order__c. The daily source refresh reads it frommain, and a push that changesdocs/**refreshes every listed document after the deploy (§3.2), so a changed document shows soon after its merge. A new document needs its record deployed first. The doc key in the address comes from the record name:docs_sasgivessasanddocs_adr_027givesadr-027. Keep the SAS headings 5.1 to 5.5 as they are, because each pillar page’s “Read the design” link uses their slugs.
3.8 Owner Emails and Testimonial Approvals
- A new contact message. The Lead owner gets one email with the visitor’s name, company, email, full message and a link to the Lead. While the Lead has no internal user as its owner, for example while the assignment rule leaves it with the Automated Process user, the email goes to the
Portfolio_Adminusers instead. A Lead rule entry that assigns “Portfolio Website” leads to me, with no template, is optional. With it the in app reply reminders reach me as well. - A new testimonial. It is submitted to the Testimonial Approval process for you, and the request email shows the quote, the author, title, relationship and vibe. Reply with APPROVE, APPROVED or YES on the first line to publish it, or REJECT, REJECTED or NO to keep it off the site, or open the request in Salesforce. Approval sets Approved and the testimonial shows on the Salesforce site (on the static site, at its next build). A rejected one stays saved and hidden.
- A testimonial saved before the hand off. It was never submitted, so submit it for approval from the record, or tick Approved by hand.
- Who gets the emails. Both go to a user, never to an address in source. The approval goes to the testimonial’s owner, the site’s default owner for guest records.
3.9 The Postman Collection and the Run in Postman Link
The API Lab and the API reference offer the Process API (PAPI) collection as a download, the static resource PAPI_Postman_Collection. It is generated from the v2 spec, portfolio-papi-v2.yaml, so it follows the spec and sends X-API-Version: v2.
- Edit the spec and commit it. When a staged file under
packages/integration-api/specsis YAML, the pre-commit hook runsscripts/regenerate-spec-outputs.js. It runssync-specs.js,generate-api-docs.jsandbuild-postman-collection.js, then stages the files they made, so the collection, the spec copies, the API Lab catalog and the API docs go into the same commit as the spec. lint-staged runs its file groups one after another (--concurrent falsein theprecommitscript), so Prettier has finished with the spec before the generators read it. Run in parallel, a spec Prettier changed was copied before the change. - To regenerate by hand, run
node scripts/regenerate-spec-outputs.js, or only the collection withnode scripts/build-postman-collection.js. The converter is the exact dev dependencyopenapi-to-postmanv2@6.4.0, sonpm installis enough.package.jsonis the one place its version is written: the PR check and the Postman Sync workflow both runnpm ci, and the builder stops with “Run npm install” when the installed version is not the pinned one. To move to a new converter version, change the pin, regenerate the collection and commit both together. node scripts/build-postman-collection.js --checkwrites nothing and exits 1 when the committed file is stale. The same spec gives the same bytes. The file must never hold a token.docs/api/postman/portfolio-api.postman_collection.jsonis a different collection. It holds both specs, comes fromsync-specs.jsand stays in the repository for the System API. The site offers the generated one.- Run in Postman. The button shows only while
Portfolio_Setting.Postman_Run_Urlholds an https link on postman.com or getpostman.com. It ships empty, so only the download shows. Fill it in Setup after the public workspace exists, and commit the same value toPortfolio_Setting.Postman_Run_Url.md-meta.xml. The Postman Sync workflow pushes the regenerated collection to the workspace after each deploy (section 3.2), so no import by hand is needed.
3.10 Setting the API v1 Dates and Retiring v1
API v1 is deprecated and has two dates, both Portfolio_Setting__mdt records (ADR-039). Both ship empty, so until I set them v1 carries no Deprecation or Sunset header and nothing answers 410.
- The records.
Api_V1_Deprecation_DateandApi_V1_Sunset_Date, inpackages/portfolio-site/main/default/customMetadata/. Each holds a date in Text Value writtenyyyy-MM-ddand read as 00:00 UTC. Leave Number Value empty. - What they do. With the deprecation date set, every v1 response carries a
Deprecationheader. With the sunset date set, every v1 response carries aSunsetheader, and from 00:00 UTC on that day every v1 call, with or without anX-API-Versionheader, gets 410 witherrorCodeGONE, exceptPOST /papi/auth/token,/sapi/healthand/papi/health. They keep their deprecation headers and are answered normally, so a client can still get a token and check that the API is up. A value that is not ayyyy-MM-dddate reads as empty, and so does an empty one. v2 is never affected. TheLinkheader to the v2 reference is sent on v1 either way. - How I set them. I edit the Text Value in the record’s file and deploy the changed record. It is a deploy of one metadata record and needs no code change. After the deploy I call a v1 path and read
DeprecationandSunsetin the response headers, and I call a v2 path and read none. I plan to set the sunset to the deploy date plus 30 days. - After the sunset. Once v1 has answered 410 for as long as I want, I clean up in one change: delete
salesforce-sapi.yamlandportfolio-papi.yaml, remove theV1_SPECSlist and its uses inscripts/sync-specs.js, runnpm run specs:syncand delete the v1 static resources it no longer writes (SAPI_Spec,PAPI_Spec,SAPI_OpenAPIandPAPI_OpenAPI, each with its meta file), remove the “v1 (deprecated)” option from the API Lab and API reference pickers, remove the v1 branch ofOpenApiDocumentand the v1 sunset code inApiRequestContext, and regeneratedocs/api. Then I delete the two date records, or leave them empty.
3.11 Retiring the Old /v1/ URLs
The API is called at versionless URLs, and the old /v1/ URLs are aliases that reach the same code (ADR-040). They have their own two dates, separate from the v1 version dates in section 3.10. Both are Portfolio_Setting__mdt records and both ship empty, so until I set them an old URL answers as it always did, with a Link header that points at the versionless URL and nothing else.
- The records.
Api_V1_Path_Deprecation_DateandApi_V1_Path_Sunset_Date, inpackages/portfolio-site/main/default/customMetadata/. Each holds a date in Text Value writtenyyyy-MM-ddand read as 00:00 UTC. Leave Number Value empty. - Before I set a date. I read the request log for
legacyPath=truelines (the log line ends with it when a caller used an old URL), so I know who still calls them. Our own callers already use the versionless URLs. - Deprecating. I set
Api_V1_Path_Deprecation_Date, deploy the one record, call an old URL and readDeprecationandLinkin the response headers. A versionless URL carries neither. - Retiring. I set
Api_V1_Path_Sunset_Dateto the day I want, deploy the record and check the same call. From 00:00 UTC on that day an old URL answers 410GONEwith the versionless URL in the message, and every version header is accepted, v2 included.POST /papi/v1/auth/token,/papi/v1/healthand/sapi/v1/healthkeep answering, with their dates andLink, so a client can still get a token and learn the new URL. A versionless URL is never affected. A date that is notyyyy-MM-ddreads as empty. - If a call is both v1 and on an old URL the response carries the earlier of the two
Deprecationdates and the earlier of the twoSunsetdates, and the 410 comes from whichever sunset has passed. The message says which. - After the sunset. Once the old URLs have answered 410 for as long as I want, I remove them in one change: the
/v1/urlMappingof each handler class (the routers then own every URL, and the exceptions need their routes inPAPIRouterandSAPIRouteralready, which they have), theLEGACY_SEGMENThandling and the two path settings inApiRequestContext, the two path date records, and the v1 specs along with the rest of section 3.10 step 4. Then I redeploy and run every test.
Switching the versionless URLs off and on
Api_Versionless_Urls is a Portfolio_Setting__mdt record that ships On (ADR-040). It is the first rollback for a fault in a router or in one of our callers, and it needs no deploy.
- Turn it off. Setup, Custom Metadata Types, Portfolio Setting, Manage Records,
Api_Versionless_Urls, Edit. Set Text Value toOffand save. OnlyOffturns it off. Blank, a typo or a missing record read asOn. - Check it took effect. Call
GET /services/apexrest/papi/healthand expect 404 with the message “Versionless URLs are switched off. Use /services/apexrest/papi/v1/health.” Call/services/apexrest/papi/v1/healthand expect 200 with nocanonicalLink. Open the API Lab and read the curl examples: they show/v1/URLs. The Lab reads the value through a cacheable call, so a page that was already open can need a reload. - What changes while Off. The routers answer 404, our callers use the
/v1/URLs, the old URLs send nocanonicalLink, and the path dates (section 3.11 above) do not apply, so no old URL answers 410. The version rules of section 3.10 are unchanged. - The smoke script fails while Off.
scripts/api-smoke.shcalls versionless URLs, so it fails on its routes until the switch isOnagain. That is expected. - Turn it back on. Set Text Value to
On, save, and repeat the check:/services/apexrest/papi/healthanswers 200. - What else Off does.
ApiRequestContextalso returns to the request handling main had before the versionless URLs (the/v1/path lists, the old AI tier check, the old endpoint segment and the old log line). The token and both health checks stay out of the 410 as before. - When Off is not enough. The switch does not cover the new classes and permission set entries merely existing. For a fault there I revert the code change.
- Cleanup. The switch and the old logic are temporary. After the v1 sunset on 2026-11-29, once the versionless URLs have run cleanly, I remove the switch, the old logic in
ApiRequestContext(marked REMOVE WITH THE SWITCH) and their tests in one change.
3.12 The Static Site: Workflow, Secrets and Kill Switches
The static front end (ADR-041) is built and deployed by site.yml. Until cutover it is reachable only at the *.pages.dev address (cutover runbook).
The workflow.
- Triggers. See section 3.2. A pull request deploys a preview under
pr-<number>, runs the live smoke test against it, and writes the preview address and Lighthouse medians (report only) to the job summary. A run onmaindeploys to the Pages production branch. - GitHub secrets it reads.
SFDX_JWT_KEYandSFDX_CLIENT_ID(the existing JWT app),SFDX_SITE_BUILD_USERNAME(the build user,site-build@ryanbumstead.com),CLOUDFLARE_PAGES_TOKEN(elseCLOUDFLARE_API_TOKEN) andSITE_DENYLIST(comma separated terms the leak scan refuses). With no build user secret the build uses the sample fixtures and says so. With no Pages token the deploy is skipped and says so. The leak scan fails a real data build whenSITE_DENYLISTis missing. - After the build. It publishes a
Governance_Event__efor the workflowsiteon success or failure, and the Delivery checklist’s “Static site build green” row reads it. - A failed build. The Pages project keeps serving the last good deployment. Read the job summary, fix the cause and run the workflow by hand.
The Cloudflare project. The Pages project ryanbumstead holds the Functions’ secrets, each as the Secret type: SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, IP_HASH_SALT and TURNSTILE_SECRET. web/wrangler.toml sets pages_build_output_dir, so Cloudflare treats that file as the source of truth for plain variables (Pages Functions Wrangler configuration documentation, https://developers.cloudflare.com/pages/functions/wrangler-configuration/, read 2026-11-04). A text variable added only in the dashboard is gone after the next deploy. That happened to the five switches on 2026-11-04, and the production *.pages.dev address read all five as live until the file carried them. So the five feature switches below and the optional PRODUCTION_HOSTS (a comma list that replaces the default production hosts ryanbumstead.pages.dev, ryanbumstead.com and www.ryanbumstead.com) go in [vars] in that file, and nothing secret ever does. TURNSTILE_SITE_KEY is a GitHub variable that the build reads. The API Lab recording step in site.yml reads the GitHub secrets SITE_EDGE_INSTANCE_URL, SITE_EDGE_CLIENT_ID, SITE_EDGE_CLIENT_SECRET and SITE_EDGE_IP_HASH_SALT, and never runs on a pull request. The rate limit is a zone rule made at cutover (cutover runbook, section 0.3).
Flipping a kill switch. Each feature has a variable that is live or recorded: FEATURE_CONTACT, FEATURE_TESTIMONIAL, FEATURE_COVER_LETTER, FEATURE_API_LAB and FEATURE_LIVE_READS. Production is decided by the request host, and on a production host a switch that is not set reads as live. Any other host is always recorded, whatever its variables say.
- Change it. On a branch, set the switch under
[vars]inweb/wrangler.tomltorecorded(or back tolive) and open a pull request. The test inweb/test/functions.test.mjsfails if a switch goes missing or a secret lands in the file. - Make it take effect. Merge it. A push to
mainthat changesweb/runssite.yml, which deploys. A variable applies to the next deployment, not to the running one (Cloudflare Pages bindings documentation, https://developers.cloudflare.com/pages/functions/bindings/, read 2026-11-03), so nothing changes until that deploy finishes. - Check it. Open
/api/featureson the site and read the five values. Then load the page: arecordedform shows a short paused note with my profile links, the cover letter shows a labelled sample, the API Lab shows build time responses or says none are in this build, and the live readings show the build snapshot only. - When to flip.
FEATURE_CONTACTandFEATURE_TESTIMONIALwhen the forms are abused or Salesforce is down.FEATURE_COVER_LETTERwhen Agentforce and Gemini are both out and I do not want the template to stand in.FEATURE_API_LABandFEATURE_LIVE_READSwhen the org’s daily API request quota (shown in the footer) runs low. A switch changes nothing in Salesforce. - If the Salesforce answer is no. The open licensing question in ADR-041 may end with every feature on
recorded. Set all five torecordedin the file and merge. The pages stay up from the last build.
Cleaning up old deployments. See the cutover runbook, step 6. Pages keeps every deployment reachable by its own address, so old builds need deleting on a schedule.
4. Disaster Recovery
4.1 Scenario: Deployment Failure
If Apex classes fail with “You can bypass this error by allowing deployments with Apex jobs in the Deployment Settings page in Setup”, nothing is wrong with the code. A scheduled job (Cache Cleanup Daily or one of the four Integration Cache Refresh jobs) depends on a changed class. Tick “Allow deployments of components when corresponding Apex jobs are pending or in progress” in Setup > Deployment Settings (Technical Guide deployment prerequisite 13), then re-run the failed deploy. The deploy is atomic, so the failed attempt changed nothing. For any other failure:
- Revert main branch to previous commit SHA.
- Trigger fresh deployment.
- Verify site health: the deploy’s
smokejob runs the Live Smoke Test on its own (§3.2). After a publish, or to check again, run Live Smoke Test by hand from the Actions tab and read its summary.
4.2 Scenario: Security Misconfiguration (Guest User)
- Symptom: PMD Scan or Health Check fails due to “Guest User Open Access”.
- Mitigation:
- Immediate Rollback: Revert the guest permission set
Portfolio_Guest(packages/portfolio-site/main/default/permissionsets/Portfolio_Guest.permissionset-meta.xml) to the previous commit. It must grant only what Technical Guide §1.4, prerequisite 6 lists: class access to the twelve site controllers (includingContactFormControllerandSourceController), the token endpointPAPIAuthToken, the two routersPAPIRouterandSAPIRouter(they answer the guest 401 and hold no data) and the spec endpointOpenApiDocument, read on the six portfolio objects, create and read onTestimonial__c, read onUserExternalCredential, and thePortfolio_API_Auth,Portfolio_API_Token_AuthandGemini_Authprincipals. Never a GitHub or Jira principal, and never Contact, Account or Lead access. - If unsure: redeploy the
mainbranch version ofPortfolio_Guestto the affected org, then read the grants back (SELECT SobjectType, PermissionsRead, PermissionsCreate FROM ObjectPermissions WHERE Parent.Name = 'Portfolio_Guest', and the same forSetupEntityAccess) and compare them with prerequisite 6. Do not plan on deleting the permission set first: it is assigned to the guest user, and whether the platform allows deleting an assigned permission set has not been tested here.
- Immediate Rollback: Revert the guest permission set
4.3 Scenario: GitHub Rate Limit Exhaustion
- Visitors never call GitHub.
GitHubCacheRefreshJobrefreshesGithub_Cache__cevery 15 minutes, and a failed refresh (429, 500 or a timeout) inserts nothing, so the changelog keeps showing the last good list with its age. Three failures in a row open thegithubbreaker for 30 minutes, and refreshes are skipped until it closes.
4.4 Scenario: Token Vending Abuse
- Symptom: spike in
API_Access_Request__crecords,429/503responses from/papi/auth/token, or Developer Edition API request usage trending toward the 15,000/day limit (Setup → System Overview). - Immediate mitigation:
- Deactivate the Portfolio API External Client App (see §1.2). All issued tokens die.
- Lower
Token_Daily_Cap__con theRyan_BumsteadPortfolio_Config__mdtrecord (when blank, theToken_Daily_Cap_Defaultsetting applies, 50 as shipped) or set it to 0 to pause vending:TokenExchangeService.dailyCapreads it and a cap of 0 answers every token request with 503.
- Reading the audit trail:
API_Access_Request__crows (one per token attempt) and the daily cap count are readable by users holdingPortfolio_Admin, which grants read and View All onAPI_Access_Request__candAPI_Rate_Window__cand read on their fields. Without it the fields are not readable, so reports and SOQL fail. - Recovery: rotate the secret (§1.2), review the audit rows (
Email__c,Source_IP__c,Outcome__c,Channel__c) for the offending email/IP, reactivate the app, restore the cap. - Per-caller limits already in place: 15 token requests per minute per email and source IP, and the PAPI tier limits (
RateWindowLimiter, ADR-025). - Why this is enough: tokens run as the integration user, whose only permissions are read-only on public portfolio data. The asset at risk is the API quota, not the data.
4.5 Scenario: Contact Form Abuse
- Symptom: unwanted Leads with
LeadSource“Portfolio Website”, or auto-responses to addresses nobody typed in. - Mitigation: on the Lead, run “Block sender” (email or domain), from desktop or the mobile app. The sender’s next message is thanked and dropped. Add a Keyword row to
Submission_Block__cfor a phrase. Deactivate a row (Active__c) to undo a block. - Already in place: the honeypot, the 3 second minimum, one auto-response per address in 24 hours, and at most 20 messages in 24 hours (Technical Guide §2.16).
- On the static site: the form is also guarded by Turnstile and a per hash rate limit, and the
FEATURE_CONTACTswitch (section 3.12) turns the form off with a one line change toweb/wrangler.tomland a merge, without touching Salesforce. A block row of type IP Hash blocks a visitor by the hash of their address, and a person blocked that way is dropped silently like any other block. - Lost messages: check the
Contact_Form_Failure__ctab. A row there is a message that failed to save, with the reason.
5. Observability & Monitoring
5.1 Quota Monitoring (Not Built)
The design was a scheduled job that read a Gemini usage counter from a Platform Cache partition (local.AIMetrics) and emailed me above a threshold. It is not built: the org has no Platform Cache partition, and no class keeps such a counter. What exists today:
- The Gemini API key’s own limits for Gemini 2.5 Flash, as recorded on 2026-10-20 (AI Studio): 5 requests per minute and 20 requests per day. Read the current figures before relying on them. Past them,
/papi/ai/generatefalls back to the local template for cover letters and answers 503ALL_PROVIDERS_UNAVAILABLEotherwise. - The
papi-airate tier, 10 requests per minute (RateWindowLimiter). The site cover letter has its own bucket,papi-ai:site-cover-letter, shared by every site visitor./papi/ai/generatekeys by session, and every evaluator token is the same session, so evaluators share one bucket too. - The footer’s “AI requests today” line (
FooterController.getSystemSnapshot), the sum of today’spapi-aicounts. It counts requests, not Gemini calls. - The audit object
API_Access_Request__cfor evaluator token requests. - Setup → System Overview for the org’s daily API request usage (15,000 per day in Developer Edition).
- The footer’s Storage row in Quotas and limits and its panel, read from one stored snapshot (
Storage_Snapshot__c).StorageSnapshotJobrefreshes it from the 15 minute integration scheduler when it is older than theStorage_Snapshot_Hourssetting (1 to 24). If the row is missing, no snapshot has been stored yet, and schedulingIntegrationCacheRefreshScheduler.scheduleAll()as the admin starts it. The data by area is an estimate (record counts times 2 KB) and Setup holds the exact figures (Technical Guide §2.23). - The
Site_Usage_Historyreport type and the “Site usage, last 30 days” report in Portfolio Owner Reports show each site’s origin and cache bandwidth and request time by day. Whether the LWR site’s traffic appears there, or counts toward the Developer Edition’s 500 MB a day, is open. On 2026-10-27 the LWR site showed no rows. “Site traffic today” in the footer is off (Footer_Site_Traffic_Visibleis 0).
5.2 Logging
- Where logs go. Apex logs through
PortfolioLog(one line per API request: path, status, correlation id, elapsed time). With Nebula Logger installed, the lines are Nebula log entries taggedportfolio. Without it, they are ordinary debug logs, visible only while a trace flag is set. - Nebula is optional and I install it myself. Install its unlocked, no-namespace package in production by hand. No deploy installs or needs it, and removing it changes nothing but where the lines land.
- Retention. Set Nebula’s log retention to 2 days, to protect the Developer Edition’s 5 MB of data storage.
- What is never logged: a request or response body, a header value, a token or a query string (Technical Guide §1.5).
- Finding the detail behind a 500. A caller who reports a 500 quotes the correlation id in its message. The detail is one line,
api error correlationId=<id> type=<exception type> message=<the first 200 characters>, written byApiRequestContext.serverError. The same id is thecorrelationIdof the request’s log line. A callout failure in the API Lab logsapi lab call failedwith the path and the exception type, and the visitor reads only that the Lab could not reach the API.