Changelog

All notable changes to CAIRL are documented here. Format follows Keep a Changelog and Semantic Versioning.

[Unreleased]

[0.7.0] - 2026-05-20

Added

Admin blog management — database-backed publishing workflow at /admin/blog/\* replacing the file-based MDX system (ADM-001). A new blog*posts table (drizzle migration 0074, hand-authored under the known forked-parent collision) backs four admin pages (list, new, edit, preview) plus five API routes (GET/POST /api/admin/blog, GET/PATCH/DELETE /api/admin/blog/[id], POST /api/admin/blog/[id]/publish, POST /api/admin/blog/[id]/unpublish), every one of which calls the centralized assertCanManageBlog(session) authorization helper before any other logic per Guardrail #5. Public /blog and /blog/[slug] now read from the database via listPublishedPosts() / getPublishedPost(slug) — both cached under tag-based Next.js cache (blog-posts / blog-post:{slug}, the repo's first use of unstable_cache + revalidateTag) — and the slug page no longer exports generateStaticParams so publishing happens without a deploy. buildSitemapForDomain is now async and reads published, non-deleted rows from the database; the /sitemap.xml route awaits it. Post bodies render through src/lib/blog/render-markdown.tsx using react-markdown + remark-gfm + rehype-sanitize (default schema) — no dangerouslySetInnerHTML, no rehype-raw, no MDX execution on any blog body code path per Guardrail #3; blog-post-json-ld.tsx is refactored to next/script to keep the strict QA-MD-09 grep clean across the entire src/lib/blog and src/app/(marketing)/blog tree. The 8 legacy MDX posts are migrated into the database via scripts/fixtures/adm-001-blog-posts.json (committed) and scripts/adm-001-seed-blog-posts.ts (Guardrail #12: imports nothing from src/content/blog/\*); the one-time generator was deleted after producing the fixture, and src/content/blog/*.mdx + posts.ts are retired. Soft-delete on a published post fires a destructive-action confirmation modal that warns about public-URL removal and slug-reuse SEO risk, then dispatches DELETE with the explicit {"confirmSlugReuseRisk": true} payload that the server requires (Guardrail #13: missing-flag requests are 400). Slug change on a published row is rejected with 409 (Guardrail #4). FK on created*by / updated_by uses ON DELETE RESTRICT so a user who authored or edited a post cannot be deleted without first transferring or soft-deleting the post (Guardrail #14). 113 new ADM-001 vitest tests cover the QA-* suite (parity, markdown safety, authorization, validation, public rendering, sitemap, cache invalidation including QA-CACHE-08 "revalidate never fires before commit", admin surface, data model, audit fields, concurrency); 6 new Playwright E2E specs (E2E-01..E2E-06) cover the admin-publishes / anonymous-deny / slug-freeze / soft-delete-gate / unpublish-removes / slug-reuse-after-soft-delete journeys. AdminSidebar gains a "Content → Blog" entry. Dependencies added: react-markdown, remark-gfm, rehype-sanitize. (ADM-001)

Staging fixture identity path foundation (DAT-004): adds `users.is_demo_identity boolean default false not null` column with partial index `ix_users_demo` (hand-authored migration `0072_dat004_users_is_demo_identity.sql` due to known drizzle meta forked-parent collision); staging-only seed `scripts/seeds/18-dat-004-fixture-identity.ts` that idempotently provisions the fixture user `demo-fixture@cairl-demo.app` with `is_demo_identity = true`, refuses to run when `NEXT_PUBLIC_APP_ENV` / `NODE_ENV` is `production`, and never overrides `verificationStatus` (the fixture user is the demo anchor, NOT proof of identity); extends `scripts/check-surface-kit-readiness.ts` with three new checks (fixture user / fixture document image / fixture face enrollment) plus a new `blocked` flag on `CheckResult` so soft-skip on missing fixture assets renders distinctly (⚠ BLOCKED) from pass — per founder direction "soft-skip must NOT read as a pass"; new `scripts/dat-004-cleanup-fixture-session.ts` (wired to `npm run dat-004:cleanup`) hard-deletes verification sessions for the fixture user and soft-deletes (`purged_at`) callback logs scoped to the surface-kit demo facet, idempotent and production-refusing; rewrites `docs/runbooks/founder-led-staging-demo.md` step 4 from forward-looking spec to as-built fixture-path operating procedure plus new "Fixture identity reference" section listing seeded user + sentinel matching values + asset status + three-fixture-category taxonomy (JSON fixtures vs placeholder stubs vs DAT-004 image fixtures); updates `docs/flows/verification/staging-live-engine-demo.flow.md` Step 7 to reference the fixture identity rather than "investor walks the real flow with their own document/face"; adds 16 predicate tests at `tests/unit/dat-004/` covering blocked-vs-pass non-conflation invariant (readiness-blocked.spec.ts), production-refusal across NEXT_PUBLIC_APP_ENV / NODE_ENV (seed-refuses-production.spec.ts), and idempotent cleanup with fixture-face BLOCKED detail (cleanup-idempotent.spec.ts); asset-dependent work (approved fixture document image + approved fixture face enrollment) remains paused pending founder asset provisioning per the auditable-and-license-clean sourcing principle (no founder real PII, no fabricated government-document-likeness images, no scraped images, no agent-invented identity values); HVF-013 status language unchanged ("HVF-013 is infra-ready and demo-surface-ready; founder-led staging-live walkthrough remains paused pending a proper staging fixture identity path") — DAT-004 is the data path, not the demo identity. (DAT-004)

CAIRL Verify Surface Kit key lifecycle endpoints (HVF-013 PR 4 of N): three POST endpoints land under src/app/api/verify-surface-kit/ — /issue creates browser-visible publishable keys with a transactional db.insert + audit pair so a failed audit rolls back the issuance per §19 #19, app-layer rejects environment=live with 501 BEFORE any DB call so the §19 #15 block surfaces a clean error instead of a 500 from the prefix*env_match constraint, and the full pk\_\* value is returned exactly once at issuance per §19 #12 (DB stores only SHA-256 hash + prefix + last4); /revoke is a soft revoke (is_active false + revoked_at NOW; DELETE forbidden) with an atomic UPDATE that carries the lifecycle predicate (is_active = true) inside the WHERE clause so two concurrent calls cannot both write a pk_revoked audit row — the loser's UPDATE matches zero rows, the route re-reads current state inside the same transaction and returns alreadyRevoked=true without an audit insert; /mode-switch re-verifies all 5 ack gates server-side per §19 #11 (zod literal-true rejects any false ack as 400 acknowledgements_incomplete BEFORE any DB lookup, env-gate canResolvePrefixFamily('pk_staging\_') is reverified, the surface_kit_staging_live_demo feature flag is reverified, the publishable-key hash is looked up to derive parent → b-{slug} business facet → Owner/Admin role with §6.3 lifecycle gate enforced for the value-based resolver only, and a permanent mode_switch audit row is emitted with fail-closed posture); a new shared library at src/lib/verify-surface-kit/key-lifecycle.ts hosts the emitKeyLifecycleAudit fail-closed helper (mirrors resolve-pk's direct db.insert pattern, never the SPF-002 swallow-and-log wrapper) and three role resolvers (resolveOwnerAdminFromParentApiKey, resolveOwnerAdminFromPublishableKeyId, resolveOwnerAdminFromPublishableKeyValue) where the value-based resolver enforces §6.3 lifecycle (revoked/expired/inactive → 401 publishable_key_revoked) while the id-based resolver intentionally retains gate-free behavior so /revoke remains idempotent on revoked rows; the existing PR 3 modal at src/components/verify-surface-kit/switch-to-staging-live-confirmation.tsx is extended to await an async onAcknowledged result and render the server's error inline when the POST returns 4xx instead of optimistically flipping mode (the badge only flips on a 200); 26 new predicate unit tests across audit-fail-closed, route policy gates, per-ack truthiness matrix, atomic-revoke concurrent-race, and resolver lifecycle gate plus the asymmetry pin between value-based and id-based resolvers || CAIRL Verify Surface Kit demo wiring + bundle secret scan (HVF-013 PR 5 of N): closes the sandbox demo loop end-to-end and adds a pre-merge guard against secret literals shipping to the browser; (1) verification_callback_logs is a new dedicated table whose column set is the privacy filter — only canonical CLM-002 claim IDs (text array), outcome (resolved/rejected), jsonb freshness metadata, modeType (sandbox/staging/live), a 16-char SHA-256-hashed session reference, businessContextId, and an explicit 30-day expires_at + soft-delete purged_at — there is no column that could accept raw document data, DOB, OCR, face data, full pk\_\* values, or cairl\_\* values per §19 #20; hand-authored migration 0071_hvf013_verification_callback_logs (drizzle:generate has the known forked-parent collision) with RLS ENABLE + FORCE no policies = default deny + service_role bypass; (2) src/lib/verify-surface-kit/callback-log.ts is a server-only helper that hashes any session reference before persistence and pins the 30-day retention; (3) src/app/sandbox/\_components/demo-customer-callback-view.tsx is the partner-side callback receiver covering success/error/missing-code/invalid-code paths plus the always-visible mode badge per §19 #10, with two thin static route files at /sandbox/{age-gate,trust-gate}/customer/callback/page.tsx; (4) the existing demo-callback-log-view becomes async and hydrates the last 25 entries from getRecentCallbacks; (5) scripts/seeds/17-surface-kit-keys.ts seeds the demo chain — a dedicated test-mode parent partner_api_key with the union of demo claims, two random pk_sandbox\_<64hex> publishable keys (hash-only persist, raw printed exactly ONCE for operator capture, idempotent skip-rotation if rows exist), and two oauth_redirect_uris rows so the resolve allowlist accepts the localhost callback URLs; (6) scripts/check-surface-kit-bundle-literals.ts walks src/ and matches /(cairl*(test|live)\_|pk_live\_)[a-f0-9]{16,}/ — pk_sandbox\_ and pk_staging\_ are explicitly allowed in browser bundles per ADR-0014; new "Surface Kit Bundle Secret Scan (HVF-013)" CI job in pr-quality.yml fails any PR introducing a forbidden literal; (7) Playwright E2E coverage extends to the callback receiver's three UI states plus a privacy assertion across every demo URL that no rendered HTML matches the forbidden-literal pattern (the runtime complement to the source-tree scan); 22 new predicate unit tests (8 callback-log helper + 14 bundle-scan walker/pattern); 129 total verify-surface-kit unit tests still green; type-check clean. (HVF-013)

Changed

DAT-004 claim canonicalization architecture (locked 2026-05-13, Option A) — internal source of truth for claims is CLM-002 canonical IDs; OAuth scopes are the wire format at the `/authorize` boundary only; legacy aliases (snake_case, OAuth-scope, malformed-hybrid) are accepted defensively on read but never written: `src/lib/verify-surface-kit/claim-normalization.ts` `LEGACY_TO_CANONICAL` map expanded to include OAuth-scope strings (`age:21+` → `age.over_21.v1.strict`, `identity:verified` → `identity.verified.v1.standard`, `freshness:status` → `freshness.current.v1.standard`) plus the malformed hybrids that broke the 2026-05-12 staging walkthrough (`age_21+` → `age.over_21.v1.strict`, `face_match` → `identity.face_match.v1.standard`) so historical DB rows resolve without a backfill migration; new `CANONICAL_TO_OAUTH_SCOPE` map and `canonicalToOAuthScope()` / `canonicalAllToOAuthScopes()` helpers form the edge translator at the OAuth boundary (canonical IDs with no OAuth-scope equivalent like `identity.face_match.v1.standard` and `document.valid.v1.standard` return null and are filtered, preventing the `invalid_scope` regression that originally surfaced 2026-05-12); `src/lib/verify-surface-kit/resolve-pk.ts` updated to call `canonicalAllToOAuthScopes(canonicalClaims).join(" ")` when constructing the `scope` URL param (replacing the DAT-004 tactical `canonicalClaims.join(" ")` that depended on the seed writing OAuth scopes); `scripts/seeds/17-surface-kit-keys.ts` `SURFACE_KIT_DEMO_CLAIMS` constant reversed from OAuth-scope format to canonical CLM-002 (`["age.over_21.v1.strict", "identity.verified.v1.standard", "freshness.current.v1.standard"]`) so the durable source of truth aligns with internal canonicalization; `tests/unit/dat-004/closeout.spec.ts` CLOSEOUT-CLAIMS-001 regression test rewritten to assert canonical format in the seed array body and reject ALL three prior formats (OAuth-scope, legacy snake_case, malformed hybrid) — prevents regression in any direction; 17 new translator tests at `tests/unit/verify-surface-kit/claim-translators.spec.ts` cover canonical→OAuth, legacy→canonical, OAuth→canonical, malformed-hybrid→canonical, idempotency on canonical input, unknown-input pass-through, filtering of canonical IDs with no OAuth equivalent, end-to-end round-trip integrity (legacy→canonical→OAuth, OAuth→canonical→OAuth, hybrid→canonical→OAuth all produce valid OAuth-scope output), and bidirectional invariants (every `CANONICAL_TO_OAUTH_SCOPE` key has a reverse entry in `LEGACY_TO_CANONICAL`, every OAuth scope value is defensively accepted on re-read); FOLLOWUP-DAT-004-claim-canonicalization.md moved from `docs/tasks/_followups/` to `docs/tasks/_followups/_closed/DAT-004-claim-canonicalization.md` with the standard `**Closed:**` trailer and a Resolution section documenting the locked decision, what shipped, and what was deliberately left out of scope (no migration of historical DB rows since defensive read-normalization handles them; no `VALID_SCOPES` change; display polish on callback log render deferred); HVF-014 (Hosted Modal/Page) decision D1 unblocked — canonicalization is now decision-locked and Phase 1 brief's BLOCKING flag is satisfied; explicitly out of scope per founder direction: HVF-014 implementation itself, `VALID_SCOPES` redesign, batch backfill of existing `partner_api_keys.claimsRequired` rows, display-friendly labels on canonical IDs in partner-facing render surfaces. (DAT-004)

Establish a 14-day triage SLA for items in `docs/tasks/_followups/` and ship `scripts/audit-followups.ts` with a weekly GitHub Actions schedule that surfaces stale P0/P1 followups as issues. Existing followups backfilled with priority and filed-date metadata. (GOV-013) (GOV-013)

Extend `scripts/validate-release-manifest.ts` and wire it into `version-check.yml` and `tag-release.yml` as a required CI gate. Future release PRs fail closed if the corresponding `docs/launch/release-verification/v{version}.md` manifest is missing, has a mismatched H1, contains `<fill in>` / `TODO` / `[FILL IN]` placeholders outside HTML comments, omits initiative-ID references, or — when validating against `package.json`'s current version — does not reference every initiative listed in CHANGELOG.md's `## [VERSION]` section. (GOV-014) (GOV-014)

Close the env-var contract gap that produced the MLG-001 newsletter outage. `NEWSLETTER_MAILING_LIST_ADDRESS` is now enforced by `scripts/check-required-env.ts` for the production deploy scope (mirrors the existing `MAILGUN_API_KEY` precedent), and a new `scripts/check-env-drift.ts` PR-level CI gate runs on every pull request to assert that every key declared in `.env.example` is either present in the canonical required-env `checks` array or annotated with a `# OPTIONAL — <reason>` comment line. The `checks` array and its `Check`/`EnvScope` types are now exported so the drift detector imports them as a single source of truth; the existing validation side-effects in `check-required-env.ts` are wrapped in a `main()` function guarded by an entry-point check so the import is side-effect-free. The 73 currently-undeclared keys in `.env.example` were triaged into specific `# OPTIONAL — <reason>` annotations (per-bucket summary at `docs/_incoming/INF-116-blockers.md`); promote-to-required follow-ups are flagged for a future security-hygiene sweep. Two new unit-test files at `tests/unit/scripts/` cover the drift parser predicates (16 cases) and the regression that would have caught the original MLG-001 gap (6 cases). PR-quality CI gains a new `env-drift-check` job. (INF-116) (INF-116)

Add `scripts/check-migration-integrity.ts` and a corresponding PR-quality CI gate that enforces append-only, contiguous `idx` values in `drizzle/meta/_journal.json` and one-to-one mapping between journal entries and `drizzle/*.sql` files on disk. The pre-existing journal gaps (idx 1 → 46, missing idx 50, tag-prefix vs idx mismatch at idx 47–48) are grandfathered with a documented runbook entry at `docs/runbooks/migration-runbook.md`; no new gaps are permitted. (INF-117) (INF-117)

Vendor consolidation: MailGap alert emails (digest, per-inbound activity) now route through Mailgun via the canonical `src/lib/email/mailgun.ts` helper instead of Resend. The `resend` npm package and `RESEND_API_KEY` environment variable have been removed from the runtime contract; CLAUDE.md tech-stack table updated. The Mailgun helper gained optional `from` and `replyTo` parameters so the alerts path can preserve its `alerts@mail.cairl.app` from-address and `support@cairl.app` reply-to without diverging from the canonical sender. No user-visible change to alert content or delivery cadence; MailGap activity alerts intentionally remain on their existing locked product copy per the MLG-003 audit (no shared-shell wrap in this initiative). Regression test at `tests/unit/mlg-004/alerts-vendor-guard.test.ts` asserts the alert-send path goes through Mailgun and source-grep guards against a future re-import of `resend`. (MLG-004) (MLG-004)

Soak-diagnostic endpoint `/api/debug/async-flag-state` now accepts an optional `?partnerId=<uuid>` query parameter; when supplied (UUID v4 shape), the `decisions` block reflects that partner's runtime async-verify routing instead of the hard-coded default, making the endpoint reusable for any soak partner without a code edit. (VRF-014)

Fixed

