0102 — Secrets: eliminate per-runtime copies; validate the rest at boot
- Status: Proposed
- Date: 2026-07-17
- Deciders: Fede
- Incident:
TOUR_SLOT_HMAC_SECRETabsent frompropflow-inbound-processor-prodfor 13 days (2026-07-04 → 2026-07-17) while present in Vercel andagent-runtime; every email prospect hitting the tour-slot tool failed silently — 5 real Camellia prospects affected. RCA in #updates-fede. Companion: ADR-0101 (turn integrity).
Context
The incident looked like "someone forgot one Lambda." The audit of how config actually reaches production shows the architecture makes this class of failure inevitable:
- ~14 deployable units execute shared code in prod, not 3: the Vercel app (plus ~11 cron routes inside it), 11 Lambdas with deploy workflows, the renewal-worker ECS Fargate container (which copies the entire
src/+agents/tree, so every guard can execute there), and the appfolio-browser-agent Vercel project. Four moredist-only lambda dirs are probably retired but undocumented. - Three secret stores with overlapping contents: SSM
/propflow/prod/*(Lambda fleet, fetched at cold start bylambda/lib/secrets.ts:loadSecrets()), AWS Secrets Managerpropflow-*(Fargate task-defvalueFrom, plus deploy-time resolution indeploy.sh), and the Vercel dashboard.ANTHROPIC_API_KEY,TWILIO_AUTH_TOKEN,ELEVENLABS_API_KEY,SENDGRID_API_KEYlive in both AWS stores; nothing reconciles them — a rotation in one silently diverges the other. - CI deploys do not propagate env. The steady-state Lambda deploy workflows run
update-function-codeonly; env vars are set in the first-deploy bootstrap branch and by manualdeploy.shruns. SSM-backed values survive (runtime fetch), but Secrets-Manager-backed values — notablyTEMPORAL_API_KEY— are resolved only by manualdeploy.sh. A rotation reaches Fargate on the next task-def render and never reaches the Lambdas until someone remembers to run a script.lambda/README.mdcallsdeploy.sh"the source of truth"; CI doesn't run it. - Exactly one variable has parity checking.
audit-runtime-token-parity.ymlcompares SHA-256 hashes ofRUNTIME_SHARED_TOKENacross three sources (built after the 2026-05-25 drift incident). No other secret, no other store pair. - No env schema or boot assertion exists anywhere. Every guard is a per-consumer inline
throw; several critical vars don't even throw —EXECUTED_LEASE_KMS_KEY_ID,S3_EVIDENCE_BUCKET,SENTRY_DSN,TEMPORAL_API_KEY(viaisTemporalConfigured()) silently disable features when absent. - Guards exist in duplicated copies (
agents/clara/libis a 283-file partial fork ofsrc/lib), so even the inline throws can drift between runtimes bundling different copies.
The fail-closed guard from PR #3027 was correct security (the prior fallback signed slot tokens with a hardcoded dev constant — meaning the inbound-processor had been signing with a public key all along). What failed is the surrounding architecture: N independent env copies, no declaration of which runtime needs what, no reconciliation, and absence expressed as silent per-request errors instead of loud deploy failures.
Industry research (full citations in the RCA thread) is unambiguous about the direction: push-synced or hand-copied env vars are the root cause, not a hygiene problem. The strong fixes remove the copies — single-source runtime resolution, or asymmetric keys that abolish the shared secret — and boot-time schema validation converts any remaining absence from silent to loud. Parity auditing is a compensating control for copies you haven't yet eliminated, not a destination.
Decision
Five moves, ordered by leverage. The principle: a secret has exactly one authoritative location, every runtime's requirements are declared in reviewable code, and absence fails the deploy — never the customer.
D1 — Per-runtime env schema, validated at boot (week 1)
One schema module (src/lib/platform/env-schema/) declares every required and optional env var with its type and consumer runtimes. Each deployable unit gets a required-set file (env.vercel.ts, env.inbound-processor.ts, env.agent-runtime.ts, env.renewal-worker.ts, …) imported at that unit's entrypoint: missing required var → the Next build fails / the Lambda cold-start throws a Sentry-fatal before serving any request. "Which runtime needs which var" stops being tribal knowledge and becomes a code-reviewed diff — adding a fail-closed guard without declaring the var in the schema becomes a lint/test failure. Optional feature vars are declared too: absence logs one structured startup warning surfaced on the admin status page, never a silent no-op.
D2 — Single source of truth per secret (sprint)
SSM /propflow/prod/* becomes the one authoritative store for shared/internal secrets:
- Lambdas: already fetch it at cold start (
loadSecrets()), unchanged — this pattern is the model, not the exception. - Fargate: task-def
valueFromentries migrate from Secrets Manager to the same SSM parameters (ECS supports SSMvalueFrom), eliminating the dual-store overlap. - Vercel: adopts a boot-time SSM fetch for shared secrets (it already holds AWS credentials for DynamoDB; grant
ssm:GetParameterByPathon the prefix, cache in module scope). Vercel-platform-specific vars (build-time,NEXT_PUBLIC_*) stay in Vercel env but are declared in the D1 schema so their absence is still loud. - Rotation becomes: change one SSM parameter → every runtime picks it up on next cold start / bounce. The CI-doesn't-propagate-env gap closes as a side effect for everything migrated; for the transition period, the Lambda deploy workflows gain a step that re-runs the
deploy.shenv-resolution block so Secrets-Manager-backed values (e.g.TEMPORAL_API_KEY) can't silently stale.
D3 — Asymmetric keys where signer and verifier differ (sprint, with fix PR 2)
The tour-slot handshake — and any future sign-here/verify-there token — moves from HMAC to an asymmetric signature (Ed25519): the private key lives only in the signing path's runtime; every verifier holds the public key, which is not a secret and is committed to the repo. The "same secret must exist in N runtimes" requirement is deleted, not managed. (Immediate incident fix — provisioning the HMAC secret to the inbound-processor via SSM — ships first as its own PR; D3 then retires the parity requirement.)
D4 — Hash-parity audit as the transitional backstop (week 1–2)
audit-runtime-token-parity is extended to read the D1 schema manifest and verify, for every secret still existing in more than one store, that (a) it is present everywhere the schema requires, and (b) shared values are byte-identical via salted-hash comparison — names and hashes only, never values. Runs scheduled daily and on every deploy of each runtime; failure blocks the deploy. This is explicitly labeled transitional: each secret migrated to D2/D3 leaves its scope, and the audit's steady-state coverage should trend toward zero.
D5 — Retire the fork and the ghosts (background)
The agents/clara/lib duplication of guard code is consolidated (tracked in ADR-0101 S5), so a guard exists in exactly one module. The four dist-only lambda directories are confirmed retired and deleted, or documented and brought under the schema — no deployable unit outside the manifest.
Consequences
- Adding a fail-closed guard now costs a schema entry and provisioning in the declared runtimes — paid in the guard's own PR, reviewed, instead of discovered as an incident.
- The 2026-07-17 class (present in some runtimes, absent in the serving one) is caught three independent ways: build/cold-start failure (D1), deploy-blocking parity audit (D4), and — for migrated secrets — the impossibility of divergence (D2/D3).
- Secret rotation becomes a one-place operation with defined propagation semantics, instead of "update three consoles and remember a script."
- Vercel gains an AWS dependency at boot for shared secrets: an SSM outage becomes a (loud, alarmed) availability risk where it was previously a silent-drift risk. Mitigated by module-scope caching and falling back to last-fetched values within a process; accepted as the right trade.
- Cost: D1 touches every entrypoint (~14 units); D2 needs IAM changes and a Fargate task-def migration; D3 changes token format (slot tokens are short-lived, so a dual-verify window of hours suffices).
Alternatives considered
- Parity auditing as the primary fix (this ADR's own first draft). Rejected as primary: auditing N copies accepts that N copies exist; it detects drift but cannot prevent the next rollout from missing a runtime. Kept, demoted, as the D4 transitional backstop.
- Secret-sync SaaS (Doppler/Infisical). Deferred: one source with an audit trail is real value, but their integrations push copies into each platform — a failed partial sync recreates this exact incident silently. Reconsider for developer-experience reasons after D1–D3; it does not replace them.
- IaC adoption (SST resource linking) for the Lambda fleet. Best-in-class end state (env set becomes a type-checked, code-reviewed link graph) but a large migration for 11 Lambdas + Fargate; not justified by this incident alone. Direction noted for when the Lambda fleet is next re-platformed.
- Startup asserts without a manifest (each runtime hand-lists its vars inline). Rejected: the hand-list is the manifest, just unreviewable and duplicated — the incident happened because per-runtime knowledge lived in heads and scripts.
- Do nothing beyond provisioning the one missing secret. Rejected: the audit found live adjacent landmines (unpropagated
TEMPORAL_API_KEYrotations, dual-store overlaps, silent-degrade vars) — the next incident is already loaded.