Admin blog editor (ADM-001): slug input on a published post now reverts to the server-confirmed value when a PATCH with a slug change is rejected with 409. Previously the inline error banner rendered correctly but the slug input retained the rejected value, leaving the UI inconsistent with persisted state. Surfaced by the staging UAT walk on PR #786 (W9). The server-side slug-freeze contract on published posts (Guardrail #4) is unchanged; this is a UI-state consistency fix only. (ADM-001)

Surface Kit demo seed integrity (DAT-003): extends `scripts/seeds/17-surface-kit-keys.ts` to create the FULL operator chain the locked HVF-013 spec assumes — b-metered-co business facet (idempotent INSERT with PERSONA_IDS.POINT as schema-required default Owner so facets_owner_id_sync_check deferred trigger satisfies at COMMIT, businessCommercialState=payg per CHECK, stable id 00000000-0000-4000-b800-0000000000fa), facet_environments rows for sandbox (default=true) and live (both status=active, since /resolve uses environment=sandbox for pk_sandbox\_\* and environment=live for pk_staging\_\*), operator Owner membership gated by SURFACE_KIT_DEMO_OPERATOR_EMAIL env var (production-safe — refuses to write if NEXT_PUBLIC_APP_ENV/NODE_ENV is "production" regardless of how the seed was invoked; idempotent skip if owner/admin role already present, promote on existing non-owner/admin), redirect URI registration configurable via SURFACE_KIT_DEMO_BASE_URL (always registers localhost variants with environment=sandbox; ALSO registers deployed-host variants with environment=live when the env var names a non-localhost origin); adds new `scripts/check-surface-kit-readiness.ts` — read-only 6-point health check (b-metered-co facet exists / facet_environments has sandbox+live active rows / Surface Kit Demo Parent api key active+mode=test / both pk_sandbox keys active+unrevoked / operator Owner-or-Admin role on b-metered-co facet (skipped cleanly when env unset, treated as not-applicable not as fail) / redirect URIs registered for both demos with the right environment column), runs SELECT-only with zero writes, refuses production unless --confirm-production passed and even then only reads, exit code 0/1/2 for all-pass/any-fail/config-error, wired to npm run validate:surface-kit-readiness; new docs/runbooks/surface-kit-staging-readiness.md captures the staging baseline output (3/6 checks passing — facet missing, environments missing, redirect URIs missing — exactly the gaps the seed extension fills), documents both durable repair (re-seed) and immediate-repair SQL (one-shot Supabase dashboard block matching the seed's writes; reversibility section included), with explicit "Never do" list pinning that the resolve chain, issue authorization, Owner/Admin requirement, publishable-key policy, and production data are all out-of-scope for repair; 25 predicate tests at tests/unit/dat-003/readiness-check.spec.ts using content-based mock dispatch (each from(table) → its fixture rather than fixed sequence) so conditional flows like operator-email-skip don't shift the queue; out of scope per locked direction: any change to /resolve context lookup, /issue authorization, Owner/Admin requirement, publishable-key policy model, or production data writes; staging baseline captured 2026-05-08 from staging.cairl.app — Surface Kit Demo Parent api key + pk_sandbox keys present (PR #739 seed legacy), b-metered-co facet + environments + redirect URIs missing (the gaps DAT-003 closes). (DAT-003)

DAT-004 closeout — staging-live demo Step 8 verified end-to-end on 2026-05-12 (engine path + Surface Kit handoff + OAuth code mint + AcmeShop callback row in `verification_callback_logs`), and codifies three fixes that prevent regression: (a) `scripts/seeds/17-surface-kit-keys.ts` `SURFACE_KIT_DEMO_CLAIMS` constant changed from the malformed legacy hybrid `["age_21+", "identity_verified", "face_match", "freshness_current"]` (which caused `invalid_scope` at the OAuth authorize endpoint because two values weren't in the LEGACY*TO_CANONICAL map and two passed-through canonical IDs aren't in VALID_SCOPES) to OAuth-scope format `["age:21+", "identity:verified", "freshness:status"]` — these strings sidestep the `LEGACY_TO_CANONICAL` map AND match `VALID_SCOPES`, restoring the OAuth handshake; (b) `src/lib/verify-surface-kit/callback-log.ts` exposes a new `getBusinessContextIdForSlug` helper and the call sites in `src/app/sandbox/_components/demo-customer-callback-view.tsx` now pass `businessContextId` on both the resolved-path and rejected-path `recordSurfaceKitCallback` invocations, populating the previously-NULL `verification_callback_logs.business_context_id` column for the "all callbacks for partner X" ops query pattern the schema was designed to support; (c) `docs/runbooks/founder-led-staging-demo.md` adds a new "Verified end-to-end path (DAT-004 closeout, 2026-05-12)" section with six explicit watchpoints (mode-switch, hvf* token mint, /verify/start token validation, verification completion, OAuth code mint, AcmeShop callback render + DB row), updates the Fixture identity reference to reflect that assets are now provisioned (FLHSMV-published "FLORIDA SAMPLE" specimen + Rekognition face id `208cf6b2-469c-40d8-b34f-6f6cbca586fc` in `cairl_dat004_fixture` collection), and inserts a load-bearing "Degenerate-fixture qualifier" warning block stating that the high face-match/liveness scores prove integration plumbing — NOT anti-spoof/biometric strength — because the selfie source is the same image as the document portrait and the same image enrolled in Rekognition (B2B partner-integration story is investor-ready, B2C biometric-strength story is NOT and needs a non-degenerate fixture before any anti-spoof pitch); adds three follow-up trackers at `docs/tasks/_followups/` (non-degenerate biometric fixture P2, mode-badge state mismatch P4 cosmetic, broader claim-canonicalization architecture P3 deferred decision between CLM-002 canonical / OAuth scopes / explicit translators); adds 3 regression tests at `tests/unit/dat-004/closeout.spec.ts` covering the seed claims-format invariant (positive: OAuth scopes present in the constant array; negative: malformed legacy hybrids must NOT reappear in the array body even if mentioned in surrounding comments) and the business-context map (returns the canonical surface-kit demo facet UUID for both age-gate and trust-gate slugs); explicitly out of scope for this PR per founder direction: redesigning `VALID_SCOPES` or claim canonicalization architecture, starting HVF-014, touching production data. (DAT-004)

Fix face-match retry destroying the HVF auth session — in-place retry overlay (`FaceMatchRetryOverlay`) when `canRetry: true && attemptsRemaining > 0` instead of session-terminating, HVF session cookie via `src/lib/hvf/session-cookie.ts` + `POST /verify/start/register-cookie` route surviving browser refresh on the verify flow, `POST /api/verify/session/refresh-liveness` for AWS Liveness one-shot retake, server-side retry budget (`MAX_FACE_MATCH_ATTEMPTS = 3`) preserved from VRF-014 Phase 0 + VRF-016, 15 unit + integration tests under `tests/{unit,integration}/vrf-019/`; separately, sandbox flag now propagates from `pk_sandbox_*` resolve into the HVF session and short-circuits the verify shell before any verification-engine routing — authenticated unverified customers in synthetic-fixture sandbox mode no longer reach liveness, Rekognition, Textract, or document upload paths. (VRF-019)

[0.6.5] - 2026-05-08

Added

CAIRL Verify Surface Kit user-facing surfaces and demo apps (HVF-013 PR 3 of N): five primitive components under src/components/verify-surface-kit/ (PoweredByCairl basic-text trust mark, ModeIndicatorBadge always-visible sandbox/staging-live signal with screen-reader announce, CairlCheckbox claim-agnostic checkbox-shaped trigger with ARIA checkbox pattern + Space-key activation, CairlButton claim-agnostic button trigger wrapping the existing shadcn Button defaulted to variant default size lg, SwitchToStagingLiveConfirmation 5-acknowledgement AlertDialog modal that gates demo transition into staging-live mode); a shared use-resolve-call hook owning the idle to resolving to redirecting to error state machine with PKCE code-challenge generation so both primitives stay byte-for-byte identical on the resolve contract; a DemoModeController client island that renders the always-visible badge and hosts the modal with the local-only mode flip gated by a server-rendered serverAuthorized predicate; six demo route pages under src/app/sandbox/age-gate/ and src/app/sandbox/trust-gate/ (each demo gets a Business-side config view, a User-side customer flow, and a callback log view), three shared view components under src/app/sandbox/\_components/, and a server-only demo-config helper under src/app/sandbox/\_lib/ that resolves the per-demo publishable key from env vars (SURFACE_KIT_DEMO_PK_AGE_GATE / \_TRUST_GATE) with a redacted placeholder fallback so the demo pages are safe-to-serve without committing any real key; a Playwright surface test suite at tests/e2e/verify-surface-kit-sandbox-demos.spec.ts covering golden surface render plus mode-indicator visibility on every demo route plus modal Operator-only disabled state plus primitive-drives-resolve-with-error-UI; and a small refactor that extracts SESSION_SUPERSEDED_KV_PREFIX from the duplicate string in validate.ts and resolve-pk.ts to a new shared server-only module at src/lib/hvf/superseded-session.ts so the read side and write side cannot drift silently; out of scope for this PR are the /api/verify-surface-kit/mode-switch endpoint that the modal will eventually POST to (deferred to PR 3.5 lifecycle alongside issue and revoke), the demo callback receiver and persistent log store (deferred to broader demo wiring), the seed extension that issues per-demo pk_sandbox keys (deferred), the HVF-014 hosted modal and hosted page render modes, and the broader no-secret-bundle browser-bundle scan (PR 4); 98 unit tests still green plus 0 lint errors plus type-check clean. (HVF-013)

Blog post: "Utah Just Made VPNs a Liability Trap" — standalone Thought Leadership post on Utah SB 73 (effective 2026-05-06), the first U.S. age-verification statute to write VPN circumvention into law. Adds `src/content/blog/utah-vpn-age-verification-sb73-live.mdx`, OG image at `public/blog/og/utah-vpn-age-verification-sb73-live.png` (1200×630), and registers a new entry in `src/content/blog/posts.ts`. Frontmatter follows the locked field set used by the other 7 published posts. Not cross-linked from the existing "Age Verification Laws Are Here" post per founder direction; staging directory `docs/_incoming/post-1/` consumed and removed. (MKT-325)

Changed

CAIRL Verify Surface Kit resolve-engine tier (HVF-013 PR 2 of N): extends the hvf token payload at src/lib/integrations/hvf-token.ts with four optional fields per §6.6 (partner-api-key-id, source, mode, session-id) so each surface-kit-minted token carries enough policy context for the validator to enforce exactly the authorization the resolve endpoint granted, while preserving the existing SPF-002 single-arg generateHvfToken call form unchanged so all current Shopify integration call sites compile and run without modification (§19 #13); extends src/lib/hvf/validate.ts conservatively to honor the new fields when present (the partner-api-key-id resolves to that specific row scoped to the partner id and is-active true; mode set to sandbox flips isTestMode and routes through the test-mode HTTP-redirect plus scope-narrowing branches; session-id check returns the new errorCode session-superseded distinct from invalid-token per §19 #9, with no PII in the description) while leaving legacy hvf tokens (no new fields) on the previous always-live-mode plus first-active-key behavior so SPF-002 consumers are byte-for-byte unchanged; adds the POST resolve route at src/app/api/verify-surface-kit/resolve/route.ts backed by the new resolve-pk lib module at src/lib/verify-surface-kit/resolve-pk.ts which implements the locked 12-step §4.4 chain end-to-end — format and prefix validation, SHA-256 hash lookup, active and not-revoked and not-expired check, server-side env gate (canResolvePrefixFamily; the pk-live family short-circuits to 501 per §19 #15), parent cairl-prefix key lookup via FK, partner-to-facet bridge, redirect-URI allowlist exact-match scoped to context plus environment plus deleted-at, canonical CLM-002 claim normalization (§19 #5), per-key rate limit (60 per minute sandbox, 30 per minute staging) via kvIncr, audit emission with PII filter applied (§17.2 plus §19 #20) and fail-closed on audit-write failure (§19 #19), hvf token mint with unique UUID v4 session id (§19 #7), §6.9 dedup window (sandbox returns the cached entry idempotently, staging supersedes the prior session id), and best-effort lifecycle counters (last-used-at, daily-call-count) on success; integration tests under tests/integration/verify-surface-kit/resolve.test.ts cover the golden path, every rejection branch including the pk-live family at both app-layer and DB CHECK constraint, cross-environment redirect rejection per §19 #18, the last-used-at and daily-call-count updates, sandbox mode in the token payload, paired audit emission for both attempt and rejected events with sandbox retentionClass 90d, the dedup-window idempotent return plus the distinct-state mint, and the §19 #16 sandbox-ICC-integrity guarantee (zero identity-attributes rows created during a sandbox resolve); 110 unit tests still green (67 verify-surface-kit plus 31 hvf plus 12 sec-011), no regression on the legacy SPF-002 path; out of scope for this PR are the issuance and revocation and mode-switch and keys-list endpoints (PR 3 lifecycle), the React component layer and demo apps and the broader no-secret-bundle browser-bundle scan (PR 4 components plus demos plus bundle scan), and the docs promotion-manifest execution (final promotion PR). (HVF-013)

Fixed

Investor section nav now hero-integrated with mobile/tablet `Sheet` drawer, replacing the horizontal-scroll row that broke at small viewports. (MKT-324)

Security

Redact raw `cairl_test_*` and `cairl_live_*` key literals committed to the launch-evidence tree, plus add a CI grep guard that blocks any future regression. Surfaced during the HVF-013 PR 1 pre-commit audit when SEC-009 and SEC-010 (commit `8500e4e3c`, merged 2026-05-07) shipped DigiCert-class token-hashing hardening but did not pick up the original spec-§14 raw-literal scrub thread. Four committed launch-evidence files on `origin/main` carried high-entropy `cairl_test_*` literals in violation of HVF-013 §19 #2 and §19 #6: `docs/launch/evidence/R-03/2026-03-18/api-key-generation.txt` carried the full real 64-hex value generated during R-03 launch verification; `docs/launch/evidence/HVF-01/2026-03-19/partner-entry-url.txt` and `docs/launch/evidence/R-05/2026-03-18/authorize-request.json` carried the placeholder shape (`cairl_test_deadbeef` followed by 56 zeros — still a 64-hex literal that matches the real-key shape and trips any leak-scanner heuristic); `docs/launch/evidence/S-02/2026-03-18/pkce-live-test.md` carried shorter ellipsis-trimmed references that still matched the high-entropy regex. All four are now redacted to the `cairl_test_REDACTED_<lastFour>` convention, with an inline `[SEC-011 2026-05-07 scrub]` note recording what was changed and why so future readers see the provenance; the launch-verification PASS verdicts the files originally captured are unaffected. The new `validate:secret-literals` npm script (`tsx scripts/check-cairl-secret-literals.ts`) walks the `docs/` tree and exits non-zero if any line matches `cairl_(test|live)_[a-f0-9]{16,}` — the 16+hex threshold is deliberate so the real-key shape is caught (every real `cairl_*` key is 64 hex, including the `deadbeef` placeholder which is also 64 hex) while still allowing prose discussion of the prefix using shorter truncated references like `cairl_test_deadbeef` (8 hex); the script is wired into `pr-quality.yml` as the `secret-literal-scan` job (additive only, runs alongside the existing `initiative-registry-guard` and `lint` jobs, blocks merge on failure). Predicate tests under `tests/unit/sec-011/secret-literal-scan.test.ts` prove the scan correctly fires (real 64-hex shape, placeholder 64-hex shape, JSON-embedded literal, recursive into nested directories, exact 16-hex boundary, both `_test_` and `_live_` prefixes) and correctly passes (redacted form, 8-hex prose reference, exactly-15-hex boundary, non-cairl prefix, non-cairl publishable `pk_sandbox_*` shape); the live-repo-state test asserts the current `docs/` tree is clean today so any future PR that reintroduces a violation fails this test in addition to the workflow-step failure (test-the-predicate-not-just-the-data per the project's recorded feedback). Out of scope for this PR (separate follow-ups, intentionally not silent debt): the `tests/e2e/launch/` deterministic-fixture-key literals (need ephemeral-key injection or env-var indirection — a larger test-infrastructure refactor); extending the scan to the publishable `pk_(sandbox|staging|live)_<hex>` family (handled inside HVF-013's bundle scan in a later PR — the publishable-key family does not yet exist on main so there is nothing to scan); extending the scan to `src/` source files (handled by HVF-013 PR 4 as a build-time production-bundle scan rather than a source-tree grep, which is the appropriate place for that check since src-tree files do not all ship to browsers); rotating the real R-03 launch-evidence test key (only meaningful if that test parent gains production capability, which it has not — the redaction stops the literal-leak surface). Pre-existing illustrative references in `docs/_archive/legacy/` and `docs/specs/oauth/api-contracts.spec.md` are all below the 16-hex threshold (truncated prefix examples or non-hex placeholders) and pass the guard as documentation of the prefix shape itself, so they are intentionally untouched. (SEC-011)

[0.6.4] - 2026-05-07

Added

`/investors` Company & Investors hub (MKT-316) — public investor-facing page with hero ("Privacy-first identity infrastructure for proof without exposure."), thesis snapshot (Problem / CAIRL Model / Investor Relevance), product modules grid (Vault, Verification Engine, OAuth Provider, Mail, Pay), market wedges (Business Users, Developers, People, Partners), operating-priorities panel, and restrained "Request investor materials" CTA routing to `/investors/request-materials` (gated mailto placeholder, `noindex`). Footer Company column gains an "Investors" link; sitemap adds `/investors`. Public copy holds the hard guardrails: no valuation, round size, allocation, deadlines, financing terms, cap table, deck/data-room downloads, revenue/return promises, customer/pilot names, or unsupported compliance claims; reserved relationship-taxonomy terms (End User, Customer, Client, Subscriber, Tenant, Workspace, Organization, Account, Team, Project) are not used. Sub-pages (`/investors/thesis`, `/product`, `/market`, `/traction`, `/operating-plan`, `/trust-and-compliance`) deferred to follow-up reservations. (MKT-316) (MKT-316)

`/investors/thesis` investor thesis page (MKT-317) — Page 02 of the Company & Investors section build spec. Public, indexable, founder-memo-tone page that anchors the section: locked H1 ("The internet needs proof without exposure."), problem section (3 cards), broken-model callout, CAIRL Store→Verify→Reuse model with 3-step flow, why-now driver grid (4 cards), B2B-first wedge with two-column comparison, privacy-first advantage with 4 principle cards, "What CAIRL is not" restraint panel, long-term infrastructure-opportunity panel with 3-condition investor signal, and closing CTA routing to `/investors/request-materials`. Wires the `/investors` hub secondary CTA from the prior "About CAIRL" placeholder to "Read the thesis" → `/investors/thesis`. Sitemap adds `/investors/thesis`. Hero secondary CTA omitted per spec §11 Option A to avoid a dead link to `/investors/product` until MKT-318 ships. Public copy holds the MKT-316 guardrails: no valuation, round size, allocation, deadlines, financing terms, cap table, deck/data-room downloads, revenue/return promises, customer/pilot names, unsupported compliance claims, "banking replacement" or "anonymous identity" or "guaranteed fraud prevention" framing; reserved relationship-taxonomy terms (End User, Customer, Client, Subscriber, Tenant, Workspace, Organization, Account, Team, Project) not used in public copy. (MKT-317) (MKT-317)

`/investors/product` investor product page (MKT-318) — Page 03 of the Company & Investors section build spec. Public, indexable product-direction page with locked H1 ("From sensitive documents to reusable proof."), product-architecture snapshot (Vault → Verification Engine → OAuth Provider → Privacy Services 4-layer flow), four product-layer sections (CAIRL Vault feature grid; Verification Engine capability grid with explicit no-perfect-fraud-elimination boundary; OAuth Provider 3-step flow + 4 developer-value cards; Privacy Services with CAIRL Mail card and CAIRL Pay framed only as a controlled future area), Now/Next/Later roadmap-discipline cards (no exact private dates), product-boundaries restraint panel (8 explicit non-positionings), product-compounding 4-condition investor-signal panel, and closing CTA section with primary route to `/investors/request-materials` and secondary route back to `/investors/thesis`. Sitemap adds `/investors/product`. Public copy holds the MKT-316/317 guardrails: no valuation, round size, SAFE language, financing terms, allocation, closing deadline, investor return claims, revenue forecasts, public deck or data room links, customer/pilot names, exact private roadmap dates, internal repository details, security-sensitive architecture diagrams, vendor secrets, or environment names; no "banking replacement", "anonymous payments", "guaranteed fraud prevention", "HIPAA compliant", or "SOC 2 certified" claims; reserved relationship-taxonomy terms (End User, Customer, Client, Subscriber, Tenant, Workspace, Organization, Account, Team, Project) not used in public copy. (MKT-318) (MKT-318)

`/investors/market` investor market page (MKT-319) — Page 04 of the Company & Investors section build spec. Public, indexable, founder-led-GTM-tone page with locked H1 ("Focused entry, expandable trust layer."), market-model snapshot (Business Users, Developers, People, Partners), primary-wedge Business Users use-case grid (age assurance, identity confidence, document trust, eligibility proof), Developers integration-path grid (OAuth flows, APIs/docs, testing, implementation confidence), People trust-center grid (control, consent, reduced repetition, confidence), Partners ecosystem-layer grid with explicit "Partners ≠ Business Users" taxonomy note, future regulated-expansion grid (regulated services, government-adjacent workflows, healthcare/sensitive-data, enterprise trust review), Discover→Pilot→Integrate→Expand 4-step GTM sequence, market-compounding 4-condition investor-signal panel, and closing CTA section with primary route to `/investors/request-materials` and secondary route to `/investors/product`. Sitemap adds `/investors/market`. Stacked with MKT-316/317/318 in PR #721 per founder direction 2026-05-06; supersedes the queued MKT-319 follow-up file. Public copy holds the MKT-316/317/318 guardrails plus market-page-specific prohibitions: no "Our market is everyone" framing, no TAM/SAM/SOM numbers, no government-approval / government-adoption claims, no logo cloud, no consumer-virality language, no funnel graphics implying unproven traction; reserved relationship-taxonomy terms (End User, Customer, Client, Subscriber, Tenant, Workspace, Organization, Account, Team, Project) not used in public copy; Partners are framed strictly as ecosystem collaborators and never as a synonym for B2B customers. (MKT-319) (MKT-319)

`/investors/operating-plan` investor operating-plan page (MKT-320) — Page 05 of the Company & Investors section build spec, with locked H1 ("Scale in the order risk demands."), operating-principle 6-step priority flow (security → product → B2B validation → developer support → compliance readiness → growth), First-3 / First-5 / First-10 hire stages, capital-priorities grid, milestone-based scaling gates, "what not to hire early" restraint panel listing 10 vanity roles to avoid, investor-signal panel, and dual CTA. Sitemap adds `/investors/operating-plan`. Also adds a shared `InvestorSectionNav` component (`src/components/marketing/investor-section-nav.tsx`) rendered above the hero on `/investors`, `/investors/thesis`, `/investors/product`, `/investors/market`, and `/investors/operating-plan` — desktop horizontal pill row, mobile-scrollable, keyboard accessible (`aria-current="page"`), with restrained `Request materials` CTA. The nav does not appear on `/investors/request-materials` (utility surface, `noindex`) and does not link to future unbuilt pages (`/investors/traction`, `/investors/trust-and-compliance`). Public copy holds the MKT-316/317/318/319 guardrails plus operating-plan-specific prohibitions: no salary budgets, no runway claims, no "we will hire 10 people immediately" language, no fake departments/committees, no premature executive titles framed as filled; reserved relationship-taxonomy terms (End User, Customer, Client, Subscriber, Tenant, Workspace, Organization, Account, Team, Project) not used in public copy. Specialized legal / SOC 2 / HIPAA / security-testing / regulatory work explicitly framed as counsel-or-contractor-led until full-time ownership is justified. (MKT-320) (MKT-320)

`/investors/trust-and-compliance` investor trust posture page (MKT-321) — Page 06 of the Company & Investors section build spec. Public, indexable trust-and-compliance page with locked H1 ("Trust is the product surface investors cannot ignore."), trust-principle 4-card panel (collect less / expose less / control access / preserve evidence), privacy-posture grid, security-posture grid (6 capabilities including encryption, least-privilege access, audit logging, environment discipline, secure integration paths, monitoring and response), compliance-readiness grid using SOC 2 / HIPAA readiness language without claiming certification, governance-reality grid that reflects founder-led operations without fake departments or committees, vendor-and-infrastructure-posture grid, incident-and-evidence-readiness grid, claim-boundaries dual panel listing prohibited claims (SOC 2 certification, HIPAA compliance, government approval, banking status, anonymous identity/payments, breach-proof / bank-grade / military-grade, guaranteed fraud prevention, government-ID replacement) alongside safer language alternatives (SOC 2 readiness, HIPAA readiness, security-by-design, controlled disclosure, least-privilege access, audit-ready evidence, external review as maturity requires), 6-condition investor-signal panel, and dual CTA. Sitemap adds `/investors/trust-and-compliance`. The shared `InvestorSectionNav` (introduced in MKT-320) gains a `Trust & compliance` link, propagating automatically to all five existing investor pages plus the new page; the nav still does not render on `/investors/request-materials` and does not link to future unbuilt pages. Public copy holds the MKT-316/317/318/319/320 guardrails plus trust-page-specific prohibitions: no internal architecture diagrams, no vendor secrets, no environment names, no private audit evidence, no penetration test reports, no incident runbook details. Governance language explicitly avoids fake departments / committees / approval chains; specialized legal / SOC 2 / HIPAA / security-testing / regulatory work is framed as counsel-and-contractor-led until full-time ownership is justified. (MKT-321) (MKT-321)

`/investors/traction` investor traction-and-evidence page (MKT-322) — Page 07 of the Company & Investors section build spec. Public, indexable traction page with locked H1 ("Early progress, clearly labeled."), current-stage 4-card panel (founder-led / pre-scale / evidence-focused / pilot-oriented), evidence-snapshot 5-card grid across product / trust / market / operating / investor readiness, product-progress grid (vault / verification / OAuth / privacy services directions), trust-readiness grid (privacy-first architecture / security-by-design posture / compliance readiness / governance clarity), market-learning grid (B2B-first motion / developer path / people trust loop / partner clarity), operating-readiness grid (hiring sequence / capital discipline / founder-led GTM / governance restraint), next-proof-points 5-step flow (qualified discovery → structured pilots → developer integration → trust evidence → repeatability), claim-boundaries dual panel listing prohibited unsupported traction claims (revenue scale, signed pilots, named Business Users / Partners, waitlist size, usage scale, conversion metrics, enterprise / government adoption, SOC 2 certification, HIPAA compliance, guaranteed fraud prevention) alongside disciplined safer-language alternatives (early / founder-led / pre-scale / product foundations / trust readiness / market learning / structured pilots / evidence discipline / investor readiness), 5-condition investor-signal panel, and dual CTA. Sitemap adds `/investors/traction`. The shared `InvestorSectionNav` (introduced in MKT-320) gains a `Traction` link, propagating automatically to all six prior investor pages plus the new page; the nav still does not render on `/investors/request-materials` and does not link to future unbuilt pages. Public copy holds the MKT-316/317/318/319/320/321 guardrails plus traction-specific prohibitions per spec §7 / §17 / §26 / §27: no public exposure of private repository details, private pipeline details, internal metrics, private audit evidence, vendor secrets, or environment names. (MKT-322) (MKT-322)

`/investors/request-materials` full gated investor materials intake form (MKT-323) — Page 08 of the Company & Investors section build spec. Replaces the prior mailto placeholder with a structured intake flow: hero ("Start a qualified investor conversation."), two-column desktop layout (form + "What happens next" 4-step process panel + "Not automatic" boundary box + direct-inquiry email), professional intake form with 7 required fields (full name, email, investor type, investment focus, strategic interest, message, acknowledgement checkbox annotated as requiring counsel review before production) plus 5 optional fields (firm/company, website, typical check range, how heard, existing relationship), success state with restrained CTAs back to the section, error states that do NOT leak provider detail, and a privacy/boundary note prohibiting confidential investment documents and sensitive personal information in submissions. Page remains `noindex` and is NOT in the sitemap. Adds `POST /api/investors/request-materials` route with layered defense: 16 KB body cap, JSON parse, honeypot via `confirmToken` (silent 200, no email — fires before schema), IP rate limit 5/hr + 50/day with `Retry-After`, zod `.strict()` schema with closed enums and length bounds, Mailgun notification to `investors@cairl.app` via canonical `renderEmailShell` template. Adds `src/lib/investors/inquiry-schema.ts`, `src/lib/investors/inquiry-rate-limit.ts`, `src/lib/email/templates/investor-inquiry.ts`, plus 22 unit tests at `tests/unit/mkt-323/` (12 schema cases + 10 API route layered-defense cases). The shared `InvestorSectionNav` (introduced in MKT-320) now renders on `/investors/request-materials` and highlights the `Request materials` CTA with `aria-current="page"` and a filled primary background. Persistence deferred per spec §13.2 (no DB table; email is source of record for this sprint). Submitter confirmation email deferred per spec §13.4 (success state on page is sufficient). No CAPTCHA per spec §14.3 — honeypot + IP rate-limit only, deviating from the security-report and newsletter precedents which both use reCAPTCHA Enterprise; deviation is intentional because investor inquiry volume is expected to be founder-led B2B inflow rather than consumer scale, and reCAPTCHA can be added later via the shared `AbuseGuard` without page-level rework if abuse surfaces. Locked guardrails per spec §7 / §13.1 / §22: NO collection of SSN, government ID, date of birth, home address, banking details, tax info, accreditation proof, or file uploads (the schema is the type-level enforcement); NO public claims of valuation, round size, SAFE terms, allocation, closing deadline, investor return language, revenue forecasts, public offering language, or "Invest now" / "Join the round" / "Reserve allocation" framing; NO data room, deck download, term sheet download, or automatic-distribution logic; reserved relationship-taxonomy terms not used in public copy. (MKT-323) (MKT-323)

Fixed

Normalized CAIRL email templates to use a shared branded header/footer, approved logo treatment, canonical tagline, and consistent plain-text fallbacks. Every transactional email — verification, password reset, verification codes, business partner onboarding, partner-team invitations, billing dunning, partner wallet alerts, contact form, careers notifications, and internal ops alerts — now renders through `src/lib/email/shell.ts` with one footer that carries the legal entity (`Reapplicate Inc d/b/a CAIRL`), the approved transactional tagline (`Proof without exposure.`), the canonical support routing (`support@`, `security@`, `privacy@`, plus `legal@` on legal variants), and an absolute reference to the email-safe wordmark asset. Brand-invariant tests in `tests/unit/mlg-003/email-brand-invariants.test.ts` now guard against drifted footers, disallowed legacy taglines, reconstructed wordmarks, and reserved relationship-taxonomy terms in user-facing copy. The newsletter, MailGap activity alerts, newsletter confirmation, and stub partner-team templates remain on their existing locked product copy by design and are flagged in the audit doc at `docs/reporting/evidence/MLG-003/email-template-brand-audit.md`. (MLG-003)

Fix verification flow terminally rejecting users for "face match failed" before Rekognition `CompareFaces` was actually invoked. Pre-CompareFaces gates (`selfie_quality`, `selfie_face_detected`, `id_face_detected`) previously caused `/api/verify/complete` to set `users.verification_status='rejected'` and `id_verification_sessions.admin_review_reason='face_match_failed'` on the user's first attempt, even though `face_match_attempts: 0` and `face_match_scores: []` proved the comparison never happened. `decideVerifyCompletion()` now distinguishes a genuine post-CompareFaces failure from a pre-CompareFaces gate failure: when the `face_matched` gate fails AND `faceMatchAttempts === 0`, the function returns a new `finalStatus: "retryable"` outcome with a precise upstream `retryableReason` (`image_quality_failed` / `selfie_face_not_detected` / `document_face_not_detected` / `face_match_not_attempted`) and the `/complete` route returns HTTP 409 instead of poisoning the user row. Terminal `face_match_failed` is reserved for sessions where Rekognition actually ran and returned a confidence below threshold. Document-level terminal failures (expired, type mismatch, spoof flag) are unaffected. (VRF-016)

Fix the verification flow hanging on the dark "Almost done…" overlay when the partner is in the VRF-014 async-extraction allowlist. `POST /api/verify/process-pipeline` returns HTTP 202 on the async branch with all extraction fields explicitly null while the SQS-driven worker runs Lambda + Textract; the client previously caught 202 in its `if (!response.ok)` branch and surfaced an error overlay (with no polling loop), so the spinner spun indefinitely until the user refreshed the page — which replayed the OAuth `state` parameter and broke the partner-bound HVF auth session, forcing a fresh `/verify/start` URL. `handleProcessing()` in `src/app/(verify)/verify/VerifyFlowClient.tsx` now treats 202 as the async-in-flight signal and polls `GET /api/verify/session/resume?sessionId=…` once per second (60s wallclock cap) until `extractionData` populates, then advances to the confirm step using the same flow-state shape as the synchronous 200 response. A new `processingCancelledRef` cleared by the `step="processing"` `useEffect` cleanup ensures any in-flight polling drops cleanly when the user abandons or navigates away mid-processing rather than writing into stale flowState. No schema, no API-shape, no server-side change. (VRF-017)

Fix demo-blocking spinner-hang on the async-extraction path. After a successful SQS enqueue inside `POST /api/verify/process-pipeline` and `POST /api/verify/face-collection/index`, the route now schedules a fire-and-forget warm-up call against the same staging cron worker URL using Next.js's `after()` post-response primitive, draining the SQS message in ~3–5 seconds instead of waiting up to 5–15 minutes for the next GHA `*/5 * * * *` scheduled tick (GitHub's documented cron jitter is measured in minutes, not seconds — confirmed during the 2026-05-06 VRF-017 Verified-on-Staging drive when a 67-second drain wait was about to time out the VRF-017 client polling loop). Default OFF behind the `ASYNC_VERIFY_WORKER_WARMUP` env var so production behavior is unchanged until the flag is explicitly enabled. Per-worker-type 15-second debounce prevents thundering-herd self-calls under concurrent enqueues. Logs include the worker response status code only, never the bearer token. If warm-up fails for any reason (network, 5xx, missing env), the GHA scheduler remains as the always-available fallback drain — VRF-018 is a latency optimization on top of an already-correct architecture, not a hard dependency. This is explicitly NOT a replacement for VRF-015 Phase B (Lambda + SQS event source mapping); it is a tactical demo-safe bridge so partner-facing flows do not hang while Phase B's infra-as-code decision is made. (VRF-018)

Security

DigiCert-style support/admin abuse hardening. Five concrete vectors closed: (1) admin document and verification preview presigned URLs now force `Content-Disposition: attachment` so a user-uploaded PDF with embedded JavaScript downloads instead of executing inline in an admin's browser origin (`src/app/api/admin/documents/[id]/route.ts`, `src/app/api/admin/verifications/[id]/route.ts`); (2) `GET /api/admin/users/[id]` now strips `recoveryKeyHash` alongside `passwordHash` from admin/support responses; (3) `POST /api/evaluations/scripts/[id]/evidence` enforces an explicit MIME allowlist (PDF, JPEG, PNG, WebP, plain text) and a 10 MB cap, replacing the prior "any `file.type`, any size" intake; (4) the deprecated `/api/verification/liveness/credentials` route no longer falls back to returning long-lived `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` when `NODE_ENV === "development"` — the scoped, time-limited STS-assumed path is now the only allowed surface; (5) `email_verification_tokens.token` and `password_reset_tokens.token` now store `sha256(plaintext)` instead of plaintext via the new `src/lib/auth/token-hash.ts` helper, so anyone with database read access (DBAs, support tooling, leaked backups, query logs) can no longer redeem a pending verification or password reset solely from a visible row — the precise DigiCert pattern. Existing in-flight tokens are invalidated at deploy; users in the middle of either flow request a new email or reset link. (SEC-009)

Hashed the remaining partner auth secrets at rest (SEC-009 deferred follow-up). `partner_email_verification_codes.code` and `partner_password_reset_tokens.token` were both stored as plaintext, the same DigiCert-class exposure pattern SEC-009 closed for `email_verification_tokens` / `password_reset_tokens` on the user side: anyone with database read access (DBAs, support tooling, leaked backups, query logs) could redeem either solely from a visible row. Storage now splits by entropy: `partner_password_reset_tokens.token` stores `sha256(plaintext)` of a high-entropy `crypto.randomBytes(32)` token (preimage / collision attacks computationally infeasible against 256 bits, salt/pepper add nothing); `partner_email_verification_codes.code` stores `HMAC-SHA256(plaintext)` keyed on a server-only `AUTH_TOKEN_PEPPER` env var via the new `src/lib/auth/token-hash.ts#hashShortAuthCode` helper, because plain SHA-256 of a 6-digit code is offline-brute-forceable in microseconds against the 1,000,000-entry keyspace — HMAC with a server-side pepper closes that gap (an attacker without the pepper cannot precompute the table). The helper trims input, rejects anything that is not exactly 6 digits, and fails closed (`throw`) when the pepper is missing or shorter than 32 chars outside `NODE_ENV=test`. The live `POST /api/partners/verify-email` route hashes its candidate input through `hashShortAuthCode` before lookup. Seed scripts (`scripts/seeds/02-partners.ts`) write the matching shape: HMAC for codes, SHA-256 for high-entropy tokens. Schema-level documentation comments distinguish the two columns explicitly so a future contributor cannot silently downgrade either. Migration `0069_sec010_hash_partner_secrets.sql` widens `partner_email_verification_codes.code` from `varchar(6)` to `varchar(64)` to fit the HMAC-SHA256 hex digest and truncates both tables since the previous plaintext rows are no longer redeemable under the new hash-at-rest invariant. Production has zero rows in either table (the legacy partner-registration path that drove these tables was retired before this migration); staging/dev seed rows are invalidated at deploy and re-seeded via the matching helpers. `partner_invitations.tokenHash` was already hashed and remains unchanged. Adds `AUTH_TOKEN_PEPPER` to the required env contract (`scripts/check-required-env.ts` and `.env.example`); promotion gates fail closed if the var is absent in deployed environments. Adds regression tests under `tests/unit/sec-010/` proving: (a) the live route uses `hashShortAuthCode` (HMAC) and not `hashAuthToken` (raw SHA-256); (b) the helper rejects non-6-digit input; (c) the helper fails closed when the pepper is missing in production / staging or shorter than 32 chars; (d) the HMAC digest does not equal `sha256("123456")`; (e) seed scripts insert the matching shape per column; (f) admin/support/user GET surfaces do not return either column; (g) schema docs distinguish HMAC short-code storage from SHA-256 high-entropy token storage. (SEC-010)

[0.6.3] - 2026-05-06

Fixed

Fix Mailgun URL convention divergence that prevented the anonymous newsletter double-opt-in path from sending its confirmation email on production. `MAILGUN_API_BASE_URL` is now consistently expected to include the `/v3` API version segment across every Mailgun-using module — `src/lib/email/mailgun.ts` previously inserted `/v3/` itself on the assumption the env var was the bare host, while the newsletter and mailing-list paths assumed the env var already contained `/v3` and 404'd when it didn't. Aligned to the canonical convention documented in `src/lib/mailgun/newsletter-config.ts:16`. Bundles the post-SEC-008 vendor copy update — replaces user-visible "Cloudflare Turnstile" references with "Google reCAPTCHA Enterprise" across the demo gate, trust page, security page, security executive summary, and the Privacy Policy / DPA / Cookie Policy / Terms of Service subprocessor sections so the public surface area is factually consistent with the SEC-008 migration. The `error: "turnstile_failed"` partner-callback wire-protocol code in `/api/checkbox/[configId]/start` is intentionally retained per the SEC-008 lock. (MLG-002)

[0.6.2] - 2026-05-06

Changed

Replace Cloudflare Turnstile with Google reCAPTCHA Enterprise as CAIRL's anti-abuse control on every public form (`/security/report`, `/verify/demo`, newsletter signup, careers application, checkbox consent). Real Cloudflare sitekeys returned client-side error `400020 "Invalid sitekey"` across two distinct widgets and four consumers despite SEC-007 fixing all integration-side bugs (`size: "invisible"` removal, idempotent loader, visible-error path, hash-module env wiring). Cloudflare test sitekeys cleared the same code path successfully, confirming the residual fault was upstream of CAIRL. SEC-008 migrates to a provider-neutral `AbuseGuard` abstraction backed by reCAPTCHA Enterprise score-based assessments (action-scoped tokens minted at submit time, server-side verification with action-mismatch defense, default 0.5 score threshold, fail-closed on every error path). No PHI/PII is sent to Google — neutral action names only (`security_report_submit`, `verify_demo_submit`, `newsletter_subscribe`, `career_application_submit`, `checkbox_consent_start`), no IP, no reporter input. CSP swaps `https://challenges.cloudflare.com` for `https://www.google.com` + `https://www.gstatic.com`. Turnstile env vars (`NEXT_PUBLIC_TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`) removed from the runtime contract; replaced by `NEXT_PUBLIC_RECAPTCHA_SITE_KEY`, `RECAPTCHA_API_KEY`, `RECAPTCHA_PROJECT_ID`. The `turnstile_failed` error code in checkbox-flow callback URLs is intentionally retained as a stable wire-protocol string for partner integrations. (SEC-008)

verification state-machine atomicity + async-readiness + extraction queue + concurrency gate + face-index queue + internal-soak surface (VRF-014 Phases 0+1+2+3+4+5) — Sprint 3 of the P0 pre-raise hardening Rev 3 plan, the LOCKED async verification pipeline initiative at `docs/specs/verification/VRF-014-async-verification-pipeline.md`. **Phase 0** ships idempotency primitives BEFORE any queue/worker (async without idempotency turns latent races into guaranteed bugs because SQS at-least-once delivery makes duplicate redelivery normal): new append-only `verification_jobs` table with `idempotency_key UNIQUE` + CHECK constraints + janitor/DLQ indexes (migration `drizzle/0066_…`); typed verification state-machine module at `src/lib/verification/state-machine.ts` with `transitionVerifyState()` atomic CAS + `additionalWhere` + `incrementFaceMatchAttempts()` / `incrementLivenessAttempts()` / `mergeGatesPassed()` SQL helpers; ESLint `no-restricted-syntax` rule banning direct `currentStep` writes; 11 retrofits across `/api/verify/`. **Phase 1** ships async-readiness scaffolding with no behavior change: nullable `id_verification_sessions.partner_id uuid` + partial index (migration `drizzle/0067_…`); `isAsyncVerifyExtractionEnabled(partnerId?)` / `isAsyncVerifyFaceIndexEnabled(partnerId?)` accessors default OFF env-driven via `ASYNC_VERIFY_EXTRACTION` / `ASYNC_VERIFY_FACE_INDEX`. **Phase 2** ships the first reachable async path, **gated behind `ASYNC_VERIFY_EXTRACTION` (default OFF)** so production behavior remains synchronous: (a) new `src/lib/aws/sqs.ts` SQS client wrapper with `maxAttempts: 1` per VRF-013 lesson + `sendVerifyExtractionMessage()` / `receiveVerifyExtractionMessages()` / `deleteVerifyExtractionMessage()` helpers all wrapped in `retryWithBackoff` with the AWS error classifier; (b) new `src/lib/verification/jobs.ts` lifecycle library — `enqueueExtractionJob()`, `claimJob()` with atomic CAS (WHERE status IN ('pending', 'in*progress') + attempts increment), `markJobSucceeded()`, `markJobFailed({ terminal })` (terminal=true sets status='failed'+completedAt; terminal=false stamps failure_reason but leaves status='in_progress' so visibility-timeout redelivers), `markJobDeadLettered()`; (c) new `src/lib/verification/extraction-pipeline.ts` shared library that both the synchronous `/api/verify/process-pipeline` route AND the new worker invoke (single source of truth for steps 1-8: S3 fetch + Lambda image correction with passthrough fallback + S3 upload + Textract AnalyzeID + parse + barcode cross-check + atomic state-machine transition processing→confirm); throws typed `ExtractionPipelineError` with category + retryable flag (sync route maps to HTTP status; worker maps retryable=true to "leave job in_progress, let SQS redeliver", retryable=false to terminal failed); (d) new `/api/cron/verify-extraction-worker` Vercel Cron route running every minute (`* \* \* \* _`in`vercel.json`), `Authorization: Bearer ${CRON_SECRET}`auth shape matching other cron routes, long-polls SQS for up to 10 messages with 20-second wait, processes batch with full idempotency contract (already-succeeded → no-op + delete; concurrent claim → log+skip; retryable failure → leave for redelivery; non-retryable → mark failed + delete; transition conflict → idempotent succeeded since extraction data already in row); (e)`/api/verify/process-pipeline`route refactored into thin orchestrator — atomic CAS to 'processing' (Phase 0 guarantee preserved unchanged) → if`isAsyncVerifyExtractionEnabled(session.partnerId)`is ON, attempt enqueue + return 202; if enqueue fails (SQS down, DB hiccup), log + fall through to sync path so the user still gets a working response; sync path delegates to`runExtractionPipeline()`lib (same code the worker calls); (f) HVF→verify partner attribution bridge (Option a per founder direction, "small bridge" guardrail respected) —`/api/verify/session`POST schema accepts optional`hvfSessionId: uuid`, route calls `getHvfSession()`from KV, validates HVF session's userId matches authed user (or is null pre-auth) with 403 refusal on cross-user mismatch (defensive guard), missing/expired session degrades gracefully (log + partnerId stays null, session still creates), KV throw degrades gracefully too. Cron entry added to`vercel.json`; SQS SDK (`@aws-sdk/client-sqs`) added to package.json at the AWS-sibling version line. Required AWS infrastructure (provisioned out-of-band, documented in runbook): `verify-extraction`SQS queue (60s visibility timeout, redrive to DLQ at maxReceiveCount=3) +`verify-extraction-dlq`(14-day retention). 23 new unit tests at`tests/unit/vrf-014/phase-2-{jobs,sqs,hvf-bridge}.test.ts`covering: P2J-001..008 jobs lifecycle (enqueue with fresh/supplied UUID, claim no-op paths × 4, CAS with attempts increment, CAS conflict, terminal vs non-terminal failure semantics, dead-letter); P2S-001..007 SQS wrapper (JSON serialization, terminal-failure → SqsError, valid-message parsing, malformed-body skip, maxMessages cap at 10, delete swallows errors for SQS-redelivery dedup, client`maxAttempts: 1`constructor lock); P2H-001..004 HVF schema contract (optional / valid UUID / non-UUID rejection / nullish accept). Total VRF-014 unit tests: **42** (Phase 0's 13 + Phase 1's 6 + Phase 2's 23). Acceptance bar (founder-approved Phase 2, all met): default production behavior remains synchronous (flag OFF default), async path reachable only behind flag, queue messages are idempotent (UNIQUE idempotency_key + CAS lock), duplicate delivery does not corrupt session state (claim returns null on already-succeeded; transition conflict treated idempotently), failed jobs surface a safe recoverable state (status='failed' + failure_reason for terminal; in_progress + failure_reason for retryable to let SQS redeliver). **Phase 3** wires the VRF-013`acquirePartnerConcurrencySlot()` primitive into the extraction worker — same flag (`ASYNC_VERIFY_EXTRACTION`, default OFF) gates whether the worker runs at all, so production behavior is unchanged. Founder design constraint applied verbatim: **gate refusal = backpressure, not failed verification**. New `src/lib/verification/worker-runner.ts`extracts per-message logic into`processVerifyExtractionMessage()`(cleaner test surface than route-level mocking per Phase 0/2 lessons); cron route refactored to a thin loop accumulating per-outcome counts in the JSON summary. Per-message flow: read session → if`session.partnerId`is non-null, acquire partner slot with`maxConcurrency: 5`(founder-approved default) → on refusal (cap reached OR KV outage) return outcome`"deferred"` with NO claim, NO mark-failed, NO SQS delete (visibility-timeout-driven SQS redelivery is the natural retry mechanism); on slot acquired, claim job → run extraction → release slot in finally (success, retryable failure, terminal failure, transition conflict all release the slot). Consumer-direct sessions (`session.partnerId IS NULL`) bypass the gate entirely — same call path the worker has had since Phase 2. Defensive guard: session-row-missing path marks job terminally failed and deletes the SQS message regardless of gate state (unchanged from Phase 2). Per-partner overrides intentionally NOT shipped — Phase 5 introduces `partners.extractionConcurrency`JSONB mirroring BIL-007's`partners.spendRateLimit`pattern. 9 new unit tests at`tests/unit/vrf-014/phase-3-worker-gate.test.ts`cover: P3W-001 acquire-before-extract via call-trace assertion; P3W-002 slot release on success; P3W-003a/b/c slot release on retryable/terminal/transition-conflict failure paths; P3W-004 refusal preserves SQS redelivery (no claim, no mark-failed, no delete); P3W-005 consumer-direct (null partnerId) bypasses gate; P3W-006 KV-outage refusal also = deferred (same backpressure semantics, proves the design constraint holds for both refusal modes); P3W-007 session-missing terminal failure path unaffected by gate. Total VRF-014 unit tests: **51** (Phase 0's 13 + Phase 1's 6 + Phase 2's 23 + Phase 3's 9). Acceptance bar (founder-approved Phase 3, all met): VRF-013 gate wired into worker;`session.partnerId`is the attribution source; default 5; existing primitive (no new gate logic); gates worker execution only (request enqueue path unchanged from Phase 2); refusal does not mark job failed; SQS redelivery / visibility-timeout behavior preserved. New runbook section documents Phase 3 backpressure semantics + KV-outage signal`[verify-extraction-worker] gate deferred partner=X reason=…`. **Phase 4** ships the second async path — flag-gated `verify-face-index`queue + worker for`/api/verify/face-collection/index` — same flag pattern (`ASYNC_VERIFY_FACE_INDEX`, default OFF) so production behavior unchanged. Six artifacts: (a) **slot-pool extension to the concurrency-gate primitive** — `acquirePartnerConcurrencySlot()`now accepts`slotPool`option for namespacing distinct partner-facing concurrency budgets; key format becomes`partner:concurrency:{slotPool}:{partnerId}`when set, legacy`partner:concurrency:{partnerId}`when unset (backward-compatible with VRF-013's API). Phase 3 retroactively sets`slotPool: "extraction"` for symmetry per LOCKED spec §7.4 (the flag is OFF in production so no in-flight slots affected by the rename). (b) **`src/lib/aws/sqs.ts`** extended with `getVerifyFaceIndexQueueUrl()`+`sendVerifyFaceIndexMessage()`+`receiveVerifyFaceIndexMessages()`+`deleteVerifyFaceIndexMessage()`— same singleton client (still`maxAttempts: 1`), same retry semantics, distinct `SQS_VERIFY_FACE_INDEX_URL` env var. (c) **`src/lib/verification/jobs.ts`** extended with `enqueueFaceIndexJob()`mirroring`enqueueExtractionJob()`but with`jobType: "face_index"`(the existing`verification_jobs.job_type` CHECK constraint already permits both values). (d) **`src/lib/verification/face-index-pipeline.ts`** new shared library that BOTH the synchronous `/api/verify/face-collection/index`route AND the new face-index worker invoke (single source of truth): skip on no consent → re-verification cleanup (delete old face vectors from Rekognition + face_vectors DB table) → index liveness reference image → optionally index additional liveness frame → insert face_vectors rows. Throws typed`FaceIndexPipelineError`with category + retryable flag (categories:`invalid_state`/`storage_failed`/`rekognition_failed`/`processing_failed`). (e) **`/api/cron/verify-face-index-worker`** new Vercel Cron route running every minute (`_ \* \* \* _`), `Authorization: Bearer ${CRON_SECRET}`auth shape matching extraction worker, long-polls SQS for up to 10 messages with 20s wait, delegates to`processVerifyFaceIndexMessage()` per-message with try/catch (Phase 3 lesson carried over) so a single thrown exception doesn't abort the batch. **`src/lib/verification/face-index-runner.ts`** mirrors Phase 3's worker-runner structure: read session → if `session.partnerId`non-null acquire slot with`slotPool: "face-index"`+`maxConcurrency: 3`(founder-approved Phase 4 default vs extraction's 5) → on refusal return`outcome: "deferred"`(NO claim, NO mark-failed, NO SQS delete; SQS visibility-timeout redelivery is the natural retry mechanism, identical backpressure semantics to Phase 3 for both`concurrency_limit`and`kv_unavailable`refusal modes) → on acquired claim job → run face-index pipeline → release slot in`finally`. (f) **`/api/verify/face-collection/index`route** refactored into thin orchestrator: when`isAsyncVerifyFaceIndexEnabled(session.partnerId)` is ON, attempts enqueue + returns 202 with shape-compatible null fields (`indexed: 0, faceIds: [], skipped: false, async: true`) following Phase 2's response-compat pattern from Codex review; on enqueue failure falls through to sync path; sync path delegates to `runFaceIndexPipeline()`lib (same code the worker calls). Cron entry added to`vercel.json`. **Design rule honored**: face-indexing is write-side follow-up; failure/delay does NOT block the user's verification result (the user's verification status is finalized in `/verify/complete`BEFORE this surface is called). 11 new unit tests at`tests/unit/vrf-014/phase-4-face-index.test.ts`: P4F-002 separate slot pool name; P4F-003 default concurrency = 3; P4F-004 success path acquires/releases slot + marks succeeded + deletes msg + uses face-index pool with maxConcurrency 3; P4F-005 gate refusal = deferred (no claim/mark-failed/delete); P4F-005b KV-outage refusal also = deferred; P4F-006 duplicate delivery (claim returns null) → no-op + delete + release; P4F-007 consumer-direct (null partnerId) bypasses gate; P4F-008 skipped pipeline (no consent) marks succeeded; P4F-009/009b retryable vs terminal failure paths; P4F-010 slot-pool key namespace differs from extraction (regression guard against accidentally sharing a pool with Phase 3). Total VRF-014 unit tests: **63** (Phase 0's 13 + Phase 1's 6 + Phase 2's 23 + Phase 3's 10 + Phase 4's 11). Acceptance bar (founder-approved Phase 4, all met): default sync path unchanged when flag OFF; queue path reachable only behind flag; uses `session.partnerId`for attribution; separate slot pool from extraction; default 3; gate refusal = deferred not failed; worker follows Phase 3 pattern (read → optional gate → claim → run → release-in-finally). Required AWS infrastructure provisioned out-of-band per`docs/runbooks/verification-resilience.md`Phase 4 section:`verify-face-index`queue (60s visibility timeout, redrive at maxReceiveCount=3) +`verify-face-index-dlq`(14-day retention) + env vars`SQS_VERIFY_FACE_INDEX_URL`, `SQS_VERIFY_FACE_INDEX_DLQ_URL`. Out of scope per Phase 4 brief (deferred to Phases 5-6): /face-match async, /detect-face async, liveness async, ICC async, default flag ON globally, partner override column, QR-before-liveness redesign, verification freshness reuse. **Phase 5** ships the controlled-internal-soak \_enabling surface_ — partner allowlist on the global flags + per-partner concurrency overrides + soak observability lib + readiness verification script + soak runbook + soak evidence template. **Founder framing correction baked in: Phase 5 is NOT promoted to Verified on Staging by code merge.** It is promoted only after empirical soak evidence (≥1 extraction async + ≥1 face-index async end-to-end + zero/explained DLQ + rollback drill + ≥1 week observation) is captured in `docs/reporting/vrf-014-phase-5-soak-evidence.md`. Six artifacts: (a) **`src/config/feature-flags.ts`** — `isAsyncVerifyExtractionEnabled(partnerId?)` and `isAsyncVerifyFaceIndexEnabled(partnerId?)` extended with allowlist resolution: when global flag OFF AND partnerId in `ASYNC_VERIFY_EXTRACTION_PARTNER_ALLOWLIST` (or face-index variant) → returns true; consumer-direct flows (null partnerId) only see the global flag value; whitespace-tolerant comma-separated parsing. (b) **migration `drizzle/0068_vrf014_phase_5_concurrency_overrides.sql`** — adds `partners.extraction_concurrency` jsonb + `partners.face_index_concurrency` jsonb columns shaped `{ maxConcurrency?: number }` mirroring BIL-007's `partners.spend_rate_limit` JSONB pattern. (c) **`src/lib/verification/partner-concurrency-overrides.ts`** — `resolvePartnerExtractionConcurrency()` + `resolvePartnerFaceIndexConcurrency()` resolvers via `getPartnerDb` scoped wrapper; fail-soft on DB error (return null → caller falls back to default). Worker precedence: `opts.maxConcurrency ?? DB-override ?? in-code-default` (5 / 3). Workers (`worker-runner.ts`, `face-index-runner.ts`) updated to call resolver between session-load and slot-acquisition. Phase 3 + Phase 4 worker tests unchanged in behavior — gain a `vi.mock` of the resolver returning null so the existing default precedence is preserved. (d) **`src/lib/verification/soak-observability.ts`** — read-only lib: `getJobStatusDistribution({ jobType?, since? })` (counts by status with 24h default window), `getDeadLetteredJobs({ jobType?, since?, limit? })` (full DLQ row payloads for triage), `getProcessingTimePercentiles({ jobType?, since? })` (p50/p95/p99 via Postgres `percentile_cont` on `completed_at - started_at` for succeeded jobs), `getRetryDistribution({ jobType?, since? })` (firstTrySuccess vs retried vs exhausted). New helper `getQueueDepth(url)` in `src/lib/aws/sqs.ts` returns `{ waiting, inFlight, total }` via `GetQueueAttributesCommand`; new env-var helpers `getVerifyExtractionDlqUrl()` / `getVerifyFaceIndexDlqUrl()`. (e) **`scripts/verify-soak-readiness.ts`** — pre-flight check operator runs BEFORE flipping the allowlist env var: AWS creds set, queue URL env vars set, queue reachability via GetQueueAttributes, CRON_SECRET set, **soak-state self-consistency** check that refuses to give the green light if the global flag is ON (which would conflate Phase 5 soak with Phase 6 rollout — the operator must be deliberate). Each check produces an independent pass/fail line; exits 0 on green, 1 on red. (f) **runbook + evidence template** — `docs/runbooks/verification-resilience.md` Phase 5 section with 11-step soak procedure, soak-evidence minimum bar (≥1 extraction + ≥1 face-index + zero/explained DLQ + rollback drill + status-doc evidence link + ≥1 week), and rollback procedure (clear allowlist env var, in-flight messages drain naturally). New `docs/reporting/vrf-014-phase-5-soak-evidence.md` empty-skeleton template the operator fills in during soak. **LOCKED spec amendment** at `docs/specs/verification/VRF-014-async-verification-pipeline.md` §9: the original "Phase 5 — no new code; observation only" line is superseded by the founder-approved enabling-surface scope, with the empirical Phase-5-evidence bar recorded inline so future readers see why merge ≠ Verified for this phase. 14 new unit tests at `tests/unit/vrf-014/phase-5-soak.test.ts`: P5F-001..006 feature-flag allowlist (global OFF default, global ON dominates, allowlist activates partner when global OFF, null-partnerId never matches allowlist, whitespace tolerance, empty-string treated as no entries); P5O-001..004 partner override resolver (null partnerId → no DB hit, valid override returned, all invalid shapes → null fall-through, DB throw → null fail-soft); P5J-001..004 soak observability (distribution shape correctness, default 24h window applied, empty result → all-zero distribution, dead-lettered rows mapped + jobType filter applied). Total VRF-014 unit tests: **77** (Phase 0's 13 + Phase 1's 6 + Phase 2's 23 + Phase 3's 10 + Phase 4's 11 + Phase 5's 14). Acceptance bar — code-shipped portion (founder-approved Phase 5, all met): allowlist resolution layered onto existing accessors; per-partner concurrency JSONB columns + resolver; workers consult resolver before acquisition; read-only soak observability lib; readiness script with self-consistency check; runbook procedure + criteria + rollback; status doc framing distinguishes "code shipped" from "soak Verified". Acceptance bar — soak-evidence portion (NOT met by this merge; pending soak): the six bullets in the runbook minimum bar. **Phase 6 (broader / default rollout) does NOT open until Phase 5 is Verified on Staging via the soak evidence above.** (VRF-014) (VRF-014)

Fixed

Provision the CAIRL Monthly Mailgun mailing list at `monthly@cairl.app` (access_level: readonly, reply_preference: sender) and wire `NEWSLETTER_MAILING_LIST_ADDRESS` on staging, production, and preview. Pre-existing infra gap surfaced during SEC-008 staging UAT on 2026-05-05: the anonymous double-opt-in newsletter path 500'd at `getNewsletterMailgunConfig()` because the env var was unset everywhere — the variable has been required since the newsletter shipped but was never wired. The captcha layer was previously masking this with an earlier 4xx; SEC-008's reCAPTCHA Enterprise correctly let the request through to the next broken layer, exposing the gap. Updates `.env.example` to record the canonical list address and notes that `social@cairl.app` is reserved for the from/reply-to header, not the list address. (MLG-001)

partner-registration HVF-integrity gap (PPT-009) — `createBusinessContext` now writes the canonical `b-{partnerSlug}` form to `facets.slug` (matching what `src/lib/hvf/validate.ts:373` looks up) and creates the paired personal + business `contexts` rows so `oauth_redirect_uris.context_id` (FK to `contexts.id`) can be inserted by partners. Pre-fix, registering a partner via `/home/add` produced a row that could not complete the HVF entry handshake — `client_inactive` ("Partner context not found") was raised at `validate.ts:389` because the facet's slug was the bare `partnerSlug` and the lookup expected `b-${partner.slug}`. Surfaced by VRF-014 Phase 5 soak setup when the founder registered "Contract Verification" and could not drive a verification through the async path. Companion changes: removed the dead duplicate facet-INSERT block from `/api/contexts/route.ts` (was wrapped in an empty try/catch, would have created a stray facet post-fix), and updated the post-creation `next_href` redirect to point at the b-prefixed slug per `getFacetBySlug` exact-match lookup. New operator-run script `scripts/repair-business-context-integrity.ts` backfills any partner registered before this fix landed (single-partner, idempotent, dry-run-supported). 6 unit tests at `tests/unit/ppt-009/create-business-context.test.ts` pin the new shape: P9-001 b-prefix on facet slug; P9-002 business contexts row id matches facet id; P9-003 personal contexts row created when missing; P9-004 idempotent on existing personal context; P9-005 partners.slug stays bare while facet+context use the prefix; P9-006 slug-collision check uses the b-prefixed value. (PPT-009) (PPT-009)

Restore the public vulnerability disclosure intake (`/security/report`) on staging and production, and unblock all four shared `TurnstileWidget` consumers (the demo gate, the security-report form, the newsletter signup, and the careers application) which had been silently broken since DEM-001. Wire the missing SEC-005 hash secret, version, and recipient env vars on staging and production, surface a visible Turnstile error with a "Try again" reset path when the captcha fails, remove the invalid `size: "invisible"` parameter from `turnstile.render()` (Cloudflare returned client-side error 400020 "Invalid sitekey" for that unrecognized value), and make the `api.js` script loader idempotent across mounts to eliminate the "Turnstile already has been loaded" warning. (SEC-007)

Add `https://www.google.com` to the CSP `connect-src` directive so the Google reCAPTCHA Enterprise SDK's telemetry endpoint (`/recaptcha/enterprise/clr`) can complete its parent-page fetches. Without this, score-based reCAPTCHA still mints tokens (because `script-src` and `frame-src` are sufficient for the iframe's challenge cycle), but the SDK fills the browser console with CSP-violation errors on every form submission and degrades scoring quality over time. Surfaced during SEC-008 staging UAT on the careers application form. (SEC-008)

userinfo-snapshot test-fixture wallclock dependency (TST-004 sub-issue) — `tests/unit/oauth/userinfo-snapshot.test.ts` was failing 5/6 tests on `main` and on every PR branch as of 2026-05-03 because the fixture used absolute date strings (`ISSUED_AT_T0 = new Date("2026-05-03T10:00:00Z")` and `tokenAtT1.issuedAt = "2026-05-03T11:00:00Z"`) for the test token's `issuedAt`, with `expiresAt` computed as `+3600s` from those values; once real wallclock passed `2026-05-03T11:00:00Z`, the userinfo route's existing expiration check at [`src/app/api/oauth/userinfo/route.ts:77`](src/app/api/oauth/userinfo/route.ts#L77) (`if (new Date() > tokenRecord.expiresAt) return unauthorizedResponse("Token has expired")`) returned **401 before the snapshot logic the SNAP-\* tests are exercising ever ran**, so all five tests that call `await GET(...)` saw `expected 200 to be 401` while SNAP-004 (which only asserts `evaluateClaimsSpy` was never called and is vacuously true) stayed green; this surfaced as a CI Unit Tests failure on PR #685 (VRF-014 Phase 0) and was confirmed pre-existing by checking out main with VRF-014 reverted — same 5 failures, same paths. **Not a product behavior bug** (per the founder's TST-004 guardrail check): the route is correct — partners ARE supposed to get 401 on expired tokens; the fixture was authored under the assumption that `2026-05-03T11:00:00Z` was forever in the future, which it isn't anymore. **Fix:** replace `ISSUED_AT_T0` with `new Date(Date.now() - 60_000)` (1 minute ago — keeps tokenAtT0's `expiresAt = ISSUED_AT_T0 + 3600s` a comfortable 59 minutes from now, well past any single test run); replace `tokenAtT1.issuedAt` with `new Date(Date.now() - 30_000)` (30 seconds ago — preserves the T0 < T1 ordering the SNAP-003 test models while staying within TTL). SNAP-005's assertion `body.evaluated_at === ISSUED_AT_T0.toISOString()` continues to work because both sides reference the same constant. No production code changed; no shared-state pollution found in oauth tests; root cause was time-bomb fixture, not the broader Layer 1/Layer 2 worker pollution that TST-004's parent scope covers (this is recorded as a TST-004 sub-issue under that initiative). Verified: 6/6 in isolation, 108/108 in `tests/unit/oauth/` batch, 233/233 across `oauth/sec-006/vrf-013/bil-007/bil-008` siblings. Surfaced and fixed during VRF-014 Phase 0 rollout (PR #685) per founder direction "fix the test floor first, then merge Phase 0 cleanly." (TST-004) (TST-004)

VRF-014 Phase 2 Option (a) HVF→verify bridge — wire `hvfSessionId` through the client. The route side has accepted optional `hvfSessionId` since PR #690 (which resolves the partner via KV and stamps `partner_id` on the new id_verification_sessions row), but `src/app/(verify)/verify/VerifyFlowClient.tsx` never sent it when calling `POST /api/verify/session`. Net effect pre-fix: every HVF-driven verification got `partner_id: null`, which caused `isAsyncVerifyExtractionEnabled(null)` to return false and silently routed to the sync (consumer-direct) path regardless of allowlist configuration. Surfaced during VRF-014 Phase 5 soak setup when the founder drove a verification through Contract Verification on staging — got the auth code back but `verification_jobs` had zero rows because the async path never activated. Fix: thread `hvfSessionId` from `HvfVerifyStep` (which already has it as `sessionId`) through to `VerifyFlowClient` as a new optional prop, then include it in the `/api/verify/session` POST body via spread-syntax (`...(hvfSessionId ? { hvfSessionId } : {})`). Consumer-direct callers (e.g. `/verify/demo`, self-serve from `/home`) pass `null`/undefined and the body omits the field, preserving prior behavior. New unit test at `tests/unit/vrf-014/phase-2-hvf-client-bridge.test.ts` mirrors the schema-only philosophy of phase-2-hvf-bridge.test.ts: P2HC-001..004 pin the body-builder spread-syntax contract (truthy → include, null/undefined/empty → omit). Companion follow-up filed at `docs/tasks/_followups/FOLLOWUP-vrf-face-match-retry-breaks-auth-session.md` — surfaced in the same soak attempt; founder hit a face-match retry that broke the HVF auth session on browser refresh, classified P1 because retry budget is structurally important to real partner flows. (VRF-014) (VRF-014)

Security

per-partner spend rate limits on billable endpoints (BIL-007) — adds a two-window rate limiter (default 60 billable events/min burst, 1000/hour runaway) keyed on `partner_id` to every `deductFromWallet()` call site (`/api/oauth/token`, `/api/checkbox/[configId]/complete`, `/api/integrations/shopify/verify/exchange`) so a buggy or compromised partner integration cannot drain a wallet in seconds. Returns HTTP 429 with `Retry-After` (distinct from 402 insufficient balance) when a window trips; **fails closed** with HTTP 503 on KV outage on billable paths (the auth-layer rate limiter at `src/lib/oauth/rate-limit.ts` fails open by design — this one must not because the financial-bleed is unbounded). Per-partner overrides via the new `partners.spend_rate_limit` JSONB column. New audit event `partner.spend_rate_limited` recorded on every trip. Hotfix track of the P0 pre-raise hardening Rev 3 plan; closes risk bucket D-F3 ahead of the Sprint 1 (E1+A1) context-isolation foundation work. (BIL-007) (BIL-007)

scheduled Stripe reconciler (BIL-008) — closes F1 of the P0 pre-raise hardening Rev 3 plan, the silent ledger drift gap between Stripe charges and the partner-wallet ledger; new daily Vercel Cron endpoint at `/api/cron/stripe-reconciler` (auth via `Authorization: Bearer ${CRON_SECRET}`, same shape as existing cron routes) that diffs Stripe payment intents (filtered to `metadata.type = 'partner_wallet_reload'`, `status = 'succeeded'`) against `partner_wallet_transactions` rows of `type = 'reload'` over a 24-hour window ending **1 hour before** the run start (the in-flight tolerance prevents false-positive drift from charges whose wallet-credit webhook hasn't fired yet); each run is recorded in a new append-only `billing_reconciliation_runs` table with `status: 'ok' | 'drift_detected'`, `drift_cents`, the full list of disagreeing `stripe_payment_intent_id` values in `drifting_event_ids` (jsonb array — load-bearing forensic field so the on-call has the exact intents to investigate without re-running the reconciler), `stripe_charge_count`, `wallet_reload_count`, and run/window timestamps; on `drift_detected` an internal ops alert email fires to `BILLING_ALERT_EMAIL` (production runbook requires this env var) and the drift is also logged to `console.error` for log-aggregation pickup. **Critically read-only** — the reconciler never mutates wallet balances even when drift is detected; auto-correction would silently paper over the underlying bug (missed webhook, deduction-flow regression, Stripe-side correction), and the whole point of F1 is to surface drift so a human can investigate root cause. The `scripts/stripe-sync.ts` script is left intact (it's an unrelated product/price sync, not a reconciler — Rev 3's "promote stripe-sync.ts" wording was implementation guesswork; the controlling requirement is reconciliation, and it lives in new well-named files: `src/lib/billing/stripe-reconciler.ts` for pure analysis, `src/app/api/cron/stripe-reconciler/route.ts` for the cron endpoint, `src/db/schema/billing-reconciliation-runs.ts` for the table, `drizzle/0065_bil008_billing_reconciliation_runs.sql` for the migration hand-authored due to the known db:generate forked-parent collision). 13 unit tests at `tests/unit/bil-008/stripe-reconciler.test.ts` cover REC-001 no-drift run → ok, REC-002 forced $0.01 drift → drift_detected, REC-003 in-flight charge ignored (vs REC-003b 2h-old charge IS scanned), REC-004 missing/wrong/misconfigured CRON_SECRET → 401/500, REC-005 read-only invariant (db.update/insert/delete on wallet tables explicitly throws in the test mock), REC-006/007/008/009 drift shape coverage (exact event-ids, both directions of amount mismatch, ledger-row-without-stripe-match). Runbook at `docs/runbooks/stripe-reconciler.md` covers cron schedule, CRON_SECRET rotation, and drift-response procedure (manual investigation only — no auto-correction). Vercel Cron schedule: daily at 04:00 UTC. Filename uses `security` category since `financial-integrity` is not in the validator's enum (silent ledger drift IS a security gap; same convention as BIL-007/OAH-002/OAH-003). Out of scope per founder direction: pricing changes, refunds workflow, partner billing webhooks, invoice export, VRF. (BIL-008) (BIL-008)

RFC 7009 token revocation (OAH-002) — closes A1 of the P0 pre-raise hardening Rev 3 plan, the existential gap that previously prevented partners from revoking issued access tokens; new `POST /api/oauth/revoke` endpoint accepts `application/x-www-form-urlencoded` (or JSON), authenticates the client via `client_id` + `client_secret` using the same hashing path as `/api/oauth/token`, verifies the submitted JWT, and flips `oauth_tokens.is_revoked = true` via `getPartnerDb` from the now-locked SEC-006 wrapper API; cross-partner revocation attempts silently no-op (the partner predicate at the WHERE level provides defense in depth on top of the `tokenRecord.partnerId === partnerKey.partnerId` runtime check), already-revoked tokens are idempotent, and the response is always HTTP 200 with empty body per RFC 7009 §2.2 — preventing `jti` enumeration regardless of token state; rate-limited at the client level via the existing `src/lib/oauth/rate-limit.ts` (returns 429 with `Retry-After`); audit event `oauth.token.revoked` fires for every successful flip; partner-facing documentation added at `content/docs/integration/oauth-token-revocation.mdx` covering the full RFC 7009 contract including the no-enumeration property and 1-second post-revoke `userinfo` 401 latency expectation; the user-initiated consent-revocation path in `src/app/api/oauth/connected-apps/route.ts` already flips outstanding tokens through the SEC-006 scoped wrapper (Phase 1B retrofit) and emits its own `oauth.consent.revoked` audit event — no changes needed there. 17 unit tests at `tests/unit/oauth/revoke-route.test.ts` cover valid revoke, unknown jti / malformed JWT / cross-partner / already-revoked all returning identical-shape 200 responses, missing-param 400, bad-creds / inactive-key 401, wrong-Content-Type 400, and rate-limit 429. No A4 (claimsSnapshot / userinfo snapshot) work — that's Sprint 2. (OAH-002) (OAH-002)

userinfo claims-snapshot semantics (OAH-003) — closes A4 of the P0 pre-raise hardening Rev 3 plan, the silent post-grant disclosure leak; `GET /api/oauth/userinfo` now returns the claim values evaluated at token issuance time instead of re-evaluating live against current user data, so a backend mutation between T0 (issuance) and T1 (userinfo call) no longer surfaces state changes the user did not authorize at consent time. Implementation reuses the existing `oauth_access_tokens.claims jsonb NOT NULL` column (which has been populated with the full evaluated payload at every issuance path since the column was added — recon during OAH-003 confirmed this); no schema migration is required and no `claimsSnapshot` column was added since that would create dual-storage ambiguity for an identical payload. The userinfo handler removes its `evaluateClaims()` import and call entirely (the silent leak was: `// Re-evaluate claims fresh (user's status may have changed)`) and reads `tokenRecord.claims` only; `evaluated_at` is now `tokenRecord.issuedAt` rather than `now()` so partners see stable timestamps for a given token. There is intentionally NO fallback to live evaluation when the snapshot is empty — that would reopen the privacy leak narrowed to the empty-snapshot population. The `meta` shape is preserved (`claims_requested`, `claims_resolved`, `claims_null` reconstructed from the snapshot keys; `claims_ignored` is `[]` since the evaluation-time meta is not persisted at issuance and the locked spec forbids adding issuance-time persistence beyond the claims payload itself). The token issuance path in `src/app/api/oauth/token/route.ts` gets annotation comments only (no behavior change) clarifying that the existing `claims` field IS the canonical T0 snapshot read by userinfo and warning future devs against either skipping persistence or persisting partial subsets — both reopen the leak. Pre-deployment runbook check at `scripts/check-long-lived-tokens.ts` queries for unrevoked tokens that expire more than the cutoff window beyond the deploy time; partners holding such tokens see a behavior contract change (live values → snapshot values) and the runbook recommendation is to revoke them before deploy to force re-issuance. 6 unit tests at `tests/unit/oauth/userinfo-snapshot.test.ts` cover stored-claims-returned, T1-mutation-does-not-change-userinfo, new-token-after-mutation-reflects-fresh-claims, evaluateClaims-NEVER-called-from-userinfo (privacy-leak regression guard), response-shape-with-issuance-evaluated_at, and empty-snapshot-returns-empty-claims-NOT-fallback (also a regression guard). Partner docs at `content/docs/integration/claims.mdx` add a "Userinfo returns the issuance-time snapshot" section framing T0 semantics as correct by design (not stale) and pointing partners that need fresh claims to the consent-renewal-via-new-token path. Out of scope per founder direction: A4-related token issuance changes beyond annotation comments, revocation logic, billing, Stripe, VRF, refresh tokens, caching. Sequenced ahead of F1 (BIL-008): identity correctness > financial integrity. (OAH-003) (OAH-003)

data-layer context enforcement (SEC-006) — Phase 1A landed the trust-foundation architecture for tenant isolation: a typed scoped DB wrapper at `src/lib/db/scoped.ts` exposing three factories (`getPartnerDb` / `getUserDb` / `getFacetDb`) with constructor-time runtime null-checks (Layer 2.5), context extractors at `src/lib/db/context.ts`, a `no-restricted-imports` ESLint rule banning raw `db` imports in retrofitted files with a per-file `// sec-006-allow-raw-db: <reason>` bypass for legitimately-cross-tenant authorization lookups (Layer 2), and a static audit script wired into CI in warn-mode for codebase-wide visibility without blocking merges; Layer 3 Postgres RLS is deferred with full carry-forward design at `docs/tasks/_followups/FOLLOWUP-sec-006-rls-layer-3.md`. Phase 1B then completed the high-risk-slice retrofit — every raw `db` import in `src/lib/partner/**` (12 files), `src/app/api/oauth/**` (7 routes), and `src/app/api/partners/**` (22 routes) now goes through the scoped wrapper, with ESLint enforcement active across all three directories and bypass comments only on legitimately-cross-tenant identity-resolution code (cross-partner cleanup cron, partner-by-slug/email/code lookups, OAuth pre-scope JWT/API-key/URI lookups, partner-creation flow, partner email-verification flow); defense-in-depth `partnerId` predicates added at WHERE level wherever an id-keyed query could in principle have crossed tenants. The audit script's codebase-wide raw-`db` import counter fell from the 380 Phase 1A baseline to 341 (39-file burn); the remaining ~341 are tracked under Phase 2 burndown via the `Scoped DB Audit (SEC-006, warn-mode)` CI job. No wrapper API changes; OAH-002 (A1 token revocation) chains off the locked Phase 1B wrapper surface after merge + staging green per founder sequencing direction. Closes risk bucket C-E1 from the P0 pre-raise hardening Rev 3 plan. (SEC-006) (SEC-006)

verification pipeline resilience helpers (VRF-013) — Sprint 3 pre-step of the P0 pre-raise hardening Rev 3 plan, closing the smallest-stabilizing-patch tranche of the verification scaling/resilience bucket BEFORE the async restructure (SQS/DLQ etc., which lands in Sprint 3 proper); three controls: (1) `src/lib/retry/backoff.ts` exponential backoff + jitter helper (200 ms / 400 ms / 800 ms ± 25 % jitter, 4 s ceiling, 3 attempts default) plus `src/lib/retry/aws-errors.ts` AWS error classifier (Throttling/5xx/429/network names → retryable; validation/auth/data → terminal short-circuit) wired transparently into both `src/lib/aws/lambda.ts` (replacing the previous naïve immediate-retry path that hammered throttled services with no spacing) AND `src/lib/aws/textract.ts` (covering both single-page `analyzeIdentityDocument` and `analyzeIdentityDocumentMultiPage`; user-data errors `DocumentNotReadableError`/`UnsupportedDocumentError` are correctly classified non-retryable so a poor-quality image is not retried — same bytes, same answer); (2) `src/lib/partner/concurrency-gate.ts` per-partner counting semaphore via Upstash KV (INCR to acquire, DECR to release, TTL crash-safety floor at 120 s default) with `withPartnerConcurrencySlot()` helper, idempotent release closure, and **fail-closed on KV outage** — same posture and rationale as BIL-007 spend rate limiter (the threat model is a runaway partner starving the verification pipeline; KV unavailability is exactly when refusing is preferred); shipped as an unwired primitive — Sprint 3 proper makes the wiring decision (which routes adopt the gate, what `maxConcurrency`/`holdTtlSeconds` values, what HTTP shape on refusal) as part of the async-pipeline backpressure restructure, keeping the policy decision deliberate and testable in isolation; (3) new `kvDecr` primitive in `src/lib/kv.ts` (DECR without TTL renewal — release pairs with `kvIncr` acquire, which sets the TTL once); also trims the now-redundant outer single-retry block from `src/app/api/verify/process-pipeline/route.ts` (the local `invokeLambdaWithRetry` was retrying ALL `LambdaError` shapes including non-retryable validation errors; SDK-level retry is now the single source of retry semantics, and the route's existing `passthroughResult` fallback continues to work as the terminal-failure catch-all); 31 unit tests across 4 files at `tests/unit/vrf-013/` cover BO-001..008 backoff math + retry semantics, AE-001..009 AWS classifier coverage, CG-001..009 concurrency gate (acquire/release/best-effort-undo/idempotent/fail-closed/release-failure-swallowed/withPartnerConcurrencySlot), IR-001..005 SDK retry integration without going to real AWS; runbook at `docs/runbooks/verification-resilience.md` documents fail-safe behavior, tunable defaults, operational signals, and explicit out-of-scope list (SQS/DLQ/async Textract state machine/Rekognition async/freshness reuse/QR-before-liveness redesign — those are Sprint 3 proper). Filename uses `security` category to match the convention established by BIL-007/SEC-006/OAH-002/OAH-003/BIL-008 P0 pre-raise hardening fragments. (VRF-013) (VRF-013)

[0.6.1] - 2026-05-01

Changed

cairl-identity.com consumer identity surface hardening (MKT-314) — applies the locked Tone Matrix Absolute Bans across all 7 routes (replacing 8 absolute-claim violations with authorization-language rewrites), enforces IBM Plex Sans + IBM Plex Mono typography compliance, corrects the Credential Gold token mismatch, brings the surface into Tailwind-semantic-token consumption parity with cairl.app, and reduces the /pricing surface from 4-tier mirror to 2-tier preview with cross-domain CTA to cairl.app/pricing/identity for the canonical hub. Negative-surface posture preserved (no auth, no schema, no API, no analytics, no cookies, no forms, no waitlist). 14 predicate tests added at tests/unit/mkt-314/. (MKT-314) (MKT-314)

cairl.app wedge enforcement and pricing-surface singularity (MKT-315) — closes the three-domain wedge formation by purifying the cairl.app footer Products column (7 → 5 buyer-facing entries; ICR + MailGap removed; cross-apex CTA above column grid) and the /products index (Identity section removed; below-fold cross-apex CTA), collapsing cairl.app/pricing to a single-surface infrastructure-only page (Identity tab + tab UI removed; below-fold cross-apex CTA), expanding cairl-identity.com/pricing from MKT-314's 2-tier preview to the canonical full 4-tier surface with id_point above-the-fold (Plus/Pro/Prime tier data sourced from new src/lib/pricing/identity-tiers.ts constants module), 301-redirecting /pricing/identity to cairl-identity.com/pricing via env-aware destination per GR-8, and inverting the cairl-identity.com cross-apex CTA from "See full pricing" to "View infrastructure pricing". Supersedes the identity-pricing portion of MKT-304 (executable amendment row in MKT-304 §20 A-1) and §4.5 cross-domain CTA in MKT-314 (executable amendment row in MKT-314 §18 A-3); MKT-314 V-1 through V-8 V-rewrites preserved. Negative-surface posture preserved (no schema, API, auth, payment, cookies, analytics, or backend changes). 22 predicate tests added at tests/unit/mkt-315/ across 6 categories. (MKT-315) (MKT-315)

cairl.me apex-root explainer (PUB-003) — replaces the production stub at `cairl.me/` with a doctrine-compliant minimal explainer (locked H1, dark-mode-aware bracket-wordmark logo pair, two cross-apex CTAs via `identityUrl()`, slim footer) and patches one slug-page footer `Learn more` link target to point at `identityUrl("/why-private-identity")`. Anonymous-only, presentation-only, no DB read, no schema, no API, no audit events. (PUB-003) (PUB-003)

[0.6.0] - 2026-04-30

Added

Initiative-registry collision guard: CI workflow job (`Initiative Registry Collision Guard (GOV-011)`) that runs `scripts/check-registry-duplicates.ts` on every PR and detects duplicate IDs, malformed IDs, and counter drift in `docs/governance/initiative-registry.md`. Pure validator logic in `src/lib/governance/registry-validator.ts` with 7 unit tests. Includes a runbook (`docs/governance/registry-collision-prevention.md`) for the human-side branch-protection setup that closes the parallel-reservation gap by requiring PRs touching the registry to be up-to-date with main before merging. Also backfills DAT-002 — a real counter-drift case the guard caught on first run (Next Available declared MKT-309's counter advanced but no DAT-002 row existed in the Assignment Log). (GOV-011)

Three-domain wedge plumbing: Next.js 16 proxy wedge layer (`src/lib/routing/wedge.ts` composed as Layer 0 of `src/proxy.ts`) that maps `cairl.app/*` (pass-through), `cairl-identity.com/*` → `/id/*`, and `cairl.me/{username}` → `/profile/[slug]`; centralized host config at `src/config/domains.ts`; canonical URL helpers at `src/lib/canonical-url.ts`; placeholder homepage for `cairl-identity.com`; explainer page for `cairl.me` (no enumeration); single-source legal redirect (`cairl-identity.com/legal/*` → `cairl.app/legal/*`); direct-access blocking for internal `/infra`, `/id`, and cross-host `/profile` prefixes; cleanup of dead `/test-s3` and `/dashboard-v2` routes. Content forks per bucket are deferred to MKT-309+. (MKT-308)

Domain hardening (Sprint 1 post-wedge): per-host `sitemap.xml` and `robots.txt` route handlers (`src/app/sitemap.xml/route.ts`, `src/app/robots.txt/route.ts`) replacing the prior single-host metadata exports; pure per-domain builders in `src/lib/seo/sitemap.ts` and `src/lib/seo/robots.ts`. cairl-identity.com gets `Allow: /` + page-level `noindex` on the placeholder (flips to indexable in MKT-311 when consumer narrative ships); cairl.me gets layered privacy: empty sitemap + page-level `noindex,nofollow` on every profile/share route + `Allow: /` in robots.txt so crawlers can actually fetch and see the noindex directive (a `Disallow` would block fetch and paradoxically allow URL-only indexing of externally-linked profiles). Adds 27 unit tests in `tests/unit/mkt-309/` (sitemap correctness, robots correctness, canonical URL audit) and an env-gated E2E in `tests/e2e/launch/mkt-309-domain-hardening.spec.ts` covering the 8-URL verification matrix from the runbook. Architecture doc updated with the privacy-posture explainer (robots.txt is not a privacy guarantee — page metadata `noindex` is the load-bearing control). (MKT-309)

Consumer Identity Front Door at cairl-identity.com — 7 public routes (`/`, `/how-it-works`, `/why-private-identity`, `/products`, `/pricing`, `/trust`, `/start`) routing into the existing live cairl.app product. Locked hero promise stack, cross-domain CTAs via `appUrl()`, atomic MKT-309 stub-posture flip (sitemap → 7 entries, robots → advertises sitemap, layout → indexable). No new auth, no waitlist, no `/join`, no schema, no API, no analytics, no cookies, no forms. (MKT-311)

Published "The EU AI Act Deployer Trap" — Compliance & Regulatory blog post on EU AI Act deployer obligations, the Annex III biometric verification carve-out, and what platforms should do before August 2, 2026. (MKT-312)

Stash EU AI Act Deployer Trap social distribution assets (LinkedIn, X/Twitter, Quote Card PNGs + manifest + Canva download script) under `src/content/social/post-7-eu-ai-act-deployer-trap/` per the established `socialPath` pattern. Operational follow-up to MKT-312; interim file-based home until the /admin tool with S3 backend supersedes it. (MKT-313)

Public profile presentation and facet presentation controls (PUB-001) — first sprint in the new PUB initiative family. Adds a user-controlled presentation layer (display name, avatar, banner, accent color, role label, bio) above the existing SOC-001 social-verification card on `cairl.me/{slug}`, gated to Pro+ tiers. New `profile_presentation` table joined 1:1 to `facets` with explicit Save draft / Publish / Unpublish state machine (no autosave); server-route-only public reads with anon SELECT denied at the RLS layer; image pipeline (presigned PUT, EXIF strip, Rekognition Moderation, CompareFaces against CAIRL-owned protected set only) with Upstash Redis cache for short-TTL presigned URLs and active invalidation on six events; reserved-name + profanity guardrails; rate-limited public report endpoint with honeypot and 4-address recipient allowlist; admin moderation queue using `moderation_state` lifecycle distinct from `deleted_at`; redacted audit logging that hashes `display_name` / `bio` / `role_label` instead of storing raw values. Absorbs FCT-003 (facet instance customizations stub). LinkedIn-aligned restraint posture — no social-network primitives, no themes, no rich-text, no creator features, no analytics. Indistinguishability across the four non-public population states is enforced deterministically on PR CI; statistical timing distribution is verified on staging only. Foundation for the rest of the PUB family (PUB-002..PUB-005). (PUB-001)

Vulnerability Disclosure Program (SEC-005) — first formal external security disclosure intake. Public `/security` policy page (with conditional safe-harbor copy and "no paid bounty" framing), `/security/report` text-only structured form, `POST /api/security-reports` route with the locked 12-layer defensive pipeline (size gate, JSON parse, honeypot before schema, zod `.strict()`, agreement check, per-IP rate limit, per-email dedup, Turnstile, atomic DB insert + audit transaction, best-effort Mailgun notification), and RFC 9116 conformant `/.well-known/security.txt`. New `security_vulnerability_reports` System-Owned table with 21 DB-level CHECK constraints (length / enum / format / lifecycle integrity) and RLS-enforced service-only access. Reporter IP and User-Agent are NEVER stored raw — HMAC-SHA256 hashed with `SECURITY_REPORT_HASH_SECRET`; `reporter_hash_version` makes secret-rotation coupling explicit. The migration also seeds `ANONYMOUS_EXTERNAL_REPORTER_USER_ID` (`00000000-0000-0000-0000-000000005005`) — a non-authenticatable, non-billable, non-user-visible audit-principal users row that satisfies the `event_ledger.user_id` FK for anonymous public-intake events. Post-seed assertion block fails the migration closed if any locked field deviates from the spec. The infrastructure-marketing "Security at CAIRL" page that previously lived at `/security` is relocated to `/security/infrastructure` (esp/ subdir preserved); marketing nav, footer, and sitemap rewired. Adds `T-07` to launch-readiness matrix Rev 18 (clearance is gated on counsel sign-off + end-to-end production evidence; HUMAN-GATED in the release-verification manifest). New SOC 2 control file `docs/soc2/vulnerability-disclosure-controls.md` (CC2.3 / CC3.4 / CC7.3-external) with cross-vendor mapping; `aws-controls.md` and `github-controls.md` extended with CC7.3-external rows. Foundation for the SEC-NNN follow-ups (admin triage UI, private bounty pilot, multi-domain `security.txt`, ISO 29147 SLA, and the PUB-001 anonymous-audit forward-fix). (SEC-005)

Changed

canonicalUrl emission becomes environment-aware via NEXT_PUBLIC_APP_URL / \_IDENTITY_URL / \_PROFILE_URL overrides; production defaults preserved. (INF-115)

cairl.app funding-readiness sweep (Sprint 2 of post-wedge work). Audit-driven polish against the 13 funding-readiness routes: rename `/customers` → `/case-studies` (308 redirect, terminology lock per MKT-308 amendment, also blocked as a reserved facet slug); add `/trust` and `/case-studies` to the cairl.app sitemap; realign primary CTA on `/products/age-verification` and `/products/oauth` from consumer signup (`/register`) to builder funnel (`/start-integration`) so the buyer-targeted pages route to the buyer funnel; add hero CTA + ~150-word strategic-positioning copy to `/products` and `/solutions` index pages with locked CTA pair (`Start integration` + `View trust model`); date the planned `/developers` tools cards (`Planned · Q3 2026`) so they read as roadmap discipline rather than vaporware; add "representative deployment shapes" framing on `/case-studies` (anonymized examples, no fabricated metrics, no implied customers, no logos or quotes unless real); switch the homepage hero CTA pair from consumer-flavored (`Secure your identity (free)` + `Start building`) to the locked infra pair (`Start integration` + `View trust model`) — consumer CTA energy moves to cairl-identity.com in MKT-311 per the specialized-not-equal wedge model. First reservation made under GOV-011's full collision protection. (MKT-310)

QR-to-mobile handoff in the /verify pipeline now mints a narrowly-scoped, server-revocable capture-only cookie (`__Host-cairl_capture_scope`, opaque 32-byte token, Redis-backed at `capture-scope:{sha256}`, 15-minute hard expiry, no refresh) instead of redirecting the phone to `/login`. The cookie authorizes only the capture-flow allow-list (`/verify`, `/m/*`, `/api/verify/*` excluding handoff routes) when its bound sessionId matches the request; vault, mail, settings, billing, OAuth grants, partners, identity, and admin remain full-session-only and have the cookie stripped from the forwarded request via `src/proxy.ts`. Server-side revocation runs on every terminal session transition (`complete`, `cancel`, `abandon`, freshness-session PATCH). Amends UIR-112 §19 Guardrails #2 and #15, adds new #17 (capture-scope deny-list) and #18 (terminal-state revocation). Closes the P1 gap surfaced in `FOLLOWUP-qr-to-mobile-linking` and brings the QR flow to Stripe / Persona / Onfido / Veriff parity. (VRF-011)

Fixed

Vulnerability Disclosure Program page collision (SEC-005 follow-up). The initial SEC-005 PR (#645) replaced the existing `/security` infrastructure-marketing page with a standalone VDP page and moved the prior content to `/security/infrastructure`, disrupting the established URL the rest of the site already pointed to. Restore the original `/security` page and integrate the locked VDP block (CTA → `/security/report`, How to report list, In scope, Out of scope, Safe harbor, Compensation) as the expanded "Vulnerability Disclosure Program" subsection at anchor `#vulnerability-disclosure` inside the existing Incident Response section. Marketing nav, footer, and sitemap revert to the single "Security" link. `/security/report` form, `/.well-known/security.txt`, `POST /api/security-reports`, the schema migration, and all SEC-005 lib modules are unchanged. (SEC-005)

[0.5.0] - 2026-04-27

Added

FCT-005 cumulative add for §5A, §5D, §5E. §5A adds a phase-independent `ModuleStatus` field to every module contract (`ideation` / `specification` / `construction` / `live` / `deferred` / `deprecated`) so the facet engine can surface only live modules in UI while keeping non-live modules declared in the matrix for roadmap integrity; the engine filter drops non-live modules at resolve time, and the build-time validator rejects contracts with missing or invalid status values (new Rule 6). Current launch scope locks 5 live modules (`vault`, `mail`, `courier`, `bank`, `careers`), 1 specification-status module (`kids`), and 9 deferred modules (`pay`, `phone`, `bio`, `health`, `meet`, `work`, `scholar`, `proof`, `billing`). §5D adds a new personal-facet configuration surface at `/home/config` that mirrors `/home/f/[slug]/config` but is scoped to the user's personal facet — so personal never routes through `/home/f/[slug]/*` just to reach its own config; the new route loads the user's personal facet server-side and delegates to the existing `FacetSettingsClient`, with a `home-config` command-palette entry and a Configuration nav module in the shell (id `"config"` to avoid collision with the existing `"settings"` module in `baseNavModules`, plus an explicit `/home/config` branch in `getRouteMetadata` so breadcrumbs and active-state highlight correctly). A discoverability banner at `/home/settings` points users to `/home/config` for per-facet controls, making the global-vs-facet settings split explicit. §5E adds a canonical type-level `displayName` to `facetTerminology` for all 6 facet types plus two helpers (`getFacetTypeDisplayName(type)` and `resolveFacetDisplayLabel(facet)` that honors the instance `facet.name` override first); four UI surfaces that previously rendered raw `{facet.type}` with CSS `capitalize` now read through the helper (`/home/facets/new`, `/home/f/[slug]/page.tsx` overview badge, `/home/f/[slug]/config` Info section, `/home/settings/deleted-facets`) so Product can retheme type labels (e.g. "Business" → "Infrastructure") in one place. Existing FCT-002 engine parity and moduleOrder tests were updated to reflect the new live-filtered behavior; new tests at `tests/unit/fct-005/` cover the enum contract, per-facet-type live subsets, validator Rule 6 rejections, route guards, and the display-label resolution precedence (154 unit tests total across FCT-002/FCT-005/ICC-002/ICC-006/ICC-007). (FCT-005)

CAIRL Monthly newsletter and post-5 publish bundle: blog post "Identity verification is becoming infrastructure" + social derivatives, newsletter format v1.2 spec (LOCKED), Issue 01 web archive at `/newsletter/[slug]` and index at `/newsletter`, Mailgun render/send modules with 80 KB safe-ceiling check, Mailgun event webhook receiver, and `npm run newsletter:{weigh,preview,test-send,send}` CLI for the human-gated send. (MKT-302)

CAIRL Monthly subscribe flow with double opt-in. New `newsletter_subscribers` table holds the consent audit trail; Mailgun mailing list is the send-time target. Smart `<NewsletterSubscribeForm>` shows an email field + Turnstile for anonymous visitors and a one-click button for authenticated users. Wired into the marketing nav (`Resources → Learn → Newsletter`), the footer (subscribe strip + Resources link), and the `/newsletter` index hero, replacing the prior "email social@cairl.app" path. (MKT-307)

UIR-112 Phase 1 (API foundation): Adds the three-path capture foundation for Journey 01 moat hardening. Introduces a new `capture_source` column on `id_verification_sessions` with a CHECK constraint over the four allowed values (`mobile_camera` / `desktop_handoff` / `mobile_handoff` / `file_upload`), a `src/lib/capture/` module (client-side `detect.ts` three-way path detection, `probe.ts` webcam-quality probe, `handoff-token.ts` Redis helper covering mint, validate, consume, and sibling invalidation on a 10-minute TTL), and four new API routes: `POST /api/verify/handoff/mint`, `GET /api/verify/handoff/validate` (response shape locked to `{ sessionId, requiresEmailChallenge }` per §19 Guardrail #16), `POST /api/verify/handoff/consume` (requires prior session or email-code auth; token alone never authenticates per §19 Guardrail #15), and `POST /api/verify/upload-file` (accepts JPEG / PNG / HEIC / HEIF up to 15 MB, server-converts HEIC via sharp, clear error surfacing on conversion failure per §19 Guardrail #12). Sibling-token invalidation is enforced via a session-scoped Redis index that is atomically torn down on successful consume. (UIR-112)

Changed

FCT-005 cumulative change for §5C + §5F. §5C rationalizes the facet creation route and separates it from the commerce/onboarding hub: a new unified entry point at `/home/facets/new` renders a type selector over all 5 non-personal facet types (family, community, education, career, business) and dispatches to the right sub-form — `CreateSimpleFacetForm` (posts to `/api/facets` with name + description) for family/community/education/career, `CreateBusinessFacetForm` (posts to `/api/contexts` with displayName/slug/website/useCase/businessMode) for business. Both form components now live under `src/components/facets/`; the former location `src/app/home/add/create-business-context-form.tsx` is deleted along with `src/app/home/add/facet/page.tsx`. The `/home/add` route is repurposed as a commerce-only hub (Facet → `/home/facets/new`, Plan, Wallet, Add-ons, Go live). Updates three nav call sites to point at `/home/facets/new`: `src/config/app-navigation.ts` (`home-facets` entry), `src/components/shared/facet-switcher.tsx` (two create-new-facet links), `src/components/app-shell/constants.ts` (shell "Add" module's "Create Facet" item). §5F promotes the `kids` module from `specification` to `live` status and ships the adult-circle launch surface: a new `KidsCircleClient` at `src/app/home/f/[slug]/kids/kids-circle-client.tsx` renders the family roster plus an invite-by-email form (owner/admin only; owners may additionally promote to admin) that posts to the existing `/api/facets/[id]/members` endpoint. The kids page at `src/app/home/f/[slug]/kids/page.tsx` no longer renders `FacetModuleStub` — it loads the facet, the member list, and the signed-in user's role, passes them to the client, and enforces family-only scope via the shared `resolveFacetModulePage` plus a defense-in-depth `facet.type !== "family"` check. Full FCT-001 §5–§6 minor + guardianship scope (minor invite, Tier 1/2 verification, VAE consent) is captured as a followup for a later sprint; for now, kids shares its status contract with the other 5 live modules. Family's engine-resolved live set grows from `[vault, bank]` to `[vault, kids, bank]`. Existing launch-scope tests and the "specification-status module" assertion were updated to reflect the flip; a new 5-test suite at `tests/unit/fct-005/kids-circle.test.ts` locks in the kids contract invariants (status, family-exclusive `supportedFacetTypes`, non-surfacing on other facets, config continuity). All 159 related unit tests pass. (FCT-005)

MKT-304: Refactors `/pricing` to a wedge-first infrastructure commercial surface (hero, two products, three agreements, consumer cross-link) and splits consumer Identity plans onto a new `/pricing/identity` hub. Adds four new infra detail pages (`/pricing/infra/{metered,contract,enterprise,claims}`) — `claims` sources its rate sheet from `src/lib/claims/registry.ts` at render time so CLM-002 updates propagate automatically. Adds four new identity plan detail pages (`/pricing/identity/{point,plus,pro,prime}`). Legacy deep links `/pricing?section=identity` and `/pricing?section=infrastructure` now 301-redirect to the correct hub. No schema, API, migration, plan, price, or claim taxonomy changes. (MKT-304)

Add public `/start-integration` destination surface and rewire the four MKT-304 "Start integration" CTAs (`/pricing` hero, `/pricing/infra/metered` top + bottom, `/pricing/infra/claims` bottom) off `/partners` onto the new wedge-aligned destination. `/partners`, footer "Partners" link, resources page, and `/home/facets/new` are unchanged. The new page exposes a three-CTA buyer-intent stack (Build now → `/home/facets/new` via NextAuth `callbackUrl`, Talk to sales → `/contact?inquiry=integration`, Read the docs → `/docs`) plus a two-product recap that links back to `/pricing/infra/metered#checkbox` and `#full-verification`. Sitemap extended with `/start-integration`. (MKT-305)

Homepage and Products nav wedge sharpening: locked H1 preserved, platform-wedge sub-head, ProductModules 5→3, nav dropdown 13→3+overflow. (MKT-306)

UIR-112 Journey 01 Moat Hardening integration phases. **Phase 5B (capture-flow integration):** wires `<CaptureChooser>`, `<QrHandoffCard>`, and `<FileUploadPanel>` into `/verify` via a new `capture_path` step inserted between document-type selection and camera capture; `VerifyFlowClient` now defers camera-start until the user picks `mobile_camera`, `desktop_handoff` mints a token and renders the QR card with live polling, and `file_upload` uploads each side through `/api/verify/upload-file` and converges back into the existing `processing` → `confirm` pipeline; `/api/verify/upload-capture` gains an optional `captureSource` formData field so the mobile-camera path records `capture_source = "mobile_camera"` on the first upload and emits `verify.capture_source.recorded` idempotently; `VerifyClientState` + `toClientState` surface `captureSource` so desktop handoff polling can observe the phone-consume transition (`captureSource = "mobile_handoff"`); E2E skeletons at `tests/e2e/uir-112/{mobile-profile,desktop-handoff,desktop-file-upload}.spec.ts` carry `// PENDING-SURFACE: UIR-112` markers so the TST-003 skipped-test gate passes, with full browser walks blocked on the seeded persona harness + auth rebuild. **Phase 5C (auth surface rebuild):** applies the UIR-112 design system and Brand Promise placement across the four auth surfaces per §0.5.2–§0.5.5 — `/login` and `/register` carry the Brand Promise hero ("Prove everything. Expose nothing.") as top-level page headers; `/register` picks the right `<TimeEstimate>` scenario via the probe-free `detectCaptureScenario()` (pointer/hover media query only, no camera prompt on signup) plus a `<DataUseExplainer>` drawer under the form listing the three items stored at signup (email, password hash, nothing else) with locked copy; `/verify-email` success path surfaces "Email verified successfully." with a "Continue to Sign In" CTA routing to `/login` — no auto-session, no redirect to `/home` (DOC-006 corrected flow); `/forgot-password` consumes design-system primitives consistently with no Brand Promise placement (not the trust-establishing surface); all four footers carry `<SecurityIndicator state="encrypted" />`. **Phase 5D (landing + result integration):** adds `<TrustBadge variants={["soc2", "hipaa", "gdpr"]} />` below the landing hero CTA row while preserving the Hook "Verified. Not exposed." watermark in `final-cta.tsx`; replaces the verification result overlay's `Code: {code} · Confidence: {n}%` one-liner with a `<VerificationCodeBadge>` rendering the progressive-disclosure drawer (What happened / What it means / What it does not mean) using Credential Gold as border/accent only per §19 Guardrail #7; threads a required `VerificationGateResult.documentId` end-to-end from `/api/verify/complete` so a new "View in vault" CTA routes to `/home/vault/{documentId}` on success; defensive fallback preserves prior text display when the returned code is outside the A-0 / A-I / A-II / S-II set. (UIR-112)

Removed

FCT-005 §5B removes unlaunched module UI surfaces. Deletes eight stub page directories under `src/app/home/f/[slug]/` (`phone`, `pay`, `meet`, `work`, `scholar`, `proof`, `bio`, `health`) that rendered `FacetModuleStub` with no real functionality; the corresponding module contracts stay declared (status `deferred`) so the matrix retains long-term product shape. Retains `billing/` code (real wallet + funnel-gated implementation) but gates the route through `resolveFacetModulePage`, which now enforces both matrix-availability and `status === "live"` — so direct URL access to `/home/f/{slug}/billing` returns 404 while billing remains deferred. Switches `src/app/home/f/[slug]/page.tsx`, `src/app/api/facets/[id]/modules/route.ts`, and `src/components/app-shell/constants.ts` (`getNavModulesForContext`) from `getAvailableModules` (matrix view) to `getEngineModulesForFacetType` (status-filtered view), so the facet overview, modules API endpoint, and shell nav uniformly show only live modules; the legacy per-type branch that only routed community through the engine is retired. Extends `requireModule` (API middleware) with a status gate so deferred-module endpoints return 403 `module_unavailable` alongside the existing matrix check. Adds 7 new route-guard tests at `tests/unit/fct-005/route-guards.test.ts` locking in 404/403 behavior for deferred/specification modules across `resolveFacetModulePage` and `requireModule`. All 146 affected unit tests pass; type-check clean. (FCT-005)

Security

UIR-112 Phase 5A (event-ledger wiring): closes the foundation-tranche integration blocker reclassified per §19 Guardrail #11. Registers 8 new operational event types (`verify.handoff.{mint,consume,invalidate,consume_failed,validate_failed}`, `verify.upload.{file,file_rejected}`, `verify.capture_source.recorded`) via the runtime bounded catalog and governance registry. Wires fire-and-forget `recordEvent()` emission into the mint, validate, consume, and upload-file routes — success paths, all failure reasons, and per-sibling invalidate rows. Hashed IP + coarsened UA carry through on token-probe audit rows. Two governance decisions recorded in the spec Working log: (1) `BOOTSTRAP_EVENT_TYPES` is used as a compatibility workaround for authenticated pre-facet events (follow-up at `docs/tasks/_followups/FOLLOWUP-user-scoped-operational-events.md`); (2) fully-anonymous failed probes degrade to `console.warn` because `event_ledger.user_id` is `NOT NULL` at the DB level while spec §17 allows nullable. Authenticated failures emit normally. Integration tests exercise QA-070 / QA-071 / QA-072 plus sibling-invalidate and upload rejection paths against local Postgres. (UIR-112)

[0.4.0] - 2026-04-18

Added

BIL-005: Replaced the flat v3.0 partner-pricing model with a provision-based v4.0 billing lifecycle. The wallet engine now supports three billable event types (`enrollment`, `provision`, `vae`), computes VAE cost from CLM-002 claim prices using `ceil(max(25, sum(millicents)) / 10)` (minimum 3 cents), and prices Class 4 provisions at runtime as `vendor_cost × margin_multiplier` with a hard per-type max cap and wallet pre-authorization before any external vendor call. The ledger schema adds `surface`, `provision_type`, `cost_cents`, `claim_prices_millicents`, `cost_formula`, `provision_validity_expires_at`, and `wallet_balance_before/after` columns (Phase A — legacy v3 columns still populated alongside). DB-level `REVOKE UPDATE, DELETE` enforces ledger immutability. Idempotency uses Redis `SET NX PX` backed by the existing `audit_reference` unique index. Raw `userId` is no longer written into any ledger row or wallet transaction description. Spend alerts extended to 50/80/100% thresholds, and a CAIRL-only velocity detector with dual trigger lands the circuit-breaker hook that CHK-001 will consume. New CI gate `npm run validate:billing-v3` rejects any resurrection of the retired `VAE_BASE_COST_CENTS`, `EXTRA_CLAIM_COST_CENTS`, or `INCLUDED_CLAIMS` constants. Board approval required before production promotion. (BIL-005)

BRD-002: Trust page now links to the Delve-hosted compliance portal at `trust.delve.co/cairl` via a new "Compliance Portal" section between Compliance Approach and Payments. The CAIRL Trust Center remains the primary public overview; the Delve portal hosts supporting compliance materials and document workflows. (BRD-002)

CHK-001: Introduced CAIRL/checkbox, the ephemeral no-account verification surface that evaluates liveness-derived claims (live-human detection and Option-A age-estimate thresholds from CLM-002) and returns a signed, anonymous JWT to the partner callback. Adds three new tables (`checkbox_configurations`, `checkbox_sessions`, `checkbox_audit_log`) with DB-level `REVOKE UPDATE, DELETE` on the audit log; a new `src/lib/checkbox/` module covering configuration validation, Option-A claim evaluation, JWT signing with `sub: null`, session token issuance, redirect construction, and a circuit-breaker sink registered on BIL-005's velocity detector; API routes for partner config CRUD, session start (`/api/checkbox/{configId}/start`), completion (`/api/checkbox/{configId}/complete`), and partner-authenticated introspection (`/v1/checkbox/introspect`) with single-use consumption; and a consent-screen shell at `/checkbox/{configId}` with all six §6.1 disclosures. Reuses the existing JWKS endpoint and OAuth signing keys. Billing flows through BIL-005 with `surface: "checkbox"` — one VAE per session, ephemeral provision rule (no double-charge). No raw biometrics, user identifiers, or selfie bytes are ever persisted; the flow is anonymous by construction and validated by the §10 security-critical tests in `tests/unit/chk-001/`. Turnstile moved from `src/lib/demo/` to `src/lib/` for shared use. New CI gate `npm run validate:checkbox` enforces that every checkbox configuration references claims with `provisionSource = liveness_session` from the CLM-002 registry. BIPA/state biometric counsel review required before production launch; the biometric privacy policy page is flagged as a launch-blocking follow-up. (CHK-001)

CLM-002: Introduced the canonical claim registry at `src/lib/claims/registry.ts` as the single source of truth for all 64 active + 8 deferred claims. Extended `ClaimDefinition` with CLM-002 schema fields (canonical id, legacyId, domain, evaluationType, persistence, provisionSource, pricingClass, priceMillicents, legalRiskTier, confidenceThreshold, thresholdRule, retryRateEstimate, version, validitySurface, status). Legacy claim names (e.g., `age_18_plus`, `identity_verified`) now resolve to canonical IDs with deprecation warnings. Partner-facing `AVAILABLE_CLAIMS`, `CLAIM_DERIVERS`, and OAuth scope mappings are derived from the registry. CI gate `npm run validate:claims` enforces the CLM-002 §12.3 invariants. (CLM-002)

COM-001: unified interaction layer foundation — `interactions` + `interaction_deliveries` tables, publish/read service layer, courier and facet producer hooks (ADR-0012 atomic with domain events), five `/api/interactions/*` routes with per-user rate limiting, notification panel and bell-icon unread badge, retention-class-aware cleanup cron. (COM-001)

Add CAIRL/courier — identity-gated verified document delivery system with DB schema, API routes, ICC policy evaluation, audit logging, navigation integration, and full UI (inbox, sent, history, settings, create delivery wizard, delivery detail) (COU-001)

Add family facet schema (governance_links, minor_partner_authorizations), 14 audit event types, and FCT-001 test suite (FCT-001)

Add facet configuration engine (FCT-002): typed registry of all 6 facet types in `src/config/facet-types.ts`, 15 module contracts in `src/config/module-contracts.ts`, service identifier registry in `src/lib/services.ts`, pure-function `resolveFacetConfig()` resolver in `src/lib/facet/engine.ts`, build-time cross-validator in `src/lib/facet/validate-config.ts` with `npm run validate:facets` CLI and CI gate, and 36 unit tests including the QA-6.9 catalog parity invariant. Wires community as the first consumer of the engine in `getNavModulesForContext`. (FCT-002)

FCT-004: Facet module routing — stub pages for every module declared in the facet-type matrix (FCT-002). Previously only `/home/f/[slug]/vault`, `/bank`, `/billing`, and a handful of facet-admin routes existed; clicking any other module tile on the facet dashboard (mail, courier, phone, pay, meet, work, careers, proof, kids, health, bio, scholar) returned 404 for business, family, education, and career facets. Each route now renders a placeholder card describing the planned module and, where applicable, links to the personal equivalent. Routes that are not declared in a facet type's matrix still 404 via a new `resolveFacetModulePage` helper that enforces the availability guard. Closes the gap left when FCT-002 shipped the engine with only community wired. (FCT-004)

Add changelog fragment system, build identity display, and release preparation script (GOV-006)

ICC-012: Business profile settings panel is now wired into `/home/f/[slug]/config` for business facets. The business go-live funnel's "Set business mode" and "Set organization type" redirects land on a real form instead of a page with no matching field. Also swaps the funnel's legacy `/home/c/{contextKey}/settings?focus=...` href to `/home/f/{slug}/config?focus=...` to match post-ICC-008 routing. The organization type is now a dropdown sourced from the approved allowlist in `src/lib/context/business-profile.ts`, and business-mode options are shared between the creation wizard and the settings panel via `src/lib/context/business-mode-options.ts` so the two surfaces stay in sync. (ICC-012)

Publish blog post 4 "Why Our Claims Engine Can't Sell You a Cross-Platform Fraud Signal" — Building CAIRL pillar launch explaining the pairwise identifier choice and the cross-platform correlation revenue stream CAIRL permanently closed by design. (MKT-302)

Add SOC-001 social account ownership verification — `social_account_facts` table (migration 0054) with public_id (nanoid), partial unique indexes, CHECK (facet_id IS NULL); 38 featured + 3 supported platform configs; HMAC-SHA256 proof tokens (4-byte uint32 mod 31^6, CAIRL-safe alphabet, [c]XXX-XXX); handle normalizer for template/subdomain/user_provided URL types incl. Yelp-style query templates; SSRF-safe HTTP wrapper using undici Agent with pinned-IP DNS lookup, IP blocklist, streaming 5 MB body cap, HTTPS-only enforcement; 9 API routes (platforms, accounts CRUD, token Pro+ gated, verify with rate limiting, status, public verify); profile checker with two-period token grace; per-user (10/hr) and global (100/min) rate limiter; 8 audit event types in EVENT_TYPES (operational, bootstrap class) with fail-open recordEvent on all mutating routes; async evaluateSocialClaims() at step 7c in evaluateClaims() (BNK-001 pattern, NOT in CLAIM_DERIVERS), 17 claims registered in OAuth scope resolution; hourly degradation cron (verified→stale, verified→lapsed, stale→lapsed); public profile route at /profile/[slug] (timing-safe with 200ms floor, identical response for Pro/Free/nonexistent), per-verification detail page; full /home/social dashboard UI; 13 reserved slugs added to facet blocklist; 7 Playwright E2E specs and ~80 unit/integration tests across token, normalize, checker, safe-fetch, rate-limit, public-profile, social-claims, plan-gate, handle-change, authorization, audit, degradation, data-model suites (SOC-001)

Journey testing protocol and sprint completion standard — new `tests/e2e/launch/` harness with three reference journeys, CI enforcement of journey-impact acknowledgement and skip markers, release-verification manifest as production-promotion gate, sprint-completion standard in CLAUDE.md, and `/spec-to-ship` amendments requiring §0.5 UX Surfaces at spec time (TST-003, absorbs GOV-009). (TST-003)

Full verification-to-ICC extraction: all machine-extracted fields from government IDs (30+ fields from Textract and barcode) now promoted into identity_attributes as provenance-preserving evidence records with multi-evidence support, audit events, and barcode-over-textract source priority (VRF-005)

Changed

BIL-006: The Generate API Key modal now shows per-claim pricing inline next to every checkbox, matches BIL-005 terminology (no "Free" — "waived" for sandbox affordances, "included" reserved for in-plan features), and keeps the live price math always visible. Test keys display the full price ladder with a subtle `TEST` badge and a `waived` marker on the Enrollment and VAE totals, so partners see exactly what a live key would cost with only the test-only waivers called out. An explainer under the Mode radios makes the waiver contract explicit. The preview footer surfaces the running millicent sum and flags the VAE floor whenever it bites. Sandbox wallet provisioning — so that test usage debits a real (capped) balance instead of being waived by copy — is captured as a future BIL deferred item. (BIL-006)

Update inbound references to the archived UI Primitive Contract (now at `docs/_archive/`) so links resolve to UIR-101 or the archive copy (GOV-009)

Trim KV env vars to prevent whitespace in Redis URLs and add error boundaries for HVF staging diagnostics (HVF-011)

Wire branded OG/hero images (1200×630) into all 5 blog posts (MKT-303). Adds `image?: string` to `BlogPost` type, populates each post in `src/content/blog/posts.ts`, threads the image through `generateMetadata` for `openGraph.images`/`twitter.images`, renders an in-page hero in `BlogPostLayout`, and replaces the logo-mark placeholder in featured/grid/list cards on the blog index. Removes the dynamic `opengraph-image.tsx` route generator (file-based metadata took priority over `generateMetadata`, blocking the static images). Adds the canonical Canva template inventory at `docs/content/canva-template-inventory.md`. (MKT-303)

Replace platform Select with searchable Command/Popover combobox on social verification dashboard; add `verification_cadence` column to `social_account_facts` with cadence selector on dashboard (schema default `daily` for backward compat, API register route defaults to `monthly` so new user accounts get the low-friction monthly cadence); implement cadence-aware period math (daily/weekly/monthly/quarterly/annually with calendar-aligned boundaries) in social token service; add cadence-aware account status degradation via `social-degrade` cron with fixed 48h grace regardless of cadence; wire cadence into verify/status/token API routes. (SOC-001)

Replace three divergent profile dropdowns with unified FacetSwitcher component and rename facet-scoped settings to configuration (UIR-110)

Optimize marketing home page mobile Lighthouse scores: replace Framer Motion with CSS animations, lazy-load below-fold components, fix WCAG accessibility issues (contrast, headings, viewport zoom, ARIA) (UIR-111)

Fixed

DOC-006: fumadocs `/docs` polish — CAIRL logo in docs header, links back to marketing (`/`), dashboard (`/home`) and pricing, working light/dark theme toggle (re-enable RootProvider theming), working search via `/api/search` route using `createFromSource`, and marketing nav docs links open in a new tab. (DOC-006)

Fix profile display name and avatar not persisting after logout — session now reads from userProfiles DB table, sidebar receives DB-backed user data via layout, and avatar upload endpoint added (GEN-002)

Bridge HVF inline auth to NextAuth session so liveness and verification routes work during partner integration flows (HVF-011)

ICC-011: OAuth signups (Google / Apple / Azure AD) now receive a personal facet at account creation. Previously, the NextAuth adapter only created the user, profile, and email rows — the FacetSwitcher then rendered its "Create your first facet" empty state on first login. `ensurePersonalFacet` is now invoked in the adapter's `createUser` for both new-user and existing-email branches, and a backfill script (`scripts/backfill-oauth-personal-facets.ts`) has been added to self-heal accounts created before this fix. (ICC-011)

INF-113: `syncPartnerMembersToFacet` in the legacy partner-proxy route no longer attempts to soft-delete facet owners when the `partner_members` table is empty. This previously happened whenever `ensureLegacyPartnerForFacet` auto-provisioned a `partners` row for a business facet but never seeded its member list — the sync then treated the facet owner as "missing" and issued a soft-delete, which the `facet_members_protect_last_owner` DB trigger correctly rejected. The trigger rejection bubbled up as a 500 and knocked out `/api/contexts/{slug}/keys`, `/wallet`, and every other path that proxies through. Both soft-delete WHERE clauses now exclude rows where `role = 'owner'`; the DB trigger continues to enforce as defense-in-depth. Follow-ups for `ensureLegacyPartnerForFacet` hygiene and eventual retirement of the proxy shim are captured as deferred items. (INF-113)

Fix document capture not starting after liveness — camera race condition where startCapture fired before video stream had dimensions (VRF-002)

Fix verification gate rejection caused by snake_case/camelCase key mismatch in extraction data; normalize document type before ICC sync to prevent batch write rejection that silently dropped all identity attributes (VRF-004)

Fix silent identity-state loss where approved verifications produced empty ICC due to fire-and-forget write patterns in serverless runtime. (VRF-006)

Removed

Remove dead legacy PAYG usage system superseded by the wallet engine (PPT-008)

[0.3.0] - 2026-04-01

Added

Deployed expanded public legal and trust surfaces, including cookies, refund, DPA, acceptable use, biometric, data retention, compliance, updated terms, trust center, security overview, and an online/downloadable Enterprise Security Packet.

Added audit-ready Shopify integration infrastructure with signed install, lookup, and uninstall endpoints, HMAC validation, rate limiting, and audit logging.

Added event ledger and cohort infrastructure with canonical event types, redaction-aware timeline reads, and supporting tests.

Added `robots.txt`, XML sitemap, and additional marketing/supporting pages to improve search discovery and content coverage.

Changed

Refined app navigation so NavRail modules stay scoped to the active context and business contexts auto-activate more consistently.

Promoted and cross-aligned legal, footer, menu, privacy, and security content across the marketing site.

Fixed

Restored missing business contexts in the context switcher after membership reconciliation.

Invalid context slugs now fail fast instead of silently falling back to `b-business`.

Corrected E.164 regex constraints in phone-related migrations so database migrations apply cleanly.

Unified app-shell version badges with the shared platform version source.

Security

Hardened Shopify endpoints with HMAC authentication and integration audit trails.

Published updated security overview and Enterprise Security Packet surfaces for partner review.

[0.1.0] - 2026-03-25

Added

Initial governed release baseline under GOV-003

Semantic versioning, changelog, and release PR workflow

Version display in app shell footer and admin dashboard

Public changelog page at `/developers/changelog`

CI workflows for release PR validation and automatic git tagging

Version source helper (`src/lib/version.ts`) for consistent version access