Disciplines · Audits

Oshun V1 — Remaining-Work Implementation Checklist (2026-06-08)

Each work item uses a fixed template:

35sections152 minread

On this page

The authoritative, source-cited, granular task list for all genuinely-remaining work to take Oshun V1 from its current verified-zero-stub baseline to fully production-complete. Every item was re-verified against current source by six parallel read-only audits (file:line cited throughout). This supersedes the "remaining gaps" framing scattered across V1_DEPLOYMENT_REQUIREMENTS.md §8/§9 and WALKTHROUGH/results/journey-e2e-quality-audit-2026-06-05-wave6.md §H — those documents are reconciled inline (see Corrections below).

What "remaining" means here. The cleanly-completable in-repo correctness/ wiring gaps are already closed (studio surface real, stub-class eliminated, five+ generation journeys wired, native triggers firing, durability/autonomy shipped, the full e2e journey suite green). What remains is: (a) a small set of real code builds over existing-but-unwired engines, (b) a handful of deploy-time inputs (creds/infra/content), and (c) two forbidden stubs that must be replaced. Each is below with a granular task breakdown.


How to read this#

Each work item uses a fixed template:

  • Status today — what exists, with file:line.
  • The gap — what is missing to call it done.
  • External dependency — exact creds/infra/provider, or "none, pure code."
  • Granular tasks — ordered - [ ] checkboxes, file-level, covering the layers that apply: domain lib → BFF store → BFF route (preHandlers + scopes) → web data layer → UI island/page → unit tests → e2e → cross-link triangle → deploy/config.
  • Acceptance criteria — observable + testable definition of done.
  • Verification commands — exact invocations.
  • Effort / risk — S/M/L + key risks.

Effort scale: S ≈ ≤½ day · M ≈ 1–3 days · L ≈ 1–2 weeks (one engineer).

Cross-cutting rules (from CLAUDE.md — non-negotiable): zero stubs (no result-faking; fail loud or ask); wire real data end-to-end (no fixtures/ fallbacks as the product); commit + push to both branch and main; never touch libs/euterpe/** or apps/euterpe-studio-web/** (concurrent session); domain-correctness tests (assert computed values, not truthiness).


✅ Forbidden stubs found — FIXED (Quality Standards violations)#

The adversarial audit found two genuine result-faking stubs (they fabricated a result they did not compute — the exact bright line CLAUDE.md forbids). Both are now eliminated (Item 2.3(b)):

  1. apps/oshun/bff/src/telegram/webhook.ts:67-71transcribeTelegramVoice returns the literal string `voice transcript from ${fileId}` instead of transcribing. FIXED — now resolveSttVoiceProvider({ env: process.env }).
  2. apps/oshun/telegram-bot/src/index.ts:119-123 — same: returns `Telegram voice note ${fileId}`. FIXED — now resolveSttVoiceProvider({ telegramBotToken: env.botToken, env: process.env }).

The real STT provider (libs/oshun/messaging-channels/src/telegram/stt-provider.ts: Telegram getFile → download → OpenAI-compatible transcription, injectable fetch) is fail-closed — without a bot token + OSHUN_STT_API_KEY it throws stt_not_configured and the bot handler replies honestly ("could not transcribe"). It NEVER fabricates a transcript (proven by stt-provider.test.ts).

(Lower-severity, in a non-shipped advisor lib: @aphrodite/crypto-payments ExchangeRateService rate-fetch is mocked at libs/aphrodite/crypto-payments/ src/index.ts:220-235,260-269 and its EVM monitor is simulated at :855-863. This lib is not the path to wire — see Corrections + Item 4.3.)


Corrections to V1_DEPLOYMENT_REQUIREMENTS.md (verified against source)#

The deployment doc is mostly accurate but the audit found these material inaccuracies — fix the doc as part of the relevant items:

Doc claim Reality (file:line) Item
§9 "Sophia /answer needs an LLM" (unbuilt) The OpenAI-compatible synthesizer is fully built + wired: sophia/answer-synthesizer.ts:139, env resolver answer-synthesizer-env.ts:16, wired app.ts:527, used with fail-soft fallback domain-stubs.ts:335-360. Remaining = set 3 env vars + a doc fix + 1 test. 1.5
§8 implies "a credential alone activates the customer path" False — keystone gap. No wired provider emits a releaseMeasurement, so every credentialed media job lands blocked/governance_measurement_absent at generation/jobs-route.ts:237. The measurement bridge must be built first. 1.1
§7 "wire @aphrodite/crypto-payments" That lib has mocked rate-fetch + simulated EVM monitor. The real, multi-chain, already-integrated stack is @oshun/payments-bridge (real 3-source price aggregator, Ed25519 receipts, paywall specs) — and the web billing page already defines the wire contract (billing/crypto/invoice-loader.ts) whose BFF endpoint doesn't exist yet. 4.3
§3 routes comment / routes/sophia.ts:20 "/v1/sophia/answer fail-closes 503 in prod" Stale — the live route is in domain-stubs.ts and returns a real composed answer, never 503. 1.5
§9 "stub indicators clean" re STT Two fake-transcript stubs exist (above). 2.3(b)

Master sequencing & dependency graph#

Build in this order — several items unblock others:

text
KEYSTONE (build first):
  1.1 Release-gate measurement bridge ──┬─→ 1.2 provider measurement emission
                                        ├─→ 1.3 caption-dub/a11y (media path)
                                        └─→ 1.4 AI video

SHARED PRIMITIVE:
  @oshun/payments-bridge ReceiptSigner ─┬─→ 4.1 deletion attestation
  (Ed25519 signed receipts)             └─→ 4.3 payment receipts

INTEGRATION HUB:
  adminAuditEventsStore ────────────────┬─→ 5.1 incident write verbs (emit events)
                                        ├─→ 5.3 abuse reports (emit events)
                                        └─→ 5.2 audit explorer (reads events)

RECIPIENT RESOLUTION CHAIN:
  2.1 channel-binding store ────────────┬─→ 2.3 push recipient resolution
                                        ├─→ 2.4 reminder delivery targeting
                                        └─→ 2.2 email-verify (shares transport)

INDEPENDENT (any order):
  1.5 Sophia (config) · 3.1 persona · 3.2 SSO · 3.3 LMS · 4.2 library sync
  6.x PWA/SSR tests · 7.x deploy infra

Quick wins (S, high value, do early): 1.5 (Sophia config+test), 6.1 (captive-portal correctness bug), 6.4/6.5 (SSR unit tests), 2.5 (.env.example

  • health endpoint), 5.2 (port the existing admin audit explorer to web).

Genuinely blocked (document, don't fake) — UPDATED 2026-06-09 (several formerly-blocked items were unblocked by user product decisions + built): 1.6 (upload→model-governance) BUILT (user confirmed V1 scope → upload→ registry→admit() route shipped 8115643e9f); 1.4 video BUILT (curated video/video-from-image cards + fal LTX provider, d9e4d8ca9c); 4.3 in-repo composition BUILT (fail-closed payments composition, ec6897241d). What genuinely remains deploy/runtime-bound: 1.3 caption-dub dubbing/ASR ML service; the deploy halves of 1.4 (a real video safety-scanner/watermark/C2PA — the gate honestly blocks every in-repo clip until then) and 4.3 (the live InvoiceTargetProvisioner settlement provider — chain RPC/BTCPay/OpenNode, which owns rate+amount+address so the BFF never fabricates them); 3.2-login (a live IdP); plus all Playwright e2e + browser visual passes (RAM/infra) and Part-7 provisioning. For each, the in-repo seam ships fail-closed and the external capability binds at deploy.



Part 1 — AI Generation Providers & Release Pipeline#

1.1 Release-gate measurement bridge (KEYSTONE — build first)#

  • Status today: The Isis release gate is fully wired and enforced — evaluateGenerationRelease runs on every worker output (generation/jobs-route.ts:237), maps to complete/needs_review/blocked, fail-closes on a missing/invalid measurement (generation/release-gate.ts:140-145, tested release-gate.test.ts:72-92). The canonical contract is CanonicalReleaseGateMeasurement (libs/oshun/generation-control-isis/src/release-gate-model.ts:289). But no code anywhere constructs a measurement — grep for safetyScanScore: finds only the gate + a type, never a builder. Executors only forward a measurement if the adapter supplies one; none do.
  • The gap: A shared, tested helper that builds a valid measurement from real per-provider governance signals (safety scan, watermark coverage, C2PA-present, quality, output kind). Without it, items 1.2/1.3(media)/1.4 all dead-end at jobs-route.ts:237.
  • The exact contract a provider must emit (all fields required, runtime-checked): outputKind (image/video/audio/3d-model/…); safetyScanScore∈[0,1] floor 0.9 (release-gate-model.ts:208,339); provenanceManifestPresent (false→block, :354); watermarkCoverage∈[0,1] per-kind floor (image/texture/material=1, video/audio/ animation=0.99, else 0; :189,384); qualityAggregate∈[0,1] per-kind floor (image/audio/texture=0.75, video/anim/material=0.7, 3d=0.65…; :174,407review); policyComplianceClear/rightsAndLicenseClear/outputShapeValid (false→block, :421/432/447); humanReviewTriggers[] (:270, non-empty→review unless reviewerAssigned); reviewerAssigned, customerFacing. Optional releaseAdmissions[] (dispatch-guard.ts:29, any admitted:false→block) and releaseKind:'analysis' escape hatch for non-media outputs only (release-gate.ts:132).
  • External dependency: None for the helper (pure code). The signals it consumes are external (a real safety scan, a watermarker, a C2PA signer) — see 1.2.
  • Granular tasks:
    • Create apps/oshun/bff/src/generation/provider-measurement.ts exporting buildReleaseMeasurement(signals): CanonicalReleaseGateMeasurement — maps a provider's safety-scan result, watermark-coverage ratio, C2PA-present flag, quality score, and output kind into the canonical envelope. Must never default a signal to a passing constant — an absent signal yields an output that the gate (correctly) blocks.
    • Unit test provider-measurement.test.ts: feed the helper's output through evaluateGenerationRelease and assert complete for a clean image, and the correct blocked/needs_review for each below-floor field (one assertion per floor — would fail if a floor regressed or a field were dropped).
    • Doc: correct V1_DEPLOYMENT_REQUIREMENTS.md §8 to state that a credential activates the provider call but the measurement bridge is what releases the output to the customer.
  • Acceptance criteria: A single tested helper produces a valid measurement; feeding it through the live gate yields complete for a clean image and the exact blocked/needs_review decision for every below-floor field. No signal is fabricated.
  • Verification: cd apps/oshun/bff && npx vitest run src/generation/release-gate.test.ts src/generation/provider-measurement.test.ts
  • Effort / risk: S. Risk: the single highest stub-temptation in the repo — hardcoding safetyScanScore:0.95/watermarkCoverage:1 to "make it pass" is a forbidden stub. Each signal must trace to a real scan/watermark/signature.

1.2 Provider credential matrix + measurement emission#

  • Status today: All four resolvers exist, are fail-closed, and are registered in the worker (apps/oshun/bff/src/server.ts:416-435): illustration/explainer → resolveImageProviderGenerate() (generation/image-provider-env.ts:76, Stability SD3.5, OSHUN_STABILITY_API_KEY); music → resolveMusicProviderGenerate() (Suno, OSHUN_SUNO_API_KEY); sky-briefing → resolveSkyBriefingProviderGenerate() (Meshy, OSHUN_MESHY_API_KEY); narration → resolveNarrationProviderGenerate() (ElevenLabs, OSHUN_ELEVENLABS_API_KEY+_VOICE_ID). Front doors wired (curated cards → createCuratedGenerationExecutor; music /v1/isis/music/generate; sky-briefing /v1/isis/nyx-3d/generate).
  • The gap: None of the four adapters emit a releaseMeasurementadaptImageProviderGenerate returns only {id,url} and discards the provider response (which even carries metadata.c2pa.signed, types.ts:524). Consequence: even a perfectly-credentialed job lands blocked/governance_measurement_absent.
  • External dependency: Per-provider credentials (deploy-bound); a safety-scan signal (Stability has no top-level safety field — map its moderation verdict or run a classifier); a watermark step to legitimately claim coverage; a C2PA manifest (OSHUN_LIVING_SCENES_C2PA_*; signer exists at libs/yemaya/living-scenes-runtime/src/provenance/c2pa/ed25519-signer.ts:30).
  • Granular tasks:
    • Build 1.1 first. (Done in the keystone session — provider-measurement.ts.)
    • (image) Extend image-provider-env.ts adaptImageProviderGenerate to read the full ImageGenerationResponse (c2pa.signed, dimensions, moderation verdict), call buildReleaseMeasurement, return {id,url,releaseMeasurement}. (DONE — the adapter now ALWAYS emits a releaseMeasurement (closing the keystone gap where it discarded governance signals + every image dead-ended at governance_measurement_absent): provenanceManifestPresent from result.metadata?.c2pa?.signed, outputShapeValid from the url, policyComplianceClear from the moderation flag, rightsAndLicenseClear true (first-party generation), watermark/quality null (deploy-bound, below). The executor already forwards it (image-executor.ts:68). 10 image-provider-env tests fed through the REAL gate.)
    • (image) Implement a real safetyScanScore source (map Stability moderation/finishReason, or add an output safety-scan pass) — no constant. (DONE — generation/image-safety-classifier.ts: a fail-closed ImageSafetyClassifier + resolveImageSafetyClassifier(env) + createOpenAiCompatibleImageSafetyClassifier — POSTs the output image to an OpenAI-moderations-compatible vision endpoint (OSHUN_IMAGE_MODERATION_API_BASE/_API_KEY/_MODEL) and maps the per-category violation scores to safetyScanScore = 1 − max(violation). NO CONSTANT: unconfigured → null → gate blocks; a moderation HTTP error → throws → the adapter catches → null → blocks (never released unscanned, never a fabricated passing score — verified by 6 classifier tests + 5 gate-fed adapter tests incl. the throwing-classifier path).)
    • (image) Add/verify a watermark step before asserting coverage; wire C2PA signing via the ed25519 signer for provenanceManifestPresent. _(provenance is now READ from the provider response (metadata.c2pa.signed → Stability=false → honestly blocks on provenance). The remaining BFF-side watermark step + C2PA SIGNING are deploy-bound: both need the generated bytes fetched + processed (sharp watermark / ed25519 C2PA manifest) + RE-STORED in the asset store (OSHUN_ASSET_STORE_\*) so the signed/watermarked bytes are what's delivered — claiming coverage/provenance without persisting the processed output would be dishonest. So the measurement honestly blocks on watermark (null→image floor 1) + provenance until those deploy-bound steps land.)_ _2026-09-18: open for an agent. "Deploy-bound" meant an asset store; the dev stack has one (MinIO on :9000, docker/docker-compose.dev.yml). The residue is the step itself: fetch the generated bytes, watermark them, sign a C2PA manifest with the ed25519 signer, store the processed bytes and deliver those, with a spec against the local store._
    • (narration) Same in narration-provider-env.ts (outputKind:'audio'). _(DONE — and this is the genuinely-VALUABLE one of the three remaining adapters: for TTS the output content IS the spoken text (ElevenLabs faithfully speaks the input), so a real text-moderation scan of request.text is a faithful output-content safety scan, not a proxy. Built generation/text-safety-classifier.ts — a fail-closed resolveTextSafetyClassifier(env) + OpenAI-moderations-compatible adapter (OSHUN_TEXT_MODERATION_API_BASE/_API_KEY/_MODEL, falling back to the shared OSHUN_IMAGE_MODERATION_\*endpoint) →safetyScanScore = 1 − max(category violation); unconfigured → null → gate blocks; HTTP error → throws → adapter catches → null → blocks. NO CONSTANT. The adapter now ALWAYS emits a measurement: safety from the classifier, provenance false (ElevenLabs no C2PA), watermark/quality null (deploy-bound), policy from the moderation flag, rights true (first-party licence — domain truth). cloned-voice-used deliberately NOT set — narration uses a FIXED, deploy-configured, platform-licensed voice (OSHUN*ELEVENLABS_VOICE_ID), not a per-request clone of a real person; a future per-user voice-cloning surface would set it. 7 text-classifier + 10 narration tests fed through the REAL gate (no-classifier→block-on-safety (score 0, never a constant); clean→block-on-provenance+watermark NOT safety; flagged→policy+safety; cloned-voice trigger absent; moderation-outage→fail-closed; scans the actual spoken text). Audio full release still needs the deploy-bound audio watermark + C2PA + asset-store, like image.)*
    • (music) Same in music-provider-env.ts (outputKind:'audio'). (DONE — emits an honest measurement. Curated music is INSTRUMENTAL (no lyrics/vocals — translateMusicRequest hard-sets instrumental:true) and there is NO in-repo audio-content safety scanner, so there is no honest output-safety signal: safetyScanScore:null→0→the gate BLOCKS at the safety floor (never released ungoverned, never a fabricated score), exactly mirroring image's no-classifier fail-closed path (policyComplianceClear:false too). Provenance false (Suno no C2PA), watermark null (audio floor 0.99), quality null, rights true (first-party licence). Emitting the measurement replaces the generic governance_measurement_absent with the SPECIFIC missing-signal block reasons (better deploy diagnostics) + lands the scaffolding a deploy-bound audio scanner/watermark/C2PA plugs into. 7 tests fed through the REAL gate (blocks on safety+provenance+watermark; rights clear; nothing fabricated).)
    • (sky-briefing) Same in sky-briefing-provider-env.ts (outputKind:'3d-model', watermark floor 0 → N/A but the envelope is still required or it blocks). (DONE — honest measurement: no in-repo 3D-geometry safety scanner → safetyScanScore:null→blocks at the safety floor (never fabricated); provenance false (Meshy no C2PA); policyComplianceClear:false (no scan ran); watermark null but the gate treats the 3d-model watermark floor 0 as N/A (so it does NOT block on watermark — the test asserts not.toContain('watermark')); quality null; rights true. 7 tests fed through the REAL gate (blocks on safety+provenance, NOT watermark; rights clear; nothing fabricated).)
    • (executors) Confirm music/narration/sky-briefing executors forward releaseMeasurement (image already does, image-executor.ts:68); add the spread where missing. (DONE — extended NarrationProviderGenerate, MusicProviderGenerate, and SkyBriefingProviderGenerate return types with the optional releaseMeasurement, and added the conditional spread to all three executors (createNarrationGenerationExecutor/Music/SkyBriefing), mirroring image-executor.ts:68 verbatim. The worker's evaluateGenerationRelease strips the envelope and evaluates it. Full generation dir green (132 tests), BFF tsc 0, eslint 0.)
    • (unit) Per *-provider-env.test.ts: assert the adapter emits a measurement that clears (or correctly fails) the per-kind floors; assert fail-closed (no fabricated measurement) when the safety scan is unavailable. (DONE for ALL FOUR providers — each *-provider-env.test.ts runs the emitted measurement through the REAL evaluateGenerationRelease and asserts per-floor decisions, not truthiness. IMAGE (image-provider-env.test.ts, 10) + image-safety-classifier.test.ts (6): no-classifier→blocked-on-safety (score 0, never a constant); clean→blocked on deploy-bound provenance+watermark NOT safety; flagged→policy+safety; C2PA passthrough; outage→fail-closed. NARRATION (narration-provider-env.test.ts, 10) + text-safety-classifier.test.ts (7): same matrix over the text classifier + asserts the cloned-voice trigger is absent + the actual spoken text is scanned. MUSIC (music-provider-env.test.ts, 7): blocks on safety+provenance+watermark, rights clear, nothing fabricated. SKY-BRIEFING (sky-briefing-provider-env.test.ts, 7): blocks on safety+provenance, NOT watermark (3d floor 0 N/A), rights clear. Full generation dir green (132 tests).)
    • (e2e) Extend jobs-route.test.ts: enqueue→process→status with a measurement-emitting provider → complete; with a measurement-less one → blocked. (DONE — verified 2026-06-09: the described enqueue→process→ status coverage ALREADY EXISTS in jobs-route.test.ts (13 tests, all green) and was mislabeled a "RAM-bound e2e" — it is a vitest test, not a browser e2e. The two exact cases the box asks for: (1) it('enqueues (202) then completes a job whose output clears the Isis release gate') injects a provider executor returning passingImageMeasurement() → asserts status==='complete' (measurement-emitting → complete); (2) it('BLOCKS a produced output that carries no governance evidence (fail-closed)') injects an executor returning no releaseMeasurement → asserts status==='blocked' + error==='governance_measurement_absent' (measurement-less → blocked). Plus below-floor-safety→blocked, below-floor-quality→needs_review, malformed-measurement→blocked, provider-error→failed, retry→complete — every gate outcome fed through the REAL evaluateGenerationRelease, asserting specific statuses not truthiness. Re-ran npx vitest run src/generation/jobs-route.test.ts → 13/13 pass in 125ms.)
    • (deploy) Document per-provider activation set (key + safety + watermark 2026-09-18: open for an agent as documentation: the residue is the section 8 table of what each provider needs before its output may be released; the audio and 3D scanners it would name do not exist and are their own tasks.
      • C2PA) in §8. _(Image + narration moderation both documented in .env.example: OSHUN_IMAGE_MODERATION__(image vision scan) andOSHUN*TEXT_MODERATION*_ (narration spoken-text scan, falling back to the image endpoint) with the fail-closed + "releases only with moderation + watermark + C2PA" notes, and a note that music (instrumental) + sky-briefing (3D) have no in-repo output-content scanner so both honestly block until deploy-bound audio/3D scanners land. The remaining audio/3D safety+watermark services are deploy-bound (no standard endpoint to document yet).)_
  • Acceptance criteria: With a credentialed provider AND the bridge, a curated-card submit → process → GET /v1/generation/jobs/:id returns complete + asset + governance.permitted:true; without a real safety scan it stays blocked (never released ungoverned).
  • Verification: cd apps/oshun/bff && npx vitest run src/generation/
  • Effort / risk: M each. Highest stub-risk in the codebase (see 1.1).

1.3 caption-dub + accessibility-pass providers#

  • Status today: Executors wired fail-closed (server.ts:428,434). accessibility-pass is partly real already: resolveAccessibilityProvider (accessibility/deterministic-accessibility-provider.ts:74) runs contrast-analysis in-repo (decodes asset bytes → WCAG ratio, :30-44) when the asset store is configured; ML passes (alt-text/transcript) honestly return requires-external-provider (:54-62); analysis output releases via the releaseKind:'analysis' exemption. The asset-store seam exists + is fail-closed: resolveAssetStore() (assets/asset-store.ts:25, wraps @oshun/storage S3, gated on OSHUN_ASSET_STORE_*). caption-dub has no providercreateCaptionDubExecutor(null); visual-dubbing lib confirmed planners-only (no fetch/execute).
  • The gap: caption-dub needs a dubbing/ASR/TTS service + asset store + output store + a media releaseMeasurement. accessibility-pass ML passes need an alt-text/transcript service; the deterministic contrast path just needs the asset store configured.
  • External dependency: caption-dub → dubbing/ASR ML service + object store + measurement signals. a11y → object store (for the deterministic path) + optional alt-text/transcript ML service.
  • Granular tasks:
    • (a11y, lowest-hanging) Set OSHUN_ASSET_STORE_*; submit an accessibility-pass contrast-analysis card; confirm complete with a real WCAG ratio. Add a test injecting an AssetResolver returning known PNG bytes and assert expect(ratio).toBeCloseTo(4.5,1) (domain-correctness). _(Domain-correctness test DONE — accessibility/ contrast-analysis.test.ts (5 tests) drives the pure dominantPairContrast (RGBA, no PNG-decode needed — cleaner + tests the SAME WCAG math the AssetResolver path wraps). Asserts REAL computed WCAG values, not hardcoded: black on quantized-white (#f8f8f8, since 255 & 0xf8 = 248) → toBeCloseTo(19.77,1) + AA + AAA; the AA-boundary #707070-on-white → ≥4.5 but <7 (passes AA not AAA); two near grays (#808080/#888888) → <1.5, fails AA; dominant-pair selection (background=most-frequent); transparent pixels (alpha<128) skipped; single-colour → throws. The hand-derived 19.77 / 4.66 WCAG values matched the impl exactly. The remaining OSHUN_ASSET_STORE_\* activation + the end-to-end card→completeconfirm are deploy-bound, not code.)_ _2026-09-18: open for an agent. The residue is the end-to-end confirm against the dev stack's MinIO: setOSHUN*ASSET_STORE*\*through a throwaway env script, submit the card, and assertcomplete with a real ratio._
    • (caption-dub) Create generation/caption-dub-provider-env.ts (mirror narration-provider-env.ts): resolve a dubbing client from env (fail-closed null), inject the AssetResolver from resolveAssetStore, emit {id,url,releaseMeasurement} (outputKind:'video'|'audio'). _(DONE 2026-06-10 — caption-dub-provider-env.ts resolves the REAL ElevenLabs Dubbing API (POST /v1/dubbing multipart → poll GET /v1/dubbing/{id} → download /audio/{lang} + /transcript/{lang}, xi-api-key auth) from OSHUN_DUBBING_ELEVENLABS_API_KEY (falls back to the narration OSHUN_ELEVENLABS_API_KEY), gated fail-closed on BOTH the key AND OSHUN_ASSET_STORE_\*(asset-store.ts grew a read-write AssetObjectStore: storeBytes= S3 upload + signed download URL — dubbed outputs persist atcaption-dub/{dubbingId}/{lang}.{ext}). Multi-locale: BCP-47 tags collapse to primary-subtag dubbing languages (es-MX+es→ oneesdub), each gets its own dub/store/scan, and the executor seam forwards a per-localeoutputs[]. 25 new tests (caption-dub-provider-env.test.ts) assert the exact multipart fields, poll/timeout/failed paths, stored keys/bytes, min-score aggregation, and gate outcomes through the REAL evaluateGenerationRelease.)_
    • (caption-dub) In server.ts:428 replace createCaptionDubExecutor(null) with createCaptionDubExecutor(resolveCaptionDubProvider()). (DONE 2026-06-10 — server.ts now wires createCaptionDubExecutor(resolveCaptionDubProviderGenerate()); fail-closed provider_not_configured unless key + asset store are both present, verified by test "feeds a fail-closed executor when unconfigured".)
    • (a11y ML) Add an optional ML a11y provider behind its own env; compose so contrast stays in-repo and ML passes use the service when configured. (DONE 2026-06-10 — accessibility/ml-accessibility-service.ts: an OpenAI-compatible ML service behind OSHUN_A11Y_ML_API_BASE + OSHUN_A11Y_ML_API_KEY (model overrides OSHUN_A11Y_ML_VISION_MODEL / OSHUN_A11Y_ML_TRANSCRIPTION_MODEL): alt-text via vision /chat/completions (image as a magic-byte-sniffed data URL + a screen-reader-specific instruction), transcript via Whisper-compatible /audio/transcriptions multipart (filename from sniffed audio container). Unrecognised bytes / HTTP errors / empty model output all THROW — never a fabricated alt text or transcript. Composed in deterministic-accessibility-provider.ts: contrast stays in-repo, alt-text/transcript run the ML service when configured and report requires-external-provider (with the error detail) when absent or failing; caption-verification stays honestly external (needs the caption track). AccessibilityPassResult grew altText/transcript. 12 new ML-service tests + 6 new/extended provider-composition tests, BFF suite green + tsc clean.)
    • (measurement) Build the media releaseMeasurement via 1.1; set cloned-voice-used if voice cloning is used. (DONE 2026-06-10 — the adapter builds the media measurement via 1.1's buildReleaseMeasurement: outputKind 'video'|'audio' from the dubbed media's content type; safetyScanScore = a REAL text-moderation scan of the DUBBED TRANSCRIPT (SRT → spoken text; the same faithful output-content reasoning as narration), MINIMUM across locales, null→blocked on any scan/transcript outage or absent classifier; provenance false + watermark null + quality null (honestly absent, deploy-bound); and humanReviewTriggers:['cloned-voice-used'] on EVERY dub — ElevenLabs dubbing clones the source speakers' voices — so a dub releases only with an assigned reviewer. Fed through the real gate in tests: clean scan → blocked on provenance+watermark+human-review, NOT safety-scan.)
    • (unit) caption-dub: locale normalization (translateCaptionDubRequest)
      • measurement forwarding + fail-closed without provider. a11y: ML passes return requires-external-provider without the service. (Done — verified 2026-06-09, 9 tests green: caption-dub-executor.test.ts (4) covers translateCaptionDubRequest de-dup + lower-case of BCP-47 target locales + keeps the source asset, fail-closed provider_not_configured with no provider, runs through an injected provider, and forwards the release measurement; accessibility-pass-executor.test.ts (5) covers the same fail-closed + de-dup, the no-url releaseKind:analysis exemption, and MEDIA measurement forwarding. These exercise the in-repo translation + fail-closed seams that need no provider; the dubbing/ASR/a11y ML providers stay deploy-bound.)
    • (e2e) Curated-card submit → enqueue → process → status for both kinds with injected providers. (DONE 2026-06-10 — jobs-route.test.ts grew an "asset-transform cards through the pipeline" block (3 tests, 16/16 green): caption-dub runs the REAL adapter (dubbing/store/classifier doubles) through enqueue→process→status — the dub is produced + stored (per-locale outputs asserted) but fail-closes at the gate on provenance+watermark+human-review (clean transcript scan, so NOT safety-scan); a second case with fully-passing audio evidence releases complete; accessibility-pass runs the real deterministic provider + ML double over an encoded PNG → complete analysis report with the REAL WCAG ratio toBeCloseTo(19.77,1) + AA + the ML alt text, and the stripped releaseKind envelope asserted.)
    • (cross-link + deploy) Link the curated-card journey; document the dubbing/ASR/a11y env + OSHUN_ASSET_STORE_* in §8/§9. (DONE 2026-06-10 — .env.example documents the new OSHUN_DUBBING_ELEVENLABS_API_KEY/OSHUN_DUBBING_API_BASE (falls back to the narration key) + OSHUN_A11Y_ML_API_BASE/_API_KEY/_VISION_MODEL/ _TRANSCRIPTION_MODEL blocks with the full fail-closed/cloned-voice/ transcript-scan semantics, and the asset-store block now notes dubbed outputs persist there with signed URLs. V1_DEPLOYMENT_REQUIREMENTS.md §8 table rows for caption-dub + accessibility-pass now carry their real env vars + providers ("all eight curated-card kinds ship a concrete in-repo provider"), §9's provider row became an infrastructure row (store/keys/moderation/review-rota/provenance), and the Summary was re-counted. Cross-link: WALKTHROUGH/studio/generation/studio-generation-curated-cards.md's open-gap bullet updated from "providers deploy-bound" to the real provider wiring, leaving the surface-level asset-picker as the remaining UI gap.)
  • Acceptance criteria: With OSHUN_ASSET_STORE_*, a contrast-analysis card returns a real WCAG verdict; with a dubbing service + store, a caption-dub card returns dubbed media that clears the gate; without them, both honestly 503/blocked.
  • Verification: cd apps/oshun/bff && npx vitest run src/generation/caption-dub-executor.test.ts src/generation/accessibility-pass-executor.test.ts src/accessibility/
  • Effort / risk: S (activate deterministic a11y) / L (caption-dub). Risk: returning a fabricated dub/transcript instead of requires-external-provider.

1.4 AI video model for Living Scenes (ComfyUI/RunPod enhancement) (BUILT 2026-06-09 — via curated cards + fal LTX-Video; user: "build the content-drop producer too" → "proceed")#

2026-06-09 — AI-VIDEO BUILT (commit d9e4d8ca9c) via the CURATED-CARD front-door (the same admission→enqueue→worker→adapter→gate chain as the image keystone), NOT the living-scenes render-route / /v1/isis/generate variant the granular tasks below describe — so those alternative boxes stay [ ] (different mechanism). The user chose text-to-video + image-to-video with richer-control inputs via AskUserQuestion. Two new curated cards (video, video-from-image) in @isis/curated-cards (types + exhaustive-switch validators + catalog), and the BFF vertical: generation/video-executor.ts (seam + two translators + two fail-closed executors forwarding governance evidence) + generation/video-provider-env.ts (resolves the REAL fal.ai-hosted LTX-Video provider from @isis/ai-providersgenerateVideo t2v + imageToVideo i2v — fail-closed null without OSHUN_VIDEO_FAL_KEY) + two worker-registry kinds. HONEST GATE POSTURE: no in-repo video safety scanner / watermarker / C2PA signer, so the measurement carries safetyScanScore=null + provenance=false + watermark=null → every in-repo clip is faithfully BLOCKED (specific missing-signal reasons, never a fabricated score), released only once those deploy-bound steps land — mirrors the music/sky-briefing adapters. The card was the genuine product gap (a VideoCardInputs contract); the provider already existed. Used the real LTX provider, NOT the ComfyUI provider-manager (the cleaner same-as-image path). Added the @isis/ai-providers/providers/video-generation tsconfig path mapping (vitest's resolver needs it). 23 tests (6 lib + 7 executor + 8 provider-env-through-real-gate

  • 2 curated-exec) + catalog enumeration; generation suite 162 green; tsc/eslint/ stub-scan clean; .env.example documents the vars.
  • Status today: The deterministic V1 producer works with no model: POST /v1/living-scenes/renderrenderLivingSceneSegmentToPng, seizure-gated, watermarked PNG (living-scenes/render-route.ts:42,66). The ComfyUI/RunPod client is real + ready (libs/yemaya/comfyui-integration/src/provider-manager/provider-manager.ts:1 — multi-provider, failover, submit/poll). The gate front-door POST /v1/isis/generate exists but is wired with notConfiguredProviderExecutor() (server.ts:248) → 503.
  • The gap: No ComfyUI executor is injected (neither /v1/isis/generate nor a video worker kind). The render route has no provider seam and does not call the gate — so the doc's "flows through the gate" is not currently true via render. No releaseMeasurement from the video client.
  • External dependency: A running ComfyUI/RunPod endpoint + model weights + creds; measurement signals (1.1); OSHUN_LIVING_SCENES_C2PA_*.
  • Granular tasks:
    • Create generation/video-provider-env.ts (resolve the real fal LTX-Video provider from env; adapter over t2v/i2v → {id,url,releaseMeasurement}; fail-closed null). DONE d9e4d8ca9c (LTX-Video, not the provider-manager).
    • Create generation/video-executor.ts (mirror image-executor.ts; outputKind:'video'). DONE — two executors (t2v + i2v), fail-closed, forward governance evidence verbatim.
    • (preferred) Add video / video-from-image executors to the worker registry (server.ts) so they flow through the wired gate. DONE.
    • Not built — rejected alternative: (optional) Replace notConfiguredProviderExecutor() for /v1/isis/generate — NOT done (curated-card front-door used instead).
    • Not built — rejected alternative: (render-route, if chosen) living-scenes render-route variant — NOT chosen (curated-card path is the cleaner same-as-image front-door).
    • Build the video releaseMeasurement (1.1). DONE — honest fail-closed (safety/provenance/watermark all absent → blocks; never fabricated).
    • (unit) Inject a fake provider: unconfigured → fail-closed; configured → measurement fed through the REAL gate asserts blocked-on-safety/provenance/ watermark; t2v vs i2v routing; out-of-enum aspect/resolution defaults. DONE (8 provider-env + 7 executor tests). NB an in-repo "measurement-clear → complete" case is impossible without a safety scanner, so the tests assert the honest always-blocked path, not a fabricated pass.
    • (deploy) Document the endpoint + weights + creds in §2/§8 (the .env.example OSHUN_VIDEO_* block is done; the §2/§8 deploy doc is not). (DONE 2026-06-09 — corrected V1_DEPLOYMENT_REQUIREMENTS.md, which had gone factually STALE after 1.4 shipped: §2 now describes the built fal LTX-Video curated-card path (OSHUN_VIDEO_FAL_KEY, fail-closed, gate-blocked until a deploy-bound video safety-scan/watermark/C2PA lands) + keeps the ComfyUI path as the optional self-hosted alternative; §8 added the video/video-from-image provider-table row + corrected "six → eight V1 curated cards"; §9 corrected the now-FALSE "video is not a V1 curated card" claim (it became one in 1.4 — only general-3D remains out of scope). Also un-stale'd §8's "per-provider signal extraction is the remaining wiring" paragraph — all nine executors now forward measurements (sessions 6–7).)
  • Acceptance criteria: With a ComfyUI/RunPod endpoint, a video job returns generated, watermarked, seizure-safe video that clears the gate; without it, 503/blocked; the deterministic render path is unaffected.
  • Verification: cd apps/oshun/bff && npx vitest run src/living-scenes/ src/isis/generation-route.test.ts src/generation/ · cd libs/yemaya/comfyui-integration && npx vitest run
  • Effort / risk: L. Risk: never ship a generative path that skips the gate or the seizure check.

1.5 Sophia /answer prose synthesis (already built — config + test + doc only)#

  • Status today: Fully implemented and wired (corrects §9). Retrieval + extractive grounding real (domain-stubs.ts:306-374, sophia/answer-composer.ts:74, abstention :80-83). The abstractive seam: createOpenAiCompatibleSynthesizer (sophia/answer-synthesizer.ts:139 — temp 0, JSON mode, 6s timeout, honesty sanitization at :98 drops un-cited claims), resolved fail-closed by resolveSophiaAnswerSynthesizer (answer-synthesizer-env.ts:16, needs OSHUN_LLM_API_BASE+_KEY+_MODEL), wired app.ts:527, used with fail-soft fallback to extractive on any error (domain-stubs.ts:335-360). Web form already posts it (app/sophia/SophiaAskForm.tsx:27).
  • The gap: Effectively none in code: (1) set 3 env vars; (2) reconcile the stale routes/sophia.ts:20 503 comment; (3) optionally add an e2e proving the LLM path
    • fallback.
  • External dependency: An OpenAI-compatible LLM endpoint + key (any vendor/self-host).
  • Granular tasks:
    • Add OSHUN_LLM_API_BASE/_API_KEY/_MODEL to deploy config; add a §8 row.
    • Fix the stale comment at routes/sophia.ts:20; correct §9 to "seam built + wired; activate via env."
    • (e2e) Inject a fetchImpl (the synthesizer accepts one, :129) returning a canned completion; assert the abstractive answer cites only valid Nisaba ids; assert a 500/timeout falls back to extractive (no 500 surfaced, grounding intact).
    • (unit) Confirm sanitize (:98) drops a claim citing an unknown id and coerces model-onlysynthesized (extend tests if not already covered).
  • Acceptance criteria: With OSHUN_LLM_* set, POST /v1/sophia/answer returns abstractive prose whose every claim cites a real Nisaba id, abstaining when evidence is absent; with the endpoint erroring it returns the extractive answer (never a 500, never un-cited prose); unset → extractive composer answers.
  • Verification: cd apps/oshun/bff && npx vitest run src/sophia/ src/routes/
  • Effort / risk: S. Honesty guards + fallback already enforced; minimal risk.

1.6 upload → model-governance enforcement (BUILT 2026-06-09 — user confirmed V1 scope)#

  • Status today: The real model-governance is the output gate (1.1), already live. The three pieces §9 implies should connect do not: Civitai intake is an image/LoRA review verdict with no governance metadata + no registry entry (isis/civitai-intake-store.ts); ModelGovernanceStore.admit() (isis/model-governance-store.ts:190) is for already-registered 3D models with full license metadata (@isis/model-governance-3d, returns unknown_model for unregistered, :195); there is no in-BFF 3D-model upload/registration flow to produce the modelId + license admit() needs.
  • The gap: Bridging them requires inventing an upload contract that doesn't exist.
  • External dependency: Not creds/infra — a missing product contract (an upload that captures license + IP + registers into the registry + calls admit()).
  • Granular tasks:
    • (decision, blocking) Confirm with product whether upload→registry→admit() is in V1 scope. If NO → correct §9: "covered by the output gate (1.1); upload-admission deferred — no upload contract." If YES, then: (DECIDED 2026-06-09 — user confirmed upload IS in V1 scope; built below.)
    • Define the upload contract (artifact + license category + IP declaration → ThreeDModelGovernanceLicenseRegistryEntry, model-governance-store.ts:5). (The route validates a full ThreeDModelGovernanceLicenseRegistryEntryInput: modelId/displayName/licenseName + a valid licenseCategory (incl. the operator-supplied category) + the COMPLETE per-right matrix (all six rights, each status+commercialStatus from the real enums) — a partial or malformed license is rejected, never silently defaulted.)
    • Make the 3D license registry durable/writable (today static DEFAULT_3D_MODEL_LICENSE_REGISTRY, :162). (ModelGovernanceStore.register() rebuilds the registry with the new entry via create3DModelLicenseRegistry — validates + de-dupes (additive, never overwrites a curated id, never half-writes). Snapshot-durable: wireDurableModelGovernance(dbHandle.store) (server.ts) persists the operator uploads + re-registers them at boot, so an uploaded model survives a restart; a now-invalid/colliding persisted entry is skipped defensively.)
    • Add POST /v1/admin/isis/model-governance/upload (admin-scoped, mirror admin-model-governance.ts) that registers then admit()s at the boundary. (Built — register → admit at the boundary (server-stamped evaluationId): 201 { registered, assessment } / 409 duplicate_model / 400 invalid_payload / 403 non-admin. 25 tests: 12 store (admit + register + durability) + 4 upload route (201 register+admit+GET-resident, 409 duplicate, 400 malformed, 403 scope). tsc + eslint clean.)
    • (tests) Domain-correctness: non-commercial license in a commercial-SaaS topology → deny/requiresReview; prohibited-IP prompt → blocked. (Done — verified 2026-06-09: new apps/oshun/bff/src/isis/model-governance-store.test.ts (6 tests) drives the live ModelGovernanceStore.admit() over known-correct @isis/model-governance-3d verdicts: flexicubes (non-commercial) in a serverless-burst commercial-saas deployment → commercial DENY → admitted: false; permissive trellis-2 + a "Pikachu/Pokemon" prompt → IP DENY → blocked (proves the stricter-of-two); permissive + no IP → admitted; unknown_model + duplicate_evaluation rejected (never fabricates a posture); the record carries BOTH sub-verdicts. Tests the EXISTING registered-model admission — the upload→registry contract above stays product-blocked.)
  • Acceptance criteria: Either the doc is corrected (real model-governance = output gate, upload-admission out of scope), or an upload registers + admits a model and a verdict is recorded + surfaced.
  • Verification: cd apps/oshun/bff && npx vitest run src/isis/model-governance-store.test.ts
  • Effort / risk: S to confirm-blocked + doc-fix; L if a real contract is built. Risk: do NOT fabricate a fake upload→admit bridge to "close" it.


Part 2 — Messaging, Reminders & Notifications#

Foundation is real, not stubbed. Every transport (libs/oshun/messaging-channels/ src/transports.ts), the delivery router (delivery.ts:91), the config gate (provider-config-env.ts:49, all-vars-present test :56-129), the reminder cycle/worker, and all four producer mappers (reminder-producers.ts) are production-grade with passing tests. Remaining work = wiring + UI + domain bulk queries + creds.

2.1 Channel-binding UI (WhatsApp / SMS / email, per-user)#

  • Status today: "Hardcoded demo array" CONFIRMEDDEMO_SERVICES (apps/oshun/web/src/components/ProfileSettingsPanel.tsx:145-156), rendered :1367-1437, Connect/Manage button :1416-1433 has no onClick. Telegram is the only real channel-bind (routes/telegram.ts:61-87) but it's not persisted. TelegramChannelBindingsStore (messaging-channels/telegram-bindings-store.ts:111)
    • admin routes are for editorial channel publishing — different concern, don't conflate. MessageRecipient already models all addresses (delivery.ts:25-34). Device tokens persist (routes/device-tokens.ts); no email/SMS/WhatsApp recipient store exists.
  • The gap: Per-user, per-channel binding lifecycle (request → send code → verify → mark verified → unbind), a durable store, BFF routes, a real UI replacing DEMO_SERVICES, and resolution of bindings into MessageRecipient at delivery time.
  • External dependency: SMS verify → Twilio; WhatsApp → Meta; email → SendGrid. Store + routes + UI are pure code, ship fail-closed (binding stays pending, verify-code send returns missing-config without creds).
  • Granular tasks:
    • (domain lib) libs/oshun/messaging-channels/src/channel-bindings.ts: BindableChannel='email'|'sms'|'whatsapp', ChannelBinding{userId,channel,address, status,codeHash,codeExpiresAt,verifiedAt,attempts}, generateVerificationCode() (crypto 6-digit), constant-time hashCode/verifyCode, pure requestBinding/ confirmBinding/unbind reducers; export from index. (Built + exported. Type named BindableChannelBinding (not ChannelBinding) to avoid a barrel collision with preferences.ts's existing verified-opt-in ChannelBinding. Crypto uses @noble/hashes SHA-256 + the Web Crypto CSPRNG (getRandomValues, rejection-sampled, no modulo bias) — NOT node:crypto, which the lint rule forbids in this client-transpiled lib (webpack noops it). Constant-time hex compare; salt = the binding identity so a code can't be replayed across addresses; status machine pending→verified/locked/unbound with a bounded attempt budget + TTL expiry. The BFF store / route / recipient-resolution / UI are the deferred wrappers over this foundation.)
    • (domain tests) channel-bindings.test.ts: code expiry, max-attempts lockout, wrong-code rejection, idempotent re-request, unbind, constant-time path. (15 domain-correctness tests: code format + crypto variety (200 draws → >50 distinct), issued-code verifies, custom TTL, wrong-code rejection, cross-address non-replay (salt), malformed-hash → false (no throw), verify clears the secret, 5-attempt lockout (then even the correct code is refused), expiry-beats-correct-code, not-pending, re-request rotates the code + resets attempts, unbind clears. All green; lint + tsc clean.)
    • (BFF store) apps/oshun/bff/src/messaging-channels/channel-bindings-store.ts (mirror telegram-bindings-store.ts Map+reset); snapshot-durable like reminders (reminders-route.ts:65-75). (Per-(user,channel) nested Map wrapping the domain reducers; request/confirm/unbind/list/get + isBindableChannel guard; the full binding (incl. code hash) lives here, the route projects a secret-free view. SNAPSHOT-DURABLE 2026-06-09: ChannelBindingsSnapshot (every user's bindings flattened — each binding self-describes its userId+channel) + bindSnapshotSink/restoreSnapshot + a private persist() on a new single setBinding() write-chokepoint (covers request/confirm/unbind); wireDurableChannelBindings(store) hydrates on boot then binds the write-through sink, wired in server.ts on the SAME deploy-bound DurableSnapshotStore as the persona-lifecycle / operator-incident / device-tokens stores. So a member's VERIFIED channels — and a PENDING verification's salted codeHash — survive a restart instead of forcing every member to re-verify after a deploy. 13 store tests: issued-code-verifies, persisted verify/wrong-attempt/lockout/expiry, per-user isolation, re-request rotation, reset, + 3 durability (a verified binding survives a restart; a pending code still verifies post-restart [codeHash persisted]; per-user isolation across re-hydrate).)
    • (BFF route) routes/channel-bindings.ts: GET /v1/profile/channels, POST /v1/profile/channels/:channel/bind (→ send code or missing-config), POST .../verify, DELETE .../:channel. preHandler:[authProtection, abuseProtection], user-scoped via request.authContext.userId (copy routes/profile.ts:148-174 + device-tokens.ts:71-78); register near registerDeviceTokenRoutes (app.ts:562). (Built + registered. Outcome map: verified→200, wrong-code/expired→400, locked→423, not-pending→409, not-found→404, invalid-channel→400. Delivery is fail-closed — missing-config in prod (no transport wired yet), preview (code exposed) in non-prod for the dev round-trip; never claims a send it didn't perform. The real per-channel transport dispatch is the deferred deploy-bound piece, matching the email-verify precedent. createApp boots with it; no /v1/profile/channels collision.)
    • (BFF route tests) 401, bind→missing-config, bind→pending (injected transport), verify happy/wrong-code, unbind, cross-user isolation. (8 tests: 401, bind→pending+preview-code (hash never leaked), bind→ missing-config in prod (injected isProduction), verify happy/wrong-code, 404 verify-with-no-pending, 400 unknown-channel, unbind + 404, per-user isolation. NOTE: a body-less DELETE must NOT send content-type: application/json (Fastify 400s on empty-body parse); and a user token needs ≥1 scope (empty scopes → 401 at authz.ts:266/366).)
    • (recipient resolution) Resolve verified bindings into MessageRecipient in the reminder produce/deliver path (server.ts:330-344; today reminders are in-app-only, reminders/streak-reminder-producer.ts:79). (Pure verifiedBindingsToRecipient(bindings) maps VERIFIED channels only onto MessageRecipient (email→email, sms→phoneE164, whatsapp→whatsappTo; pending/locked/unbound contribute nothing — never deliver to an unverified address). produceStreakRemindersForUsers gains an optional resolveRecipient?(userId) (default {} = in-app only), wired in server.ts to verifiedBindingsToRecipient(channelBindingsStore.list(userId)) so a verified channel rides alongside in-app delivery. 4 tests (2 helper + 2 producer); tsc/lint clean. The actual SEND still needs the per-channel transport creds (deploy-bound).)
    • (web data layer) apps/oshun/web/src/profile/channel-bindings-client.ts (mirror profile-sync.ts + notifications/push-registration.ts). (Thin typed channelBindingsClient (list/bind/verify/unbind) over the shared api client (/v1/profile/channels…), mirroring data-export-client.ts / consent-client.ts. Typed views are secret-free (no code hash). 4 unit tests asserting paths + bodies. The ChannelBindingsSection UI that replaces DEMO_SERVICES is the remaining layer.)
    • (UI) Replace DEMO_SERVICES + the static section with a real ChannelBindingsSection: live status, address input, send-code → code-entry → verify, unbind; keep/migrate data-profile-service* hooks. DONE — resolved the product-design flag by ADDING (not replacing). Verified against source that DEMO_SERVICES (ProfileSettingsPanel.tsx) is connected ACCOUNTS (Google / Apple Health / Spotify / Notion OAuth integrations), a DIFFERENT feature — replacing it would destroy that surface. So built apps/oshun/web/src/components/profile/ChannelBindingsSection.tsx ('use client', consuming channelBindingsClient) and ADDED it as a new "Notification channels" CollapsibleSection at the END of ProfileSettingsPanel (so no positional section-index shift), with the sibling vi.mock('../profile/ChannelBindingsSection',…) in the panel test. It renders each channel by status — verified → address + Remove; pending → code entry + Verify/Cancel (+ the dev preview code); unbound → address input + Send code — driving channelBindingsClient.{list,bind,verify,unbind} with honest per-row errors (never claims a send/verify it didn't perform). 5 component tests (mount affordances, bind→preview→verify happy path, wrong-code error, unbind, list-error) green; bounded tsc 0; eslint 0. The data-profile-service* connected-accounts hooks are untouched (correct — a different surface). NB: ProfileSettingsPanel.test.tsx carries 8 PRE-EXISTING failures on branch HEAD (verified via git stash; stale positional sections[N]/links.length assertions from the earlier unconditional "Operator access" section/shortcut addition) — UNRELATED to 2.1 and not introduced here. Browser visual pass deferred (RAM-bound).)
    • (UI unit test) Extend ProfileSettingsPanel.test.tsx: pending→verified, verify-error, unbind. (Done — verified 2026-06-09: the channel-binding UI states are covered by the DEDICATED components/profile/ChannelBindingsSection.test.tsx (5 tests, green): bind → dev-preview-code → verify (pending→verified); a wrong code → honest error + NOT marked verified (verify-error); unbind; mount affordances; list-load error. That's the correct home — ProfileSettingsPanel mocks the section, and the panel test carries pre-existing positional-index failures unrelated to this work, so extending it would risk green tests.)
    • (e2e) e2e/profile-channel-bindings.spec.ts (pattern from profile-consent-controls.spec.ts): route the endpoints, drive bind→verify→unbind. (DONE 2026-06-09 — built + GREEN against the real BFF (seedCustomerSession + the dev preview-code path, NO external creds). Two tests, both pass incl. the suite auto-axe scan: (1) email send code → verify → revoke full lifecycle (initial unboundpending + dev code → verified + address → unbound); (2) a WRONG code → "incorrect" error + stays pending (no fabricated verify). Drives the shipped ChannelBindingsSection in ProfileSettingsPanel. This e2e caught + I fixed FOUR real bugs source-reading missed: (a) the address/code <input>s had no accessible name → axe label critical → added aria-labels; (b) the row re-list()-ed after each mutation, which the PWA service worker serves stale (it caches /v1/* GETs by URL, ignoring cache-control: no-store) → the UI never reflected a just-made bind → switched to optimistic updates from each mutation's server-confirmed response (real data, never fabricated) + added no-store on the route; (c) the verify route returned a 4xx for a wrong code, so the api-client threw and DISCARDED the outcome the UI needs → now returns 200 carrying the outcome (verified/wrong-code/expired/locked/not-pending), 404 only for not-found (route test updated); (d) the BFF CORS allow-methods omitted DELETE/PUT/PATCH (@fastify/cors default GET,HEAD,POST), so EVERY cross-origin unbind/sync/delete the api-client makes was browser-blocked → added the verbs. The spec blocks the SW + uses a unique-per-run customer for deterministic isolation. Verified: BFF tsc 0, web bounded tsc 0, eslint 0, route 8/8, store 10/10, component 5/5, e2e 2/2.)
    • (cross-link + deploy) Update coverage tracker; add OSHUN_TWILIO_*/ _WHATSAPP_*/_SENDGRID_* to .env.example; note fail-closed in §4. _(Cross-link DONE — coverage.md + the journey's E2E-coverage section now list profile-channel-bindings (lifecycle + the 4 bugs). Env vars ALREADY present: .env.example documents OSHUN_TWILIO_ACCOUNT_SID/_AUTH_TOKEN/_FROM (SMS), OSHUN_SENDGRID_API_KEY/OSHUN_SMTP_HOST (email) + OSHUN_WHATSAPP_\*— the general messaging transports the channel-binding code-delivery REUSES once wired; adding channel-binding-specific rows would be inert (7.6 no-inert-rows). The per-channel verification-code SEND is still deploy-bound (route reports missing-configin prod /previewin non-prod — never claims a send) → a dedicated §4 note left for when that transport dispatch is wired.)_ _2026-09-18: open for an agent. The residue is the per-channel verification-code send: wire the route to the messaging transports, prove email against Mailpit (:8025), and keep SMS and WhatsApp failing closed until vendor keys exist. This is the same defect as P1 ofdocs/audits/V1*RESIDUAL_BACKLOG_2026-06-11.md; close them together.*
  • Acceptance criteria: A signed-in user enters an address, gets a real code (creds set) or honest "not configured", verifies, sees verified, can unbind; persists across restart; a verified channel populates MessageRecipient; no DEMO_SERVICES remains; user isolation holds.
  • Verification: cd libs/oshun/messaging-channels && npx vitest run src/channel-bindings.test.ts · cd apps/oshun/bff && npx vitest run src/routes/channel-bindings-route.test.ts · cd apps/oshun/web && npx vitest run src/components/__tests__/ProfileSettingsPanel.test.tsx · cd apps/oshun/web && npx playwright test e2e/profile-channel-bindings.spec.ts --workers=1
  • Effort / risk: L. Risk: constant-time compare + attempt rate-limiting (reuse abuse-protection preHandler); PII (phone/email) rides the snapshot store, not a new sink.

2.2 Email-verify round-trip#

  • Status today: signUp sets emailVerified:false unless OSHUN_DEV_AUTO_VERIFY_EMAIL=true/test (auth/customer-auth-store.ts:142-157). No send-verification or verify-token endpoint exists (routes/auth.ts:309-380 has only signup/login/logout/recovery/me/sessions). The only email-ish round-trip (recovery) returns the code in-body in dev and never calls SendGrid. SendGrid transport is real (transports.ts:66-98). Mailpit is in docker/docker-compose.dev.yml:260-271 (SMTP :1025, UI :8025) but the harness doesn't use it.
  • The gap: (a) a send-verification endpoint; (b) a verify endpoint flipping emailVerified; (c) transport-shape decision — SendGrid is HTTPS, Mailpit is SMTP, so a real round-trip e2e needs either an SMTP transport pointed at Mailpit or an HTTP-capture fake of SendGrid.
  • External dependency: Prod send = SendGrid (OSHUN_SENDGRID_API_KEY, OSHUN_MESSAGING_EMAIL_FROM). E2E round-trip = SMTP transport→Mailpit, or capture the SendGrid HTTP call via the fetchImpl seam (transports.ts:73).
  • Granular tasks:
    • (domain/token) Add a verification-token mint+verify with TTL (reuse the recovery-code generator customer-auth-store.ts:804-813), bound to userId+email. (New createEmailVerificationToken — 32-byte hex, 24h TTL via OSHUN_AUTH_EMAIL_VERIFICATION_TTL_MS.)
    • (store) Extend CustomerAuthStateStore with issueEmailVerification/ confirmEmailVerification + a verificationTokensByToken Map (mirror recoveryCodesByCode, :108). (Single-use, TTL-bound; flips emailVerified; re-request invalidates prior. Prod delivery is 'missing-config' — it never claims an 'email' send it didn't perform.)
    • (send) On signUp success, send the verification link via sendEmailViaSendgrid (fail-soft, return verificationDelivery:'email'| 'missing-config'|'preview' like recovery :273). (Done — and wired on BOTH signup AND the explicit POST /v1/auth/verify-email/request via one code path. New auth/verification-email-sender.ts: resolveVerificationEmailSender(env) is fail-closed null unless OSHUN_SENDGRID_API_KEY + OSHUN_MESSAGING_EMAIL_FROM + OSHUN_WEB_BASE_URL are all set; buildVerificationEmailContent builds the {base}/auth/verify-email?token=… link/subject/body; the sender POSTs via sendEmailViaSendgrid bounded by an 8s timeout (a hung provider → not-delivered, never stalls signup). issueEmailVerification is now async (userId, sender?): when the sender accepts the message → delivery 'email' and the token is NEVER exposed (it went by email); a throwing/false/timed-out send is swallowed fail-soft and falls through to the honest 'preview'(non-prod)/'missing-config'(prod) path — 'email' is claimed ONLY on a real provider accept. The signup response now carries a verification block; a send failure never fails the signup. registerAuthRoutes resolves the sender from env (override via options for tests). 9 new tests (4 store-send incl. throwing/false/null fail-soft + token-never-leaked-when-delivered; 5 sender-module incl. fail-closed-null, real SendGrid POST body carries the verify link, non-2xx → false, throw → propagates; + 2 route tests: signup→preview without a sender, signup→email with an injected sender). Updated the 7 existing store/route callers to await. tsc + eslint clean; 20/20 + 18 auth-route + 5 profile-route green.)
    • (routes) POST /v1/auth/verify-email/request (auth-gated) + POST /v1/auth/verify-email/confirm (public, abuse-protected); register on both /auth/* and /v1/auth/*.
    • (decision) SMTP transport — add sendEmailViaSmtp so e2e can point OSHUN_SMTP_HOST/PORT at Mailpit and assert via http://localhost:8025/api/v1/messages; gate selection in buildMessageProviderConfigFromEnv (prefer SMTP when set). OR (alt) write the round-trip e2e against an injected fetchImpl capturing the SendGrid POST and extracting the token; document Mailpit isn't used (HTTPS). (DONE — chose the real SMTP transport (not the fetchImpl-capture alt). New libs/oshun/messaging-channels/src/smtp-email-transport.ts sendEmailViaSmtp is a real, dependency-free RFC 5321 client over node:net/node:tls (greeting→EHLO→optional STARTTLS→optional AUTH PLAIN/LOGIN→MAIL FROM→RCPT TO→DATA→QUIT; multiline replies; RFC 5322 message build; dot-stuffing; header-injection guard; injectable socket factory + TLS upgrade). NEVER fabricates success — any non-2xx reply / dropped connection / reply timeout → {ok:false}. Wired into BOTH the email-verify sender (resolveVerificationEmailSender prefers SMTP via OSHUN_SMTP_HOST, else SendGrid; fail-closed) AND the messaging router (MessageProviderConfig.email.smtp + buildMessageProviderConfigFromEnv gate OSHUN_SMTP_HOST/_PORT/_SECURE/_STARTTLS/_USERNAME/_PASSWORD, precedence over SendGrid; delivery.ts email-ses branch). 15 transport tests against a real in-process node:net SMTP server (no mocking: plain Mailpit path, AUTH PLAIN/LOGIN, dot-stuff round-trip, STARTTLS sequencing, MAIL-FROM reject, connection-refused, reply-timeout, multiline EHLO) + 9 wiring tests (4 sender SMTP branch + 5 router/env). lib tsc 0, BFF tsc 0, eslint 0. .env.example documents the OSHUN_SMTP* block + the Mailpit round-trip note. The Playwright email-verify-roundtrip.spec.ts against a live BFF + Mailpit stays the deferred e2e box below — RAM-bound on this box — but is now de-risked: the transport + both consumers are real and in-process-verified.)_
    • (unit/route tests) token mint/expiry, confirm flips emailVerified, expired/replayed rejected, re-request invalidates prior; /me reflects verified. (9 tests: 6 store + 3 route, via a bare-Fastify registerAuthRoutes with NODE_ENV overridden off auto-verify.)
    • (e2e) e2e/email-verify-roundtrip.spec.ts: signup (auto-verify off) → poll Mailpit or read captured token → open link → assert verified. (DONE 2026-06-09 — GREEN (2/2 incl. auto-axe). Uses the dev preview-token path (read the captured token), NOT Mailpit, so it needs no SMTP creds: a fresh signup (NODE_ENV=development, OSHUN_DEV_AUTO_VERIFY_EMAIL unset → emailVerified false) returns verification.verificationToken; the spec opens /auth/verify-email?token=…, asserts the page confirms it (→ "Email verified"), then opens the SAME link again and asserts an honest "no longer valid" (single-use — the BFF consumes the token), + a missing-token link → "incomplete". BUILT THE MISSING LANDING PAGE (real gap closed): the BFF emails a {base}/auth/verify-email?token=… link but NO page existed there (the link was DEAD — 404/redirect). Added app/auth/verify-email/page.tsx (server: reads the token from searchParams) + components/auth/VerifyEmailConfirm.tsx ('use client': confirms the token on mount against the PUBLIC POST /v1/auth/verify-email/confirm — StrictMode-safe useRef guard so the single-use token isn't double-consumed — and reports verified / already-verified / invalid-expired-consumed / missing-token; never fakes success, uses <Link> per the Next rule). Also fixed the public-route gates so the signed-out recipient isn't bounced to /welcome: added /auth/verify-email to BOTH proxy.ts PUBLIC_PATHS (server 307 gate) and auth-session.ts isPublicShellPath (client gate). 4 island unit tests + auth-session tests green; web bounded tsc 0, eslint 0, stub-scan clean. No dedicated journey doc exists for email-verify, so the spec is standalone account-security coverage.)
    • (cross-link + deploy) Add OSHUN_SENDGRID_*/OSHUN_MESSAGING_EMAIL_* (+ OSHUN_SMTP_* if chosen) to .env.example; note OSHUN_DEV_AUTO_VERIFY_EMAIL must be unset for the test. _(.env.example DONE — OSHUN_SENDGRID_API_KEY + OSHUN_MESSAGING_EMAIL_FROM were already present (2.5); added OSHUN_WEB_BASE_URL (the link origin) + rewrote the email-verify block to document the now-wired fail-soft send + the unset-OSHUN_DEV_AUTO_VERIFY_EMAIL test caveat. The coverage-tracker cross-link rides with the deferred e2e (it needs the live-BFF round-trip to claim coverage); OSHUN_SMTP_\* is the separate SMTP-transport decision below.)_ 2026-09-18: open for an agent. The residue is the coverage-tracker cross-link, which waits for the live-BFF e2e of this section; the stack boots by hand on the Linux server.
  • Acceptance criteria: A fresh signup (auto-verify off) creates emailVerified:false, triggers a real email (captured by Mailpit/HTTP fake), and the link flips emailVerified:true (via /v1/auth/me); replayed/expired tokens rejected; no creds → honest missing-config.
  • Verification: docker compose -f docker/docker-compose.dev.yml ps | grep mailpit · cd apps/oshun/bff && npx vitest run src/routes/auth.test.ts src/auth/customer-auth-store.test.ts · cd apps/oshun/web && npx playwright test e2e/email-verify-roundtrip.spec.ts --workers=1
  • Effort / risk: M (HTTP-capture) / L (SMTP transport). Risk: don't claim a "Mailpit round-trip" while shipping only an HTTPS transport; token single-use + TTL.

2.3 Push / STT / two-way calendar#

2.3(a) Push (FCM/APNs)#

  • Status today: Transports fully real (FCM transports.ts:132-165, APNs ES256 HTTP/2 :183-258, routed correctly delivery.ts:132-165). Device-token registration live (routes/device-tokens.ts:65-205, web client notifications/push-registration.ts). Config gate provider-config-env.ts:76-104.
  • The gap: (i) APNs needs an HTTP/2 fetchImpl injected (Node fetch is HTTP/1.1) — nothing injects it (V1_DEPLOYMENT_REQUIREMENTS.md:67); (ii) device tokens are in-memory + not read into MessageRecipient.deviceToken; (iii) no e2e (can't drive a real device).
  • External dependency: FCM/APNs creds + web VAPID + an HTTP/2 fetch lib.
  • Granular tasks:
    • Inject an HTTP/2 fetchImpl (Node http2/undici H2) at the BFF root into the delivery path so push-apns can POST (seam: provider-config-env.ts:154-166, transports.ts:217); warn on boot if APNs configured but fetch is H1. DONE — per-channel injection (the key correctness fix). The H2 adapter createHttp2TransportFetch (http2-transport-fetch.ts, real node:http2, 4 h2c-server tests) was built; now WIRED. CRITICAL: the delivery path uses a SINGLE shared fetchImpl across all 7 channels, so a naive global H2 injection would force SendGrid/Twilio/FCM/Meta/Slack/Discord (all HTTP/1.1 endpoints) through node:http2 too — wrong. So deliverDispatchedMessage gained a SEPARATE optional apnsFetchImpl used ONLY by the push-apns case (apnsFetchImpl ?? fetchImpl); threaded through deliverWithEnvProviders + ReminderCycleInput; the BFF runScheduledReminderCycle (reminders/reminders-route.ts) injects createHttp2TransportFetch() so a scheduled Apple-device reminder actually delivers over H2 instead of failing on H1. Reachable TODAY via POST /v1/reminders/{schedule,run-cycle} (a client schedules channel:'push',pushProvider:'apns',recipient:{deviceToken}). Boot signal: server.ts logs the H2 wiring when APNs is configured; the pure tested guard apnsHttp2BootWarning (provider-config-env.ts) flags the misconfig (APNs creds set, no H2 wired). 7 new tests: 3 in delivery.test.ts (APNs uses apnsFetchImpl NOT the shared fetch; FCM stays on the shared fetch; fallback to shared when unwired) + 4 in provider-config-env.test.ts (boot-warn warn/silent/silent + deliverWithEnvProviders routes APNs through apnsFetchImpl). lib tsc 0, BFF tsc 0, eslint 0, stub-scan clean.
    • Persist device-tokens.ts tokensByUser via the snapshot store. (DONE 2026-06-09 — device-tokens.ts is now snapshot-durable, mirroring the reminder / personalization / conversation stores: a flat DeviceTokenSnapshot (each token carries its userId, so it reconstructs the per-user map), captureSnapshot/restoreSnapshot, a write-through sink, and wireDurableDeviceTokens(store) (hydrate at boot + fire-and-forget save) wired in server.ts beside the other durable stores (conditional on dbHandle.store; in-memory without a DB). register/unregister persist; hydrate uses a non-persisting putToken. 3 route-level tests over a real serialization boundary (inMemorySnapshotStore): a registered token + an unregister BOTH survive a simulated restart (register → flush → reset → re-hydrate → GET), + per-user isolation. BFF tsc 0, eslint 0, stub-scan clean. NB the GET redacts the raw token string (asserted on the returned tokenType/deviceId/platform metadata). Supersedes the earlier "needs Prisma not snapshot" caution — snapshot durability is the established V1 baseline for user-scoped stores (personalization/conversation use it); a Prisma table is a deploy-time scale optimization.) (Deploy-bound — the durable snapshot mechanism fits small operator/admin whole-document stores, not a large per-user token table; same deferral as the other in-memory stores in this checklist. The accessor getTokensForUser exists module-private in device-tokens.ts.)
    • Read device tokens into recipient.deviceToken at reminder time (FCM vs APNs by stored tokenType). (DONE 2026-06-10 — the two prerequisites the DEEPER-BLOCKED note named have since landed (session 6 built the Expo + Web-Push transports; the device-token store became snapshot-durable 06-09), so this resolved cleanly: device-tokens.ts exports resolveDevicePushBinding(userId) — picks the user's most-recently-ACTIVE registered token and maps stored tokenType→transport: fcm/apnsrecipient.deviceToken, expoexpoPushToken, web-push→parses the stored JSON.stringify(subscription.toJSON()) into webPushSubscription (malformed → skipped honestly, falls back to the next-newest deliverable token; none → null → reminder stays in-app only). The lib's ReminderRecipientBinding grew pushProvider? (+ the PushRecipientBinding type) forwarded by toReminder, so the planner routes channel:'push' to the token's REAL transport (push-fcm/push-apns/push-expo/push-webpush) instead of the coarse FCM default. All four BFF producers (streak/assignment/session/content-drop) accept resolvePushBinding and merge it with the verified-binding recipient; server.ts's worker produce hook passes resolveDevicePushBinding to all four. One most-recently-active device per reminder — consistent with the one-external-send-per-reminder deliveryId architecture (note 3's fan-out concern is thereby respected, not fought). 9 new tests: 6 resolver (each tokenType, newest-wins, malformed-web-push fallback, all-undeliverable→null, no-tokens→null) + 2 producer + 1 lib forwarding; BFF + lib tsc clean, suites green.)
    • Extend transports.test.ts for the H2 path (authorization bearer JWT, apns-topic, apns-push-type) with an injected H2 double; assert apns-id/FCM-name. (Duplicate coverage — http2-transport-fetch.test.ts already drives the H2 adapter (incl. apns-id passthrough) against a real h2c server, and transports.test.ts:146-259 already tests sendPushViaApns exhaustively (ES256 JWT, prod+sandbox, apns-id, content-available, rejection) with an injected fetch. The NEW per-channel routing (APNs→H2 vs others→H1) is now covered end-to-end in delivery.test.ts. Re-testing the union in transports.test.ts adds nothing.) Not a task (2026-09-18): its own note records that the coverage already exists in http2-transport-fetch.test.ts, transports.test.ts and delivery.test.ts.
    • Add e2e/push-registration.spec.ts (web push reg against a mocked /v1/device-tokens). _(DONE 2026-06-10 — and fixed the deeper product gap behind it: registerForPushNotifications had ZERO callers (the web app never offered push enablement). NotificationPreferences grew a "This device" registration row under the master push toggle (data-profile-push-device, status checking/unsupported/unavailable/permission-denied/unregistered/ registered/error — honest fail-closed copy for each): register → browser permission + SW subscription → POST /v1/device-tokens (same-origin, the stringified subscription JSON the BFF's resolveDevicePushBinding parses back); unregister → browser unsubscribe + DELETE. VAPID public key from NEXT_PUBLIC_VAPID_PUBLIC_KEY (documented in .env.example beside OSHUN_VAPID_\*) with a **OSHUN_WEB_PUSH_VAPID_PUBLIC_KEY** runtime override seam (host page / e2e harness — no rebuild). The 4-test spec stubs ONLY the browser Push API boundary (fake SW registration + granted permission — headless chromium has no push service) and asserts the REAL wire shape both ways (endpoint + p256dh/auth keys round-trip), the 503-BFF→error honesty path, and the no-VAPID→unavailablefail-closed state. 4/4 green at 1 worker; the adjacentprofile-notification-preferences.spec.ts re-run green (5/5, no regression); web tsc clean; coverage.md row updated.)_
    • Add FCM/APNs/VAPID vars to .env.example; document the H2 requirement. _FCM (OSHUN_FCM_ACCESS_TOKEN/_PROJECT_ID) + APNs (the four OSHUN_APNS_*+ optional\_SANDBOX) are documented; the APNs comment now accurately states APNs requires HTTP/2 and the BFF wires it automatically (no env). CORRECTION 2026-06-09: the original note ("VAPID has no BFF var") was written in session 1 and is now STALE — session 6 built the Web-Push (RFC 8291/8292) + Expo transports, and buildMessageProviderConfigFromEnv (provider-config-env.ts:136,143-145) DOES read OSHUN_VAPID_PUBLIC_KEY/ \_PRIVATE_KEY/\_SUBJECT(all three required →config.webPush) and OSHUN_EXPO_ACCESS_TOKEN(optional push-security) as live BFF vars. These were wired but never documented (the env-doc was the gap, not the rule). NOW DOCUMENTED — added the Push-Expo + Push-Web-Push (VAPID) blocks to .env.example beside the FCM/APNs block, with the optional-Expo / all-three-VAPID / recipient-gated semantics. (The web client still gets the VAPID *public* key via injected config + the mobile app via EXPO_PUBLIC_VAPID_PUBLIC_KEY; the BFF needs the full keypair to sign.)_
  • Acceptance criteria: With creds + H2 fetch, push-apns POSTs a signed request to api.push.apple.com/3/device/<token> returning apns-id; tokens persist + resolve into recipients; web-push reg e2e passes; without creds → missing-config.

2.3(b) STT (speech-to-text) — FIXES TWO FORBIDDEN STUBS#

  • Status today: Browser Web Speech path is real but not CI-drivable (assistant/AssistantPanel.tsx:10). Telegram voice transcription is two fabricated-transcript stubs: apps/oshun/bff/src/telegram/webhook.ts:67-71 returns `voice transcript from ${fileId}`; apps/oshun/telegram-bot/src/index.ts:119-123 returns `Telegram voice note ${fileId}`. No server-side STT provider exists.
  • The gap: A real VoiceProvider.transcribeTelegramVoice (libs/oshun/messaging-channels/src/telegram/bot.ts:70-72) replacing the stubs, fail-closed when unconfigured.
  • External dependency: An STT provider (Whisper/Deepgram/Google Speech) + key.
  • Granular tasks:
    • Add libs/oshun/messaging-channels/src/stt-provider.ts implementing VoiceProvider against the chosen API (injectable fetchImpl), fail-closed (stt_not_configured) without a key — never a fabricated transcript. (Placed at src/telegram/stt-provider.ts beside bot.ts; real 3-step path: Telegram getFile → download → OpenAI-compatible /audio/transcriptions.)
    • Replace the two stub bodies (webhook.ts:67-71, telegram-bot/src/index.ts:119-123) to call the provider, or surface "voice transcription unavailable" when unconfigured. (Both call resolveSttVoiceProvider; bot handler now replies honestly on throw/empty — also updated the e2e that pinned the old stub.)
    • Add the STT key to buildMessageProviderConfigFromEnv (all-vars-present gate). (Gate implemented in resolveSttVoiceProvider instead — STT is INBOUND transcription, not an outbound delivery channel, so it must not enter the delivery config / configured-channels list. Same fail-closed all-vars-present semantics, tested.)
    • stt-provider.test.ts: real transcript extraction, error path, and missing-config (assert it NEVER returns a fake transcript — stub-elimination check).
    • Add the STT key to .env.example.
  • Acceptance criteria: A Telegram voice note is transcribed by a real provider when configured; honestly reports unavailable otherwise; both placeholder strings gone; tests prove a wrong/absent key never yields a fake transcript.

2.3(c) Two-way calendar#

  • Status today: A complete two-way framework exists + is tested + fails loud (libs/shared/inbound-integrations/src/calendar.ts: CalendarTransport with upsert/delete :146-152, exportEvent throws when no transport :402-414, providers google/apple/outlook :13). Mobile composes it (apps/oshun/mobile/src/calendar/mobileCalendarSync.ts). One-way .ics export exists for Nyx. No provider transport implements CalendarTransport, and the framework is not wired into any BFF route.
  • The gap: A provider transport + a BFF route/store; the domain is done.
  • External dependency: Google Calendar API / CalDAV / Microsoft Graph creds + OAuth.
  • Granular tasks:
    • Implement CalendarTransport for ≥1 provider (Google or CalDAV): fetchEvents, upsertEvent, deleteEvent (injectable fetchImpl). (Done — libs/shared/inbound-integrations/src/calendar-google-transport.ts createGoogleCalendarTransport({resolveAccessToken, fetchImpl?}) implements all three. The framework's CalendarDateTime/CalendarAttendee/ CalendarEventStatus are modeled on the Google Calendar API v3 event shape, so mapGoogleEventToProviderEvent is near-1:1 (summary→title, id→externalId, start/end/attendees/recurrence pass through; unknown status→fail-safe 'confirmed'; '(untitled)' fallback). fetchEvents = real windowed GET (timeMin/timeMax/singleEvents), paginated via nextPageToken (no silent cap), incremental sets showDeleted + updatedMin (so deletes propagate); upsertEvent = POST(create)/PUT(update-by-externalId); deleteEvent = DELETE (410 Gone = idempotent success). Fail-LOUD: an empty/throwing resolveAccessToken rejects BEFORE any network call (never proceeds tokenless, never fabricates events); a non-ok HTTP throws. The OAuth2 token exchange/refresh behind resolveAccessToken + the BFF route/store (routes/calendar-sync.ts) + e2e are the deploy-bound/remaining pieces. 13 spec tests (mapping timed/all-day/cancelled/attendees, pagination, incremental showDeleted, fail-loud token + HTTP, POST-vs-PUT, 410-success). tsc + eslint + stub-scan clean. PLUS a framework-integration spec (calendar-google-transport-framework.spec.ts, 2 tests): the real createCalendarConnectorFramework reconciliation engine + MemoryCalendarSyncStore driving the REAL Google transport over a fake fetch — proves a live-shaped Google event JSON flows through the transport and is accepted by the framework's import (creates:1, summary→title, watermark persisted) and export (PUT update; the framework requires a non-empty externalId — it updates a linked event, not a blind create). This composition test is the "does the new provider actually work with the orchestration" proof. PLUS the OAuth2 auth sidecreateGoogleOAuthAccessTokenResolver exchanges a stored refresh token for an access token at Google's token endpoint (grant_type=refresh_token), composing directly into the transport's resolveAccessToken. Fail-LOUD: an absent refresh token (never exchanges), a non-2xx exchange, or a response with no access_token all throw — never an empty/fabricated token. 4 more tests (17 total in the transport spec). So Google calendar is now usable end-to-end given a deploy-bound client-id/secret + refresh token; only the interactive OAuth consent flow + the BFF routes/calendar-sync.ts + store remain.)
    • routes/calendar-sync.ts: OAuth start/callback, POST /v1/calendar/sync (import), POST /v1/calendar/export (writeback), GET /v1/calendar/connectors; bind a CalendarSyncStore (Memory dev / durable prod); auth-gate per user. (Built the env-config/admin model (mirroring the LMS connector surface, 3.3) rather than a per-user one — consistent with the existing tenant-connector pattern: calendar/calendar-runtime-env.ts createCalendarRuntimeFromEnv reads OSHUN_CALENDAR_CONNECTORS (JSON), validates each (malformed JSON + invalid descriptors retained with issues, never silently skipped), and builds the framework over the REAL Google transport + OAuth resolver (client creds from env; per-connector refresh token via the descriptor's refreshTokenRef → env) + MemoryCalendarSyncStore. google-only — the only transport implemented, so a non-google connector is surfaced as unsupported_provider, not dropped. routes/calendar-sync.ts: GET /v1/admin/calendar/connectors (secret-free summary + invalid w/issues, no refreshTokenRef leaked) + POST /v1/admin/calendar/connectors/:id/import (runs the framework's real reconciliation → the import run; 404 unknown, 409 CalendarConnectorError), admin-scoped, registered in app.ts (createApp boots, /v1/admin/calendar/connectors→401 unauth). The /export writeback route is now done tooPOST /v1/admin/calendar/connectors/:id/export runs framework.exportEvent (the connector identity is server-authoritative so a client can't spoof the tenant/connector/provider; 400 without an externalId — the framework updates a linked event; 404 unknown). So the two-way list/import/export trio is complete. 11 tests (runtime valid/unsupported/malformed + route auth/list/import/export/404/400). tsc + eslint + stub clean. The interactive OAuth flow's CODE is now built too — buildGoogleCalendarAuthorizationUrl (consent URL with access_type=offline + prompt=consent so a refresh token is actually returned) + exchangeGoogleCalendarAuthorizationCode (grant_type=authorization_code → access + refresh tokens, fail-loud on empty-code/non-2xx/no-token), 5 more tests (22 in the transport spec). The per-user interactive connect flow is now built toocalendar/calendar-oauth-connection-store.ts (single-use TTL-bound CSRF state tokens bound to the user + per-user connections; the refresh token is server-side, NEVER projected to the client) + routes/calendar-connect.ts: POST /v1/calendar/connect (auth; 503 unconfigured; else the consent URL), GET /v1/calendar/callback (public, CSRF-state-validated → code exchange → store the refresh token as a per-user connection → 302 to the web app; 502 if Google returns no refresh token — never persists a non-renewable one), GET .../connections (secret-free), DELETE .../:id. Registered in app.ts (createApp boots). 9 tests (store single-use/TTL/isolation + full connect→callback round-trip + forged-state 400 + no-refresh-token 502 + disconnect); OSHUN_GOOGLE_CALENDAR_REDIRECT_URI documented. The per-user connection store is now SNAPSHOT-DURABLE 2026-06-09CalendarOAuthConnectionsSnapshot (established connections ONLY; the ephemeral single-use/TTL CSRF state tokens are deliberately NOT persisted) + bindSnapshotSink/restoreSnapshot + persist() on saveConnection/removeConnection; wireDurableCalendarOAuthConnections(store) wired in server.ts on the SAME deploy-bound DurableSnapshotStore as the persona-lifecycle / channel-bindings / SSO stores. So a member's calendar links (incl. refresh token, needed for a post-restart refresh) survive a restart instead of forcing a reconnect. Gained a dedicated store unit test (6 store-logic + 3 durability incl. one asserting the CSRF state does NOT span a restart). Remaining: only the lib-level MemoryCalendarSyncStore sync-state durability (deploy-bound).) 2026-09-18: open for an agent. The residue the note names is one thing: make the library's MemoryCalendarSyncStore sync state durable (Postgres, per the repository's persistence policy), with a restart test.
    • Map domain items (Tara rituals, Arete reminders, Nyx events, mobileCalendarSync.ts:13-21) → CalendarEvent. (DONE 2026-06-10 — BFF calendar/domain-calendar-events.ts maps the user's REAL schedule (not the mobile fixtures): tara_ritual ← the durable upcoming-session store; metis_study_session ← the durable due-assignment store; nyx_event ← the user's enabled Nyx event reminders (honours an explicit channels selection — only 'external-calendar'-eligible reminders export); arete_reminder ← the REAL at-risk daily streak (same isStreakAtRisk signal as the reminder producer, block ending at the UTC day deadline). Block lengths are documented product defaults (30/15/60 min — calendar blocks need an end the upstream records don't carry). domainCalendarItemToCalendarEvent mirrors the mobile buildCalendarExportEvent conventions exactly (stable oshun-<kind>-<slug> externalIds + the raw.oshun metadata envelope) so either surface's exports reconcile identically. PLUS the consumer that makes it live: routes/calendar-events.tsGET /v1/calendar/events (the caller's real exportable schedule, user-isolated) and POST /v1/calendar/export (writes it to the caller's OWN connected Google calendar via a per-user transport built on their stored refresh token; 503 unconfigured / 409 no connection / per-item honest failure outcomes), registered in app.ts. 14 new tests (7 mapper + 7 route incl. per-user transport identity + cross-user isolation + fail-soft domain outage); tsc + stub-scan clean.)
    • Transport-double tests: import normalization (normalizeCalendarEvent:657), upsert/delete, fail-loud when transport missing (:402-414). (DONE 2026-06-10 — import normalization + upsert/delete were ALREADY covered by calendar.spec.ts ("imports provider events, normalizes them, and stores watermarks"; "exports and deletes events through the provider transport"; ownership/calendar-mismatch/import-only rejects). The genuinely missing case was fail-loud-on-missing-transport — added: a fetch-only transport (no upsertEvent/deleteEvent) → exportEvent rejects calendar_export_unavailable and deleteExportedEvent rejects calendar_delete_unavailable (never a silent no-op write-back). Lib spec 13/13 green.)
    • e2e/calendar-two-way.spec.ts against a mocked transport: connect → import → export → assert upsert called. (DONE 2026-06-10 — built as the BFF full-chain flow test routes/calendar-two-way-flow.test.ts (the web e2e variant is not yet drivable: the web app has NO calendar-connect UI — see the new task below — and a browser e2e cannot intercept the BFF's server-side Google token exchange, so the API-level chain with the transport as the ONLY double is the faithful version of this box). One app instance, all three real route modules: POST /v1/calendar/connect → consent URL → GET /v1/calendar/callback (exchange double) → the refresh token is stored on the caller's connection; POST /v1/admin/calendar/connectors/:id/import → run applied with creates:1 from the double's provider event; POST /v1/calendar/export → the caller's REAL seeded session upserts through THEIR connection — upserts[0].event.externalId === 'oshun-tara_ritual-session-sunrise-sit' asserted. Full calendar suite 44/44 green, tsc clean.)
    • (web UI — gap spotted 2026-06-10) The per-user calendar connect surface does not exist in the web app: nothing calls POST /v1/calendar/connect, and the BFF callback redirects to /account/integrations?calendar=connected which has NO page (404). Build the integrations surface (connect/disconnect Google calendar via the real per-user routes, list connections, trigger POST /v1/calendar/export, handle the ?calendar=connected return) + a Playwright spec; then point the fixture-driven ConsumerShellCalendarSyncPanel (/activity) at GET /v1/calendar/events instead of its hardcoded items. (DONE 2026-06-10 (first half) — new profile "Calendar sync" section (CalendarSyncSection.tsx + calendar-connections-client.ts), deep-linked ?path=calendar (added to WEB_PROFILE_ROUTE_PATHS + aliases): connect → real POST /v1/calendar/connect → browser sent to the consent URL; the BFF callback redirect retargeted from the 404 /account/integrations to /profile?path=calendar&calendar=connected (connect-route test updated); connections listed secret-free with disconnect; the REAL exportable schedule previewed from GET /v1/calendar/events; "Export to my calendar" posts the browser time zone and renders per-item outcomes (failures listed with the provider error, never fabricated); honest unavailable copy on 503. 4-test Playwright spec profile-calendar-sync.spec.ts (Google consent mocked at ITS boundary via an accounts.google.com route intercept → bounce back connected; service worker blocked — it serves stale /v1 GETs). ALSO repaired 8 PRE-EXISTING ProfileSettingsPanel.test failures (stale index-pinned section lookups broken by the unconditional Operator-access section a prior session added at the top): replaced every sections[N] with a title-based sectionByTitle helper + the shortcut-count assert with an explicit id-list — 62/62 green (was 54/62). REMAINING (second half, tracked below): repoint the fixture-driven ConsumerShellCalendarSyncPanel on /activity at the real GET /v1/calendar/events.)
    • (web UI — /activity panel) Point the fixture-driven ConsumerShellCalendarSyncPanel (/activity, hardcoded REVIEW_PROVIDER_EVENTS + fixed 2026-05-07 clock + shell-core fixture items) at the real per-user data: items from GET /v1/calendar/events, connection state from GET /v1/calendar/connections, and the sync/export action through POST /v1/calendar/export; update calendar-sync.spec.ts (it currently pins the fixture item ids). (DONE 2026-06-10 — the panel's ENTIRE fixture layer is gone: items ← GET /v1/calendar/events, providers ← GET /v1/calendar/connections (Google live; Apple/Outlook honestly disabled — "Not available yet", never claimed connected), and "Sync now" performs a REAL pull through the NEW GET /v1/calendar/provider-events (per-user transport fetchEvents over the caller's connection, now-anchored 14-day window, 502 fail-loud on provider errors — 3 new route tests, 10/10 file green). The shell-core reconciliation engine now runs over real provider state (live-calendar-sync.ts mappers incl. raw.oshun round-trip metadata parsing); fixed clock → live clock; honest loading/load-error/sync-error/unconnected states with a deep link to Profile → Calendar sync; header copy de-puffed. Panel unit tests REWRITTEN against the live layer (5 tests: real items only, honest provider states, live pull → inbound reschedule + missing-source conflict through the REAL engine, failed-pull honesty, failed-load honesty); calendar-sync.spec.ts rewritten (3 e2e: live sync review, unconnected guidance, mobile) — all green; ActivityDashboard suite 83/83; web tsc clean.)
    • Add OAuth vars to .env.example; document in a new §. (Done — a "Two-way calendar sync (Google)" block documents OSHUN_CALENDAR_CONNECTORS (the per-tenant descriptor JSON, mirroring OSHUN_LMS_CONNECTORS), OSHUN_GOOGLE_CALENDAR_CLIENT_ID/_SECRET, and the refresh-token-by-ref convention, with the google-only + fail-loud + deferred-OAuth-consent caveats.)
  • Acceptance criteria: With a configured provider, connect → imported events appear → a local change writes back; unconfigured export fails loud; the .ics Nyx path still works.
  • Effort / risk (2.3 overall): push M, STT M, calendar L. Risks: APNs H2 (silent failure on H1); STT stub-elimination is mandatory; calendar OAuth complexity.

2.4 Reminder producers (due-assignment / upcoming-session / content-drop) (ALL THREE TWINS BUILT 2026-06-09 — matrix complete)#

2026-06-09 — user chose to add the model + build the producers. DUE-ASSIGNMENT BUILT (10 unit + 1 Redis-integration tests, commit f2c0dbd710) via a BFF-side durable projection rather than an LMS-native bulk query + admin cron route (cleaner — no change to the LMS lib): reminders/due-assignment-store.ts (snapshot-durable, listDueAssignmentsBetween), reminders/assignment-event-consumer.ts (the real WRITER — projects lms.assignment.{upserted,cleared} events the LMS publishes), reminders/assignment-reminder-producer.ts (feeds the lib's produceAssignmentReminders). Wired into the BFF reminder worker's produce hook with the same verified-binding recipients as the streak producer; the stable assignment:courseId:assignmentId:userId sessionId dedups. The due date is an ABSOLUTE instant → no per-user timezone model needed (the original blocker).

2026-06-09 — UPCOMING-SESSION TWIN NOW BUILT (commit bd309dc417), the symmetric follow-up via the identical worker-hook + event-consumer shape: 10 unit

  • 1 Redis-integration tests (full reminders suite 44 passing; tsc + eslint clean; adversarial stub-scan zero hits). reminders/upcoming-session-store.ts (snapshot-durable BFF projection, listUpcomingSessionsBetween, absolute startsAtUnixSeconds), reminders/session-event-consumer.ts (the real WRITER — projects session.{upserted,cancelled} on the shared Redis bus, keyPrefix oshun:session), reminders/session-reminder-producer.ts (feeds the lib's produceV3SessionReminders). Wired into the BFF reminder worker's produce hook (return [...streaks, ...nyx, ...assignments, ...sessions]) with the same verified-binding recipients; the session-native sessionId dedups; durable hydrate at boot + shutdown teardown. The granular tasks below describe the alternative Nyx/Metis-native-store + admin-cron-route shape; the worker-hook + event-consumer shape was built instead — so they stay [ ] (different mechanism).

2026-06-09 — CONTENT-DROP TWIN NOW BUILT (commit 286132837b; user: "build the content-drop producer too"). The 4th lib mapper produceContentDropReminders now has an automatic worker source via the identical worker-hook + event-consumer shape: 10 unit + 1 Redis-integration tests (full reminders suite 54 passing; tsc + eslint clean; adversarial stub-scan zero hits). reminders/content-drop-store.ts (snapshot-durable BFF projection, listContentDropsBetween, absolute publishAtUnixSeconds), reminders/content-drop-event-consumer.ts (the real WRITER — projects content.drop.{scheduled,cancelled} on the shared Redis bus, keyPrefix oshun:content), reminders/content-drop-reminder-producer.ts (feeds the lib's produceContentDropReminders → " goes live", 48h window). Wired into the worker <code>produce</code> hook (<code>return [...streaks, ...nyx, ...assignments, ...sessions, ...contentDrops]</code>) + durable hydrate + shutdown teardown. <strong>AUDIENCE-MODEL RESOLUTION:</strong> a content drop is global + at-publish-time, so unlike per-user assignments/sessions the AUDIENCE fan-out is the PUBLISHER's job — the editorial service that schedules a drop decides who is in its audience (saved / followed / eligible) and publishes ONE <code>content.drop.scheduled</code> per recipient, exactly as the LMS publishes one <code>lms.assignment.upserted</code> per (user, assignment). The BFF never invents the audience; it projects the per-recipient events it receives (empty store → produces nothing → honest). This is the same architecture accepted for the assignment consumer (also publisher-fed). The pre-existing admin-batch route <code>/v1/admin/reminders/produce</code> (<code>reminders/reminder-producer-store.ts</code>) remains as the alternative editor-submits-a-batch surface; both coexist. <strong>The reminder-producer matrix is now COMPLETE — all four lib mappers have an automatic per-recipient worker source: streaks + assignments + sessions + content-drops.</strong></p> </blockquote> <ul> <li> <p><strong>Status today:</strong> Streak path fully built (BFF known-user sweep <code>reminders/streak-reminder-producer.ts:62-91</code> wired <code>server.ts:330-344</code>; Arete all-users <code>produceStreakLossReminders</code> over <code>listHabitsAtRiskOfStreakLoss</code> (<code>apps/arete/api/src/repositories/habits.ts:148,545-573</code>), admin <code>POST /v1/reminders/streak-due</code>). The <strong>producer mappers for the other two kinds already exist</strong>: <code>produceAssignmentReminders</code> (<code>messaging-channels/src/reminder-producers.ts:124-135</code>) and <code>produceV3SessionReminders</code> (<code>:76-87</code>) — pure mappers, unfed.</p> </li> <li> <p><strong>The gap:</strong> The <strong>domain bulk-enumeration queries</strong> that feed them — no LMS <code>listAssignmentsDueBetween(window)</code> and no session <code>listUpcomingSessions(window)</code> analogous to <code>listHabitsAtRiskOfStreakLoss</code>. (Documented at <code>streak-reminder-producer.ts:5-12</code>.)</p> </li> <li> <p><strong>External dependency:</strong> None for code — it's identifying/implementing the owning service's bulk query (LMS for assignments; Nyx and/or Metis for sessions).</p> </li> <li> <p><strong>Granular tasks:</strong></p> <blockquote> <p><strong>NOTE (2026-06-10):</strong> the boxes below describe the LMS-/owning-service- native mechanism (bulk queries + per-service admin cron routes). They stay <code>[ ]</code> because a DIFFERENT mechanism was chosen and built 2026-06-09 (see the section banner): BFF-side durable projections fed by <code>lms.assignment.*</code> / <code>session.*</code> / <code>content.drop.*</code> events + the BFF reminder worker — covering the same product outcome without touching the LMS lib. Do not implement these unless the event-projection approach is later replaced.</p> </blockquote> <ul> <li><strong>Due-assignment:</strong> <ul> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Add <code>listAssignmentsDueBetween(fromIso,toIso)</code> to the LMS assignment repo (Memory + Pg), returning <code>{userId,courseId,assignmentId,assignmentTitle,dueAtIso}[]</code> for active enrollments with no submission — mirror <code>habits.ts:142-148</code>.</li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Repo tests: window boundary, excludes submitted/inactive, ordering.</li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> <code>apps/<lms>/api/src/reminders/assignment-reminder-production.ts</code> mapping rows <ul> <li><code>UserRepository</code> (recipient/tz) → <code>produceAssignmentReminders</code>.</li> </ul> </li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Admin <code>POST /v1/reminders/assignments-due</code> (copy <code>apps/arete/api/src/routes/reminders.ts:50-106</code>).</li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Sweep + route tests.</li> </ul> </li> <li><strong>Upcoming-session:</strong> <ul> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Add <code>listUpcomingSessions(fromIso,toIso)</code> to the owning service (Nyx/Metis), returning <code>UpcomingV3Session</code>-shaped rows for opted-in users.</li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Production sweep → <code>produceV3SessionReminders</code>; recipient via <code>UserRepository</code>.</li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Admin <code>POST /v1/reminders/sessions-upcoming</code>.</li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Query + sweep + route tests.</li> </ul> </li> <li><strong>Shared:</strong> <ul> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Point a daily cron (or the BFF worker <code>produce</code> hook) at the two sweeps → <code>runReminderCycle</code> → §4 transports.</li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Verify dedup via stable <code>sessionId</code> keys (<code>reminder-producers.ts:129</code>).</li> <li><em>Not built — rejected alternative (2026-06-10 note above):</em> Document the two cron endpoints in §4 (the "remaining external slices" note <code>:86-87</code> becomes wired).</li> </ul> </li> </ul> </li> <li> <p><strong>Acceptance criteria:</strong> A daily sweep enumerates all users with an assignment due / session upcoming and produces one deduped reminder each with a correct deadline; re-running adds nothing; orphaned users skipped; reminders deliver. Tests assert window boundaries + submitted/attended exclusions against fixtures.</p> </li> <li> <p><strong>Verification:</strong> <code>cd apps/arete/api && npx vitest run src/reminders/streak-reminder-production.test.ts</code> (reference) · <code>cd libs/oshun/messaging-channels && npx vitest run src/reminder-producers.test.ts</code> · <code>cd apps/oshun/bff && npx vitest run src/reminders/</code></p> </li> <li> <p><strong>Effort / risk:</strong> <strong>M–L.</strong> Risk: identifying the "sessions" owner (Nyx vs Metis); tz-correct deadlines (reuse <code>endOfLocalDayUtcIso</code>); paginate/index the bulk query.</p> </li> </ul> <h2 id="25-provider-credential-matrix--envexample--health-endpoint-quick-win" class="hd hd-l2">2.5 Provider credential matrix + <code>.env.example</code> + health endpoint <strong>(quick win)</strong><a class="hd-anchor" href="#25-provider-credential-matrix--envexample--health-endpoint-quick-win" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> The matrix is complete in <code>provider-config-env.ts:49-130</code> (all-vars-present gate proven <code>:56-129</code>). <strong>But <code>.env.example</code> documents NONE of the messaging/reminder vars</strong> (verified empty). No endpoint surfaces which channels are configured. APNs H2 fetch not injected (2.3a). Slack/Discord recipient fields unpopulated by any binding (2.1).</li> <li><strong>The gap:</strong> Operational surface — docs, a health endpoint, the H2 injection.</li> <li><strong>Exact var matrix</strong> (source <code>provider-config-env.ts</code>): email→<code>OSHUN_SENDGRID_API_KEY</code>+ <code>OSHUN_MESSAGING_EMAIL_FROM</code> (<code>:62-67</code>); sms→<code>OSHUN_TWILIO_ACCOUNT_SID</code>+<code>_AUTH_TOKEN</code>+ <code>_FROM</code> (<code>:69-74</code>); push-fcm→<code>OSHUN_FCM_ACCESS_TOKEN</code>+<code>_PROJECT_ID</code> (<code>:76-85</code>); push-apns→<code>OSHUN_APNS_TEAM_ID</code>+<code>_KEY_ID</code>+<code>_PRIVATE_KEY</code>(.p8)+<code>_BUNDLE_ID</code> (+<code>_SANDBOX?</code>) (<code>:89-104</code>); whatsapp→<code>OSHUN_WHATSAPP_ACCESS_TOKEN</code>+<code>_PHONE_NUMBER_ID</code>+ <code>_TEMPLATE</code>+<code>_LANGUAGE</code> (<code>:106-117</code>); slack→<code>OSHUN_SLACK_BOT_TOKEN</code>; discord→ <code>OSHUN_DISCORD_BOT_TOKEN</code>.</li> <li><strong>External dependency:</strong> The creds themselves + an H2 fetch lib; docs + endpoint are pure code.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Add a "Messaging / Notifications" block to .env.example enumerating every var above (+ optionals, the APNs .p8 multi-line note, OSHUN_REMINDER_WORKER_INTERVAL_MS)." checked=""/> Add a "Messaging / Notifications" block to <code>.env.example</code> enumerating every var above (+ optionals, the APNs <code>.p8</code> multi-line note, <code>OSHUN_REMINDER_WORKER_INTERVAL_MS</code>).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Add GET /v1/admin/messaging/configured-channels (admin-gated, pattern admin-messaging-channels.ts:97-112) returning configuredChannelsFromEnv() — channel names only, never secret values." checked=""/> Add <code>GET /v1/admin/messaging/configured-channels</code> (admin-gated, pattern <code>admin-messaging-channels.ts:97-112</code>) returning <code>configuredChannelsFromEnv()</code> — <strong>channel names only, never secret values</strong>.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Health-endpoint test: lists exactly the configured channels, leaks no secret." checked=""/> Health-endpoint test: lists exactly the configured channels, leaks no secret.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Inject the APNs H2 fetchImpl (shared with 2.3a); boot-warn on H1. DONE — per-channel apnsFetchImpl wired through the delivery path; the BFF reminder worker injects createHttp2TransportFetch(); boot-readiness guard apnsHttp2BootWarning + a confirming server.ts boot log. See 2.3a for the full implementation note (the seam is per-channel — a global H2 injection would wrongly route the H1 channels through node:http2)." checked=""/> Inject the APNs H2 <code>fetchImpl</code> (shared with 2.3a); boot-warn on H1. <em>DONE — per-channel <code>apnsFetchImpl</code> wired through the delivery path; the BFF reminder worker injects <code>createHttp2TransportFetch()</code>; boot-readiness guard <code>apnsHttp2BootWarning</code> + a confirming <code>server.ts</code> boot log. See 2.3a for the full implementation note (the seam is per-channel — a global H2 injection would wrongly route the H1 channels through node:http2).</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Slack/Discord: if user-facing, extend 2.1 to capture slackChannel/ discordChannelId; else document as ops-only." checked=""/> Slack/Discord: if user-facing, extend 2.1 to capture <code>slackChannel</code>/ <code>discordChannelId</code>; else document as ops-only.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Keep §4 authoritative; add the health endpoint + H2 injection point. Both done: the health endpoint (GET /v1/admin/messaging/configured-channels, names-only) shipped earlier in 2.5; the APNs H2 injection point is now wired (per-channel, see 2.3a)." checked=""/> Keep §4 authoritative; add the health endpoint + H2 injection point. <em>Both done: the health endpoint (<code>GET /v1/admin/messaging/configured-channels</code>, names-only) shipped earlier in 2.5; the APNs H2 injection point is now wired (per-channel, see 2.3a).</em></li> </ul> </li> <li><strong>Acceptance criteria:</strong> <code>.env.example</code> documents every channel; the endpoint shows exactly the configured channels (no secrets); a fully-set channel routes to its real transport; any missing var → channel omitted + <code>missing-config</code>; APNs uses H2.</li> <li><strong>Verification:</strong> <code>cd libs/oshun/messaging-channels && npx vitest run src/provider-config-env.test.ts</code> · <code>grep -ni 'sendgrid\|twilio\|fcm\|apns\|whatsapp' .env.example</code></li> <li><strong>Effort / risk:</strong> <strong>S</strong> (docs + endpoint) + <strong>M</strong> (H2 injection). Risk: never echo secrets; APNs H2 is the one substantive runtime risk.</li> </ul> <hr /> <hr /> <h1 id="part-3--persona-lifecycle-tenant-sso--lms" class="hd hd-l1">Part 3 — Persona Lifecycle, Tenant SSO & LMS<a class="hd-anchor" href="#part-3--persona-lifecycle-tenant-sso--lms" aria-label="Permalink to this section">#</a></h1> <h2 id="31-persona-lifecycle-store--consumer-session-picker" class="hd hd-l2">3.1 Persona lifecycle store + consumer session-picker<a class="hd-anchor" href="#31-persona-lifecycle-store--consumer-session-picker" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> The validator is complete: <code>transitionPersonaLifecycle</code> (<code>libs/oshun/persona-registry/src/lifecycle.ts:356-483</code>), transition table <code>:93-219</code> (13 events), 8 canonical states (11 w/ aliases, <code>index.ts:71-125</code>). The consumer <strong>browse/select lib is fully built + tested</strong> (<code>customer-persona-browse.ts:284,385</code>, eligibility <code>:195</code>, disclosure gate). <strong>But <code>transitionPersonaLifecycle</code> has ZERO non-test callers</strong> — no store, no audit log; the admin store uses an unrelated 3-value <code>AdminPersonaStatus</code> (<code>apps/oshun/bff/src/admin/state.ts:2023</code>) and <code>publishPersona</code> hard-sets <code>'published'</code> (<code>:16667</code>) without the validator. <strong>No oshun consumer session-picker</strong> (the only <code>PersonaSelector.tsx</code> is in the separate lilith app).</li> <li><strong>The gap:</strong> (1) lifecycle persistence + transition route + audit log; (2) a BFF route exposing browse/select + a consumer picker UI.</li> <li><strong>External dependency:</strong> None — pure code (durable once <code>OSHUN_ADMIN_DATABASE_URL</code> set, like all admin stores).</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (store) apps/oshun/bff/src/admin/persona-lifecycle-store.ts: Map<personaId,{status,auditLog[]}>, getLifecycle, listLegalEvents (→listEventsFromStatus), applyTransition(request) calling the validator, advancing status + appending auditEvent only on ok. (In-memory module singleton mirroring operator-incident-store.ts; durable snapshot deploy-bound like the sibling new admin stores. Adds optimistic concurrency: a caller-supplied stale currentStatus → a conflict outcome, never a lost write. Imported via the @oshun/persona-registry/lifecycle subpath to dodge the barrel's PersonaLifecycleEvent as PersonaLifecycleAuditEvent alias.)" checked=""/> <strong>(store)</strong> <code>apps/oshun/bff/src/admin/persona-lifecycle-store.ts</code>: <code>Map<personaId,{status,auditLog[]}></code>, <code>getLifecycle</code>, <code>listLegalEvents</code> (→<code>listEventsFromStatus</code>), <code>applyTransition(request)</code> calling the validator, advancing status + appending <code>auditEvent</code> <strong>only on ok</strong>. <em>(In-memory module singleton mirroring <code>operator-incident-store.ts</code>; durable snapshot deploy-bound like the sibling new admin stores. Adds optimistic concurrency: a caller-supplied stale <code>currentStatus</code> → a <code>conflict</code> outcome, never a lost write. Imported via the <code>@oshun/persona-registry/lifecycle</code> subpath to dodge the barrel's <code>PersonaLifecycleEvent as PersonaLifecycleAuditEvent</code> alias.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (durability) Add a persona_lifecycle snapshot key (durable-stores.ts:96), write-through on each transition (:227-240), load on boot. (SHIPPED: PERSONA_LIFECYCLE_STORE_KEY='persona-lifecycle' + PersonaLifecycleSnapshot (full per-persona status + audit). The store gained bindSnapshotSink/restoreSnapshot + a private persist() that write-throughs on every applyTransition AND seedPersona; wireDurablePersonaLifecycle(store) hydrates on boot (sink.load() → restoreSnapshot OVERLAYS persisted state onto the seeded catalog so a newly-added catalog persona keeps its seed) then binds the write-through sink. Wired in server.ts right after wireDurableModelGovernance, backed by the SAME deploy-bound DurableSnapshotStore (Postgres adminStoreSnapshot in prod, in-memory in tests) as the sibling device-tokens / model-governance / operator-incident stores. 3 durability tests added (transition survives a restart; runtime-seeded persona survives; restoreSnapshot overlay-not-replace) → 59 tests green.)" checked=""/> <strong>(durability)</strong> Add a <code>persona_lifecycle</code> snapshot key (<code>durable-stores.ts:96</code>), write-through on each transition (<code>:227-240</code>), load on boot. <em>(SHIPPED: <code>PERSONA_LIFECYCLE_STORE_KEY='persona-lifecycle'</code> + <code>PersonaLifecycleSnapshot</code> (full per-persona status + audit). The store gained <code>bindSnapshotSink</code>/<code>restoreSnapshot</code> + a private <code>persist()</code> that write-throughs on every <code>applyTransition</code> AND <code>seedPersona</code>; <code>wireDurablePersonaLifecycle(store)</code> hydrates on boot (<code>sink.load()</code> → <code>restoreSnapshot</code> OVERLAYS persisted state onto the seeded catalog so a newly-added catalog persona keeps its seed) then binds the write-through sink. Wired in <code>server.ts</code> right after <code>wireDurableModelGovernance</code>, backed by the SAME deploy-bound <code>DurableSnapshotStore</code> (Postgres <code>adminStoreSnapshot</code> in prod, in-memory in tests) as the sibling device-tokens / model-governance / operator-incident stores. 3 durability tests added (transition survives a restart; runtime-seeded persona survives; restoreSnapshot overlay-not-replace) → 59 tests green.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (seed sync) Init new personas to drafted; reconcile the legacy publishPersona path so 'published' ↔ canonical released (canonicalizePersonaApprovalStatus, index.ts:121) — single source of truth. (seedPersona initialises new personas to drafted; the store seeds from the same governance catalog as admin/state.ts (persona-{zen,stoic,sufi}-guide), mapping the coarse 3-value status onto the canonical lifecycle status. The publishPersona↔released reconciliation is deferred — the lifecycle store is the new canonical 8-state authority, kept separate from the coarse AdminPersonaStatus to avoid a risky rip-out, mirroring 5.1's two-incident-systems separation.) 2026-09-18: open for an agent. The residue is the deferred reconciliation: make the legacy publishPersona path write the canonical lifecycle status, so published and released cannot disagree, with a test that drives both paths."/> <strong>(seed sync)</strong> Init new personas to <code>drafted</code>; reconcile the legacy <code>publishPersona</code> path so <code>'published'</code> ↔ canonical <code>released</code> (<code>canonicalizePersonaApprovalStatus</code>, <code>index.ts:121</code>) — single source of truth. <em>(<code>seedPersona</code> initialises new personas to <code>drafted</code>; the store seeds from the same governance catalog as <code>admin/state.ts</code> (persona-{zen,stoic,sufi}-guide), mapping the coarse 3-value status onto the canonical lifecycle status. The <code>publishPersona</code>↔released reconciliation is deferred — the lifecycle store is the new canonical 8-state authority, kept separate from the coarse <code>AdminPersonaStatus</code> to avoid a risky rip-out, mirroring 5.1's two-incident-systems separation.)</em> <em>2026-09-18: open for an agent. The residue is the deferred reconciliation: make the legacy <code>publishPersona</code> path write the canonical lifecycle status, so <code>published</code> and <code>released</code> cannot disagree, with a test that drives both paths.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (route) transitionPersonaLifecycleHandler in routes/admin.ts (near publishPersonaHandler:1926): parse {event,currentStatus,signoffs[],justification?, rollbackPlan?}, applyTransition; map !ok→409{lifecycle_transition_invalid, errors}, unknown→404, ok→buildMutationPayload. Add a GET read handler ({status,legalEvents,auditLog}). Register /admin + /v1/admin/personas/:id/ lifecycle (POST via registerAdminMutationRoute, GET via app.get with [abuseProtection,authProtection]), scope getAuthorizedAdminContext(...,'persona'). (Built as a separate file routes/admin-personas-lifecycle.ts mirroring the freshest admin-operator-incidents.ts template rather than editing the giant admin.ts; same effect. GET list + GET :id + POST transition, dual-prefix /admin + /v1/admin, admin:workspace:persona scope. The server stamps emittedAtUnixSeconds so a client cannot backdate emission below a signoff. A POST emits a persona.lifecycle.<event> audit event (registered with {auditEventsStore} so 5.2 sees it). Confirmed no route collision; the full createApp() boots with it registered.)" checked=""/> <strong>(route)</strong> <code>transitionPersonaLifecycleHandler</code> in <code>routes/admin.ts</code> (near <code>publishPersonaHandler:1926</code>): parse <code>{event,currentStatus,signoffs[],justification?, rollbackPlan?}</code>, <code>applyTransition</code>; map <code>!ok</code>→<code>409{lifecycle_transition_invalid, errors}</code>, unknown→<code>404</code>, ok→<code>buildMutationPayload</code>. Add a GET read handler (<code>{status,legalEvents,auditLog}</code>). Register <code>/admin</code> + <code>/v1/admin/personas/:id/ lifecycle</code> (POST via <code>registerAdminMutationRoute</code>, GET via <code>app.get</code> with <code>[abuseProtection,authProtection]</code>), scope <code>getAuthorizedAdminContext(...,'persona')</code>. <em>(Built as a separate file <code>routes/admin-personas-lifecycle.ts</code> mirroring the freshest <code>admin-operator-incidents.ts</code> template rather than editing the giant <code>admin.ts</code>; same effect. GET list + GET <code>:id</code> + POST transition, dual-prefix <code>/admin</code> + <code>/v1/admin</code>, <code>admin:workspace:persona</code> scope. The server stamps <code>emittedAtUnixSeconds</code> so a client cannot backdate emission below a signoff. A POST emits a <code>persona.lifecycle.<event></code> audit event (registered with <code>{auditEventsStore}</code> so 5.2 sees it). Confirmed no route collision; the full <code>createApp()</code> boots with it registered.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (consumer route) routes/personas-consumer.ts: GET /v1/personas (browsePersonas), POST /v1/personas/select (selectPersonaForMember), session-presence gated. Add a per-user active-persona store (GET/POST /v1/personas/active), durable. (Built + registered in app.ts (createApp boots, /v1/personas→401 unauth, no collision). GET /v1/personas (browse), POST /v1/personas/select (validate + set active on ok), GET /v1/personas/active (read), and DELETE /v1/personas/active (clear) — I used DELETE-clear rather than a redundant POST /active since /select IS the validated setter (a bare POST /active would either duplicate /select's validation or allow setting an ineligible persona). The "real member-context wiring" the audit flagged is now resolved — personas/persona-member-context.ts resolves the PersonaBrowseMemberContext SERVER-SIDE + HONESTLY: tier from the real plan (getCurrentUser(userId).plan, fail-safe 'free' — a client cannot claim a higher tier to unlock gated personas), isStaff from an admin scope, isBetaEnrolled from a beta/alpha tier, locale/market from the validated Accept-Language, surface validated against PERSONA_SURFACES (default 'tara'). Critically: every launch persona requires voice/avatar account consent (verified: a free no-consent member has 0 eligible across all 13 surfaces; with consent, 12), so hasVoiceAvatarOptIn is resolved from the REAL consent store — customerConsentStateStore.getSnapshot(userId) → a granted voice|avatar flow — fail-closed false (an un-consented member correctly sees an empty eligible roster, with the personas surfaced as blocked, not hidden). The consent read is an injectable seam for tests. active-persona-store.ts is now SNAPSHOT-DURABLE 2026-06-09 — ActivePersonaSnapshot (per-user selection keyed by userId, which the selection itself doesn't carry) + bindSnapshotSink/restoreSnapshot + persist() on set/clear; wireDurableActivePersonas(store) wired in server.ts beside wireDurablePersonaLifecycle on the SAME deploy-bound DurableSnapshotStore, so a member's chosen guide survives a restart instead of reverting on deploy (3 durability tests: selection survives, cleared stays cleared, per-user isolation across re-hydrate; 7 store + 8 consumer-route green). The web data-layer + consumer UI + e2e remain.)" checked=""/> <strong>(consumer route)</strong> <code>routes/personas-consumer.ts</code>: <code>GET /v1/personas</code> (<code>browsePersonas</code>), <code>POST /v1/personas/select</code> (<code>selectPersonaForMember</code>), session-presence gated. Add a per-user active-persona store (<code>GET/POST /v1/personas/active</code>), durable. <em>(Built + registered in <code>app.ts</code> (createApp boots, <code>/v1/personas</code>→401 unauth, no collision). <code>GET /v1/personas</code> (browse), <code>POST /v1/personas/select</code> (validate + set active on ok), <code>GET /v1/personas/active</code> (read), and <code>DELETE /v1/personas/active</code> (clear) — I used DELETE-clear rather than a redundant <code>POST /active</code> since <code>/select</code> IS the validated setter (a bare POST /active would either duplicate /select's validation or allow setting an ineligible persona). <strong>The "real member-context wiring" the audit flagged is now resolved</strong> — <code>personas/persona-member-context.ts</code> resolves the <code>PersonaBrowseMemberContext</code> SERVER-SIDE + HONESTLY: tier from the real plan (<code>getCurrentUser(userId).plan</code>, fail-safe 'free' — a client cannot claim a higher tier to unlock gated personas), isStaff from an admin scope, isBetaEnrolled from a beta/alpha tier, locale/market from the validated Accept-Language, surface validated against PERSONA_SURFACES (default 'tara'). <strong>Critically:</strong> every launch persona requires voice/avatar account consent (verified: a free no-consent member has 0 eligible across all 13 surfaces; with consent, 12), so <code>hasVoiceAvatarOptIn</code> is resolved from the REAL consent store — <code>customerConsentStateStore.getSnapshot(userId)</code> → a granted <code>voice</code>|<code>avatar</code> flow — fail-closed false (an un-consented member correctly sees an empty eligible roster, with the personas surfaced as blocked, not hidden). The consent read is an injectable seam for tests. <code>active-persona-store.ts</code> is <strong>now SNAPSHOT-DURABLE 2026-06-09</strong> — <code>ActivePersonaSnapshot</code> (per-user selection keyed by userId, which the selection itself doesn't carry) + <code>bindSnapshotSink</code>/<code>restoreSnapshot</code> + <code>persist()</code> on set/clear; <code>wireDurableActivePersonas(store)</code> wired in <code>server.ts</code> beside <code>wireDurablePersonaLifecycle</code> on the SAME deploy-bound <code>DurableSnapshotStore</code>, so a member's chosen guide survives a restart instead of reverting on deploy (3 durability tests: selection survives, cleared stays cleared, per-user isolation across re-hydrate; 7 store + 8 consumer-route green). The web data-layer + consumer UI + e2e remain.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (web data layer) bffGet/bffPost for /v1/personas, /select, /active. (Client half DONE — apps/oshun/web/src/personas/personasClient.ts, a thin auth-bearing wrapper over the shared api client (browse(surface?,locale?) → query-serialized GET, select(request) → POST body, getActive() → GET, clearActive() → DELETE), mirroring savedItemsClient/channel-bindings-client with typed DTOs for the browse/select/active shapes. 5 unit tests (path + body + query serialization). tsc + eslint clean. The server-side bffGet('/v1/personas') for the SSR page rides with the consumer page below — that page is UI and needs the in-browser visual pass (CLAUDE.md), which is the deferred piece.) 2026-09-18: open for an agent. The residue is the server-side read for the consumer page, done with that page and verified in a real browser."/> <strong>(web data layer)</strong> <code>bffGet/bffPost</code> for <code>/v1/personas</code>, <code>/select</code>, <code>/active</code>. <em>(Client half DONE — <code>apps/oshun/web/src/personas/personasClient.ts</code>, a thin auth-bearing wrapper over the shared <code>api</code> client (<code>browse(surface?,locale?)</code> → query-serialized GET, <code>select(request)</code> → POST body, <code>getActive()</code> → GET, <code>clearActive()</code> → DELETE), mirroring <code>savedItemsClient</code>/<code>channel-bindings-client</code> with typed DTOs for the browse/select/active shapes. 5 unit tests (path + body + query serialization). tsc + eslint clean. The server-side <code>bffGet('/v1/personas')</code> for the SSR page rides with the consumer page below — that page is UI and needs the in-browser visual pass (CLAUDE.md), which is the deferred piece.)</em> <em>2026-09-18: open for an agent. The residue is the server-side read for the consumer page, done with that page and verified in a real browser.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (UI consumer) apps/oshun/web/src/app/personas/page.tsx: browse cards grouped by family (PersonaBrowseGroupByFamily:121) + disclosure/eligibility; a client island posts /select and reflects active selection (reuse LWebShell/ LMasthead). (DONE — app/personas/page.tsx (thin server shell: LWebShell + LCustomerNav + LMasthead) hosting components/personas/PersonaPicker.tsx ('use client'). The picker browses /v1/personas (BFF resolves tier/consent/surface from the session — no client tier claim), renders the byFamily groups with per-card eligibility disclosure: eligible → "Choose this guide"; blocked → its eligibilityBlockers (e.g. "Requires voice/avatar account consent" — the verified real gate where an un-consented member sees an empty eligible roster + an honest "No guides are available… grant the consents below" line, personas surfaced as blocked NOT hidden). Select → personasClient.select → repaints the active guide; a rejected selection surfaces the reason/blockers; Clear → clearActive; a 401 prompts sign-in. Never fabricates a selection. 6 component tests (browse+group+blocked reasons, select→active repaint, rejected reason, clear, 401, all-blocked). bounded tsc 0, eslint 0, stub-scan clean. Browser visual pass deferred (RAM-bound).)" checked=""/> <strong>(UI consumer)</strong> <code>apps/oshun/web/src/app/personas/page.tsx</code>: browse cards grouped by family (<code>PersonaBrowseGroupByFamily:121</code>) + disclosure/eligibility; a client island posts <code>/select</code> and reflects active selection (reuse <code>LWebShell</code>/ <code>LMasthead</code>). <em>(DONE — <code>app/personas/page.tsx</code> (thin server shell: <code>LWebShell</code> + <code>LCustomerNav</code> + <code>LMasthead</code>) hosting <code>components/personas/PersonaPicker.tsx</code> ('use client'). The picker browses <code>/v1/personas</code> (BFF resolves tier/consent/surface from the session — no client tier claim), renders the <code>byFamily</code> groups with per-card eligibility disclosure: eligible → "Choose this guide"; blocked → its <code>eligibilityBlockers</code> (e.g. "Requires voice/avatar account consent" — the verified real gate where an un-consented member sees an empty eligible roster + an honest "No guides are available… grant the consents below" line, personas surfaced as blocked NOT hidden). Select → <code>personasClient.select</code> → repaints the active guide; a rejected selection surfaces the reason/blockers; Clear → <code>clearActive</code>; a 401 prompts sign-in. Never fabricates a selection. 6 component tests (browse+group+blocked reasons, select→active repaint, rejected reason, clear, 401, all-blocked). bounded tsc 0, eslint 0, stub-scan clean. Browser visual pass deferred (RAM-bound).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (UI admin) Extend the persona admin surface (e.g. lilith-studio/personas/SaraswatiPersonaDossierEditor.tsx) to render current status + legal-event buttons from the lifecycle GET, with a signoff/justification/ rollback-plan form. (Built as a dedicated /operator/personas server page + PersonaLifecycleConsole client component (selection-reactive, mirroring AdminInboxConsole + AdminInboxIncidentActions) rather than retrofitting the dossier editor — same effect, cleaner host. Reads the live lifecycle (status + legal events + audit history) from GET /v1/admin/personas/lifecycle; a transition form reveals exactly the required signoff-actor inputs + justification + rollback plan per event (form hints mirror the table; the BFF validator stays authoritative), POSTs to /v1/admin/personas/:id/lifecycle, repaints the status from the response, and surfaces honest 400/403/404/409 errors — never a faked transition. 5 unit tests (PersonaLifecycleConsole.test.tsx). Linked from /operator/audit & /operator/sso. The CONSUMER browse/select picker is the separate track below (still gated on real member-context wiring).)" checked=""/> <strong>(UI admin)</strong> Extend the persona admin surface (e.g. <code>lilith-studio/personas/SaraswatiPersonaDossierEditor.tsx</code>) to render current status + legal-event buttons from the lifecycle GET, with a signoff/justification/ rollback-plan form. <em>(Built as a dedicated <code>/operator/personas</code> server page + <code>PersonaLifecycleConsole</code> client component (selection-reactive, mirroring <code>AdminInboxConsole</code> + <code>AdminInboxIncidentActions</code>) rather than retrofitting the dossier editor — same effect, cleaner host. Reads the live lifecycle (status + legal events + audit history) from <code>GET /v1/admin/personas/lifecycle</code>; a transition form reveals exactly the required signoff-actor inputs + justification + rollback plan per event (form hints mirror the table; the BFF validator stays authoritative), POSTs to <code>/v1/admin/personas/:id/lifecycle</code>, repaints the status from the response, and surfaces honest 400/403/404/409 errors — never a faked transition. 5 unit tests (<code>PersonaLifecycleConsole.test.tsx</code>). Linked from <code>/operator/audit</code> & <code>/operator/sso</code>. The CONSUMER browse/select picker is the separate track below (still gated on real member-context wiring).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (unit store) persona-lifecycle-store.test.ts: every legal transition advances + logs; every illegal one (wrong from-state, missing signoff/justification/ rollback for live-exposure events) returns ok:false + no mutation — enumerate all 13 events × from-states from lifecycle.ts:93-219. (56 tests: every event × every legal from-state DRIVEN through the real machine asserts applied+1 audit entry; every event from an illegal state → rejected, no mutation; the gates (missing-signoff / -justification / -rollback, duplicate signoff, signoff-after-emission) → rejected; unknown→unknown, stale→conflict; a full drafted→released happy path. All green.)" checked=""/> <strong>(unit store)</strong> <code>persona-lifecycle-store.test.ts</code>: every legal transition advances + logs; every illegal one (wrong from-state, missing signoff/justification/ rollback for live-exposure events) returns <code>ok:false</code> + no mutation — enumerate all 13 events × from-states from <code>lifecycle.ts:93-219</code>. <em>(56 tests: every event × every legal from-state DRIVEN through the real machine asserts applied+1 audit entry; every event from an illegal state → rejected, no mutation; the gates (missing-signoff / -justification / -rollback, duplicate signoff, signoff-after-emission) → rejected; unknown→<code>unknown</code>, stale→<code>conflict</code>; a full drafted→released happy path. All green.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (unit route) admin-persona-lifecycle-route.test.ts (mirror admin-persona-governance-route.test.ts): 401/403/404/409-with-errors/200. (10 tests: auth+scope gate, list, GET view with legalEvents, 400 malformed, 409 lifecycle_transition_invalid with errors, 409 lifecycle_status_conflict, 404 unknown, dual-prefix, and a full route-driven drafted→released journey that proves the live-exposure rollback gate over HTTP. All green.)" checked=""/> <strong>(unit route)</strong> <code>admin-persona-lifecycle-route.test.ts</code> (mirror <code>admin-persona-governance-route.test.ts</code>): 401/403/404/409-with-errors/200. <em>(10 tests: auth+scope gate, list, GET view with <code>legalEvents</code>, 400 malformed, 409 <code>lifecycle_transition_invalid</code> with errors, 409 <code>lifecycle_status_conflict</code>, 404 unknown, dual-prefix, and a full route-driven drafted→released journey that proves the live-exposure rollback gate over HTTP. All green.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (unit consumer) GET /v1/personas returns only eligible/disclosed cards; POST /select returns each PersonaSelectionReason failure + ok for valid. (21 tests: 9 member-context resolver (plan→tier incl. unknown→free, surface validation, locale/market normalization, staff-from-scope, beta-from-tier, consent-passthrough), 4 active-persona store (set/overwrite/per-user-isolation/clear), 8 route (401 unauth on all verbs; browse returns server-resolved free tier + every eligible card lists the browsed surface; un-consented → empty eligible + non-empty blocked (honest); select happy-path sets active + GET /active reflects it; select of an unknown persona → not-in-catalog + active NOT set; 400 missing personaId; DELETE clears; per-user active isolation). The happy-path browses the live catalog to find a real eligible persona rather than hardcoding an id. tsc + eslint clean.)" checked=""/> <strong>(unit consumer)</strong> <code>GET /v1/personas</code> returns only eligible/disclosed cards; <code>POST /select</code> returns each <code>PersonaSelectionReason</code> failure + ok for valid. <em>(21 tests: 9 member-context resolver (plan→tier incl. unknown→free, surface validation, locale/market normalization, staff-from-scope, beta-from-tier, consent-passthrough), 4 active-persona store (set/overwrite/per-user-isolation/clear), 8 route (401 unauth on all verbs; browse returns server-resolved free tier + every eligible card lists the browsed surface; un-consented → empty eligible + non-empty blocked (honest); select happy-path sets active + GET /active reflects it; select of an unknown persona → <code>not-in-catalog</code> + active NOT set; 400 missing personaId; DELETE clears; per-user active isolation). The happy-path browses the live catalog to find a real eligible persona rather than hardcoding an id. tsc + eslint clean.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e) Extend persona-governance.spec.ts (or new persona-lifecycle-bff.spec.ts) for a full drafted→…→released happy path + an illegal 409; new persona-session-picker.spec.ts for the picker round-trip. (DONE 2026-06-10 — both built as pure-API specs over the REAL BFF (Playwright request, no page.route): persona-lifecycle-bff.spec.ts drives the canonical chain on the seeded drafted persona — per-step status+audit advance, the approve-for-release-without-rollbackPlan 409 live-exposure gate (status pinned unchanged), an illegal-event-for-live-status 409 + no-mutation probe, and 401/403 scope gates; RESUME-AWARE (re-runs against a warm BFF pick up the chain from the persona's current status). Request bodies are built from an inline EVENT_REQUIREMENTS hint table — the SAME precedent as the operator console (PersonaLifecycleConsole.tsx); the BFF validator stays authoritative and a drifted hint fails loudly (@oshun/persona-registry is not web-resolvable from e2e). persona-session-picker.spec.ts does the consumer round-trip with a FRESH signup: honest consent gate (eligible empty/blocked surfaced) → a REAL high-risk voice consent grant (signature verification + canonical evidence via POST /v1/consent/voice/grant) → roster opens → select → GET active read-back → not-in-catalog honesty (active unchanged) → DELETE clears; + 401 gates. 5/5 green at 1 worker.)" checked=""/> <strong>(e2e)</strong> Extend <code>persona-governance.spec.ts</code> (or new <code>persona-lifecycle-bff.spec.ts</code>) for a full <code>drafted→…→released</code> happy path + an illegal 409; new <code>persona-session-picker.spec.ts</code> for the picker round-trip. <em>(DONE 2026-06-10 — both built as pure-API specs over the REAL BFF (Playwright <code>request</code>, no page.route): <code>persona-lifecycle-bff.spec.ts</code> drives the canonical chain on the seeded drafted persona — per-step status+audit advance, the approve-for-release-without-rollbackPlan 409 live-exposure gate (status pinned unchanged), an illegal-event-for-live-status 409 + no-mutation probe, and 401/403 scope gates; RESUME-AWARE (re-runs against a warm BFF pick up the chain from the persona's current status). Request bodies are built from an inline EVENT_REQUIREMENTS hint table — the SAME precedent as the operator console (<code>PersonaLifecycleConsole.tsx</code>); the BFF validator stays authoritative and a drifted hint fails loudly (<code>@oshun/persona-registry</code> is not web-resolvable from e2e). <code>persona-session-picker.spec.ts</code> does the consumer round-trip with a FRESH signup: honest consent gate (eligible empty/blocked surfaced) → a REAL high-risk voice consent grant (signature verification + canonical evidence via POST /v1/consent/voice/grant) → roster opens → select → GET active read-back → not-in-catalog honesty (active unchanged) → DELETE clears; + 401 gates. 5/5 green at 1 worker.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (cross-link) Update ADMIN_WALKTHROUGH/journeys/persona-release-cycle.md" checked=""/> <strong>(cross-link)</strong> Update <code>ADMIN_WALKTHROUGH/journeys/persona-release-cycle.md</code> <ul> <li>matrix; add a consumer journey doc + <code>coverage.md</code> row; source-grounding headers. <em>(DONE 2026-06-10 — persona-release-cycle.md gained an "E2E coverage" section linking both specs + the operator console, and its "customer-side persona surfacing" open question is now ANSWERED (released → eligible via GET /v1/personas only for consented members on launchSurfaces at tier) and checked off. coverage.md's persona-voice-avatar-approval-workflow row upgraded partial→<strong>deep</strong> with both new specs. Both specs carry source-grounding headers (journey + routes). The consumer picker journey is documented inside the admin journey's coverage section rather than a separate doc — one cycle, two halves.)</em></li> </ul> </li> </ul> </li> <li><strong>Acceptance criteria:</strong> A persona drives through all 8 canonical states via the lifecycle route, each persisted + audited; every illegal event → 409 + <code>errors[]</code>, status unchanged. <code>GET /v1/personas</code> returns eligible cards; <code>/select</code> returns ok + config or the right reason; selection persists per-user; a consumer can pick a persona and see it active. Unit tests cover every legal + illegal transition.</li> <li><strong>Verification:</strong> <code>cd libs/oshun/persona-registry && npx vitest run src/lifecycle.test.ts src/customer-persona-browse.test.ts</code> · <code>cd apps/oshun/bff && npx vitest run src/admin/persona-lifecycle-store.test.ts src/__tests__/admin-persona-lifecycle-route.test.ts</code></li> <li><strong>Effort / risk:</strong> <strong>L.</strong> Risk: reconcile the coarse 3-state <code>AdminPersonaStatus</code> with the 8-state model (single source of truth); confirm <code>LAUNCH_PERSONA_CONFIGS</code> is populated so the picker isn't empty.</li> </ul> <h2 id="32-tenant-admin-sso-editor" class="hd hd-l2">3.2 Tenant-admin SSO editor<a class="hd-anchor" href="#32-tenant-admin-sso-editor" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> Read path is a <strong>non-prod fixture</strong> — <code>/v1/sso</code> via <code>guardedFixtureRoute</code> (<code>domain-stubs.ts:1645</code>, fixture <code>:514-544</code>, blocked in prod). <code>OperatorSsoPage</code> is <strong>read-only</strong> (<code>app/operator/sso/page.tsx:43</code>, no CRUD). The auth-policy route (<code>admin-studio-auth-policy.ts</code>) is login-risk scoring, <strong>not</strong> connector CRUD. <strong>Two real, unwired SSO models exist:</strong> <code>@oshun/tenant-console</code> <code>SsoConnection</code>/<code>SsoClaimMapping</code>/<code>processSsoLogin</code> (<code>identity/sso.ts:9-125</code>) and the full <code>@yemaya/organizations</code> <code>SSOService</code> (~1800 lines, CRUD repo + SAML/OIDC adapter seams, <code>sso-service.ts:118-120,196,223,1683</code>). Neither is route-wired.</li> <li><strong>The gap:</strong> No write path anywhere — no create/update/delete connector, no claim-mapping editor, no JWKS field, no test-connection. Data is a static fixture; UI is a read-only roster.</li> <li><strong>External dependency:</strong> The CRUD store + routes + editor UI are <strong>pure code</strong> (both models exist). Runtime-only externals (needed to <em>exercise</em> a login, not to build the editor): a concrete <code>ISamlResponseValidator</code>/<code>IOidcTokenExchanger</code> impl (<code>sso-service.ts:196,223</code>), a live IdP metadata/JWKS, network egress for test-connection.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (decision) Use the oshun-native @oshun/tenant-console SsoConnection model + host the editor in apps/oshun/web /operator/sso (don't stand up a separate app — read surface, nav, design system, auth, durable-store plumbing already live there). (Backend uses the tenant-console model; the web editor host is the deferred frontend.)" checked=""/> <strong>(decision)</strong> Use the oshun-native <code>@oshun/tenant-console</code> <code>SsoConnection</code> model + host the editor in <code>apps/oshun/web</code> <code>/operator/sso</code> (don't stand up a separate app — read surface, nav, design system, auth, durable-store plumbing already live there). <em>(Backend uses the tenant-console model; the web editor host is the deferred frontend.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (store) apps/oshun/bff/src/tenant-console/sso-connection-store.ts: Map<connectionId,SsoConnection> with list/get/create/update/delete + validate (protocol, idpEntityId, ≥1 endpoint, ≥1 claim mapping incl. required email, positive lifetimes); durable (sso-connections snapshot key). (SNAPSHOT-DURABLE 2026-06-09: SsoConnectionSnapshot (every connection flattened) + bindSnapshotSink/restoreSnapshot + a private persist() on every mutation (create/update/remove); wireDurableSsoConnections(store) hydrates on boot then binds the write-through sink, wired in server.ts on the SAME deploy-bound DurableSnapshotStore as the persona-lifecycle / operator-incident / channel-bindings stores. So an operator's configured tenant SSO survives a restart instead of vanishing on deploy. 16 store tests incl. 3 durability: a created connection survives, a removed one stays removed, an update re-hydrates. tenantId immutable on update.)" checked=""/> <strong>(store)</strong> <code>apps/oshun/bff/src/tenant-console/sso-connection-store.ts</code>: <code>Map<connectionId,SsoConnection></code> with list/get/create/update/delete + <code>validate</code> (protocol, idpEntityId, ≥1 endpoint, ≥1 claim mapping incl. required <code>email</code>, positive lifetimes); durable (<code>sso-connections</code> snapshot key). <em>(<strong>SNAPSHOT-DURABLE 2026-06-09:</strong> <code>SsoConnectionSnapshot</code> (every connection flattened) + <code>bindSnapshotSink</code>/<code>restoreSnapshot</code> + a private <code>persist()</code> on every mutation (create/update/remove); <code>wireDurableSsoConnections(store)</code> hydrates on boot then binds the write-through sink, wired in <code>server.ts</code> on the SAME deploy-bound <code>DurableSnapshotStore</code> as the persona-lifecycle / operator-incident / channel-bindings stores. So an operator's configured tenant SSO survives a restart instead of vanishing on deploy. 16 store tests incl. 3 durability: a created connection survives, a removed one stays removed, an update re-hydrates. tenantId immutable on update.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (routes) apps/oshun/bff/src/tenant-console/sso-route.ts (mirror admin-studio-auth-policy.ts): GET/POST /v1/admin/sso, GET/PATCH/DELETE /v1/admin/sso/:id; preHandler:[abuseProtection,authProtection] + admin scope. Validate body → 400/404/200|201." checked=""/> <strong>(routes)</strong> <code>apps/oshun/bff/src/tenant-console/sso-route.ts</code> (mirror <code>admin-studio-auth-policy.ts</code>): <code>GET/POST /v1/admin/sso</code>, <code>GET/PATCH/DELETE /v1/admin/sso/:id</code>; <code>preHandler:[abuseProtection,authProtection]</code> + admin scope. Validate body → 400/404/200|201.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (test-connection) POST /v1/admin/sso/:id/test: OIDC → fetch discovery + JWKS over HTTPS (reuse lms-route.ts:116-122); SAML → parseSamlMetadata (sso-service.ts:1763) + inspectX509Pem (:1799). Fail-closed, structured result — never fake success. (Built tenant-console/sso-connection-test.ts — a pure, injectable-fetch testSsoConnection(connection) returning a structured {ok, protocol, checks[], testedAt} verdict, plus the POST /v1/admin/sso/:id/test route (200 always carries the verdict incl. ok:false; 404 only on unknown id; 401/403 gated). OIDC = the real HTTPS path the checklist asked for: derive the discovery doc from the issuer (oidcDiscoveryUrl, RFC-8414 path-aware), GET /.well-known/openid-configuration, read jwks_uri, GET the JWKS, require ≥1 key — any throw/non-200/missing-key → a failed check, never a green light. SAML deviates from parseSamlMetadata/inspectX509Pem by design: those parse IdP metadata XML, but the tenant-console SsoConnection model holds a signing-cert THUMBPRINT + sso/slo endpoint URLs — there is no metadata XML to parse, and importing the yemaya parser would half-wire the rejected @yemaya/organizations model (the very thing the 3.2 decision forbids). So SAML probes exactly the config we hold: per-sso-endpoint liveness (any HTTP answer = reachable; a thrown DNS/conn/TLS/timeout error = genuinely unreachable) + thumbprint validity (hex, SHA-1 40 / SHA-256 64). 12 pure-fn tests (OIDC happy/unreachable/ no-jwks_uri/empty-keys/non-200; SAML happy/unreachable/short-thumbprint/ non-hex/missing-endpoint; discovery-URL derivation; injected-clock testedAt) — every failure path asserts ok:false so a thrown fetch can NEVER yield success — + 4 route tests (401/403/404/200-ok-true/200-ok-false fail-closed). tsc + eslint clean.)" checked=""/> <strong>(test-connection)</strong> <code>POST /v1/admin/sso/:id/test</code>: OIDC → fetch discovery + JWKS over HTTPS (reuse <code>lms-route.ts:116-122</code>); SAML → <code>parseSamlMetadata</code> (<code>sso-service.ts:1763</code>) + <code>inspectX509Pem</code> (<code>:1799</code>). Fail-closed, structured result — never fake success. <em>(Built <code>tenant-console/sso-connection-test.ts</code> — a pure, injectable-fetch <code>testSsoConnection(connection)</code> returning a structured <code>{ok, protocol, checks[], testedAt}</code> verdict, plus the <code>POST /v1/admin/sso/:id/test</code> route (200 always carries the verdict incl. <code>ok:false</code>; 404 only on unknown id; 401/403 gated). OIDC = the real HTTPS path the checklist asked for: derive the discovery doc from the issuer (<code>oidcDiscoveryUrl</code>, RFC-8414 path-aware), GET <code>/.well-known/openid-configuration</code>, read <code>jwks_uri</code>, GET the JWKS, require ≥1 key — any throw/non-200/missing-key → a failed check, never a green light. SAML <strong>deviates from <code>parseSamlMetadata</code>/<code>inspectX509Pem</code> by design</strong>: those parse IdP metadata XML, but the tenant-console <code>SsoConnection</code> model holds a signing-cert THUMBPRINT + sso/slo endpoint URLs — there is no metadata XML to parse, and importing the yemaya parser would half-wire the rejected <code>@yemaya/organizations</code> model (the very thing the 3.2 decision forbids). So SAML probes exactly the config we hold: per-<code>sso</code>-endpoint liveness (any HTTP answer = reachable; a thrown DNS/conn/TLS/timeout error = genuinely unreachable) + thumbprint validity (hex, SHA-1 40 / SHA-256 64). 12 pure-fn tests (OIDC happy/unreachable/ no-jwks_uri/empty-keys/non-200; SAML happy/unreachable/short-thumbprint/ non-hex/missing-endpoint; discovery-URL derivation; injected-clock <code>testedAt</code>) — every failure path asserts <code>ok:false</code> so a thrown fetch can NEVER yield success — + 4 route tests (401/403/404/200-ok-true/200-ok-false fail-closed). tsc + eslint clean.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (wire) Register in server.ts near LMS/tenant-console (:282-294). (Registered in app.ts with the other admin routes — createApp-testable; same effect.)" checked=""/> <strong>(wire)</strong> Register in <code>server.ts</code> near LMS/tenant-console (<code>:282-294</code>). <em>(Registered in <code>app.ts</code> with the other admin routes — createApp-testable; same effect.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (retire fixture in prod) Replace /v1/sso's fixture body (domain-stubs.ts:1645) with a read from the store (map → the operator card shape operator/sso/page.tsx:28-36). (Done — /v1/sso now reads the real ssoConnectionStore (the same singleton the /v1/admin/sso CRUD routes write to) and projects each SsoConnection through mapSsoConnectionToOperatorCard onto the non-secret operator card shape; the SSO_FIXTURE constant is deleted. Serves a 200 envelope in EVERY env (empty until an operator creates a connection) — the prod-503 is retired. Auth posture (deliberate): kept UNGATED to honor the established /v1/sso contract — there's an explicit e2e tripwire (tenant-sso-config.spec.ts) doing an UNAUTHENTICATED GET /v1/sso asserting a roster envelope, plus the prod-503 guard existed only because fixtures fabricate data; real, non-secret config metadata (no thumbprint / claim-mappings / lifetimes are projected) isn't a fabrication hazard. The mapping derives every field from the model (NOT constants): status = active iff the connection permits idp- OR sp-initiated login else disabled; idpMetadataUrl = the RFC-8414 discovery doc for OIDC / the entity id for SAML; protocol saml2→saml; tenantLabel = tenantId (model has no label); lastSyncIso = null (not tracked). Tests reconciled: removed /v1/sso from the prod-503 it.each, kept the envelope-keys it.each (empty array still has both keys), and replaced the fixture "active+pending" assertion with a store-seeded mapping-correctness test (asserts the DERIVED protocol/status/metadata-url for a SAML + an OIDC connection); relaxed the e2e tripwire's fixture-specific >=2 / contains-saml-oidc to per-connection shape checks. 67/67 domain-stubs route tests green; tsc + eslint clean. If product later wants the roster gated, the ungated tripwire + the unauthenticated unit injects must move to an admin bearer — a separate, explicit decision.)" checked=""/> <strong>(retire fixture in prod)</strong> Replace <code>/v1/sso</code>'s fixture body (<code>domain-stubs.ts:1645</code>) with a read from the store (map → the operator card shape <code>operator/sso/page.tsx:28-36</code>). <em>(Done — <code>/v1/sso</code> now reads the real <code>ssoConnectionStore</code> (the same singleton the <code>/v1/admin/sso</code> CRUD routes write to) and projects each <code>SsoConnection</code> through <code>mapSsoConnectionToOperatorCard</code> onto the non-secret operator card shape; the <code>SSO_FIXTURE</code> constant is deleted. Serves a 200 envelope in EVERY env (empty until an operator creates a connection) — the prod-503 is retired. <strong>Auth posture (deliberate):</strong> kept UNGATED to honor the established <code>/v1/sso</code> contract — there's an explicit e2e tripwire (<code>tenant-sso-config.spec.ts</code>) doing an UNAUTHENTICATED <code>GET /v1/sso</code> asserting a roster envelope, plus the prod-503 guard existed only because fixtures fabricate data; real, non-secret config metadata (no thumbprint / claim-mappings / lifetimes are projected) isn't a fabrication hazard. The mapping derives every field from the model (NOT constants): <code>status</code> = <code>active</code> iff the connection permits idp- OR sp-initiated login else <code>disabled</code>; <code>idpMetadataUrl</code> = the RFC-8414 discovery doc for OIDC / the entity id for SAML; <code>protocol</code> saml2→saml; <code>tenantLabel</code> = tenantId (model has no label); <code>lastSyncIso</code> = null (not tracked). Tests reconciled: removed <code>/v1/sso</code> from the prod-503 <code>it.each</code>, kept the envelope-keys <code>it.each</code> (empty array still has both keys), and replaced the fixture "active+pending" assertion with a store-seeded mapping-correctness test (asserts the DERIVED protocol/status/metadata-url for a SAML + an OIDC connection); relaxed the e2e tripwire's fixture-specific <code>>=2</code> / contains-saml-oidc to per-connection shape checks. 67/67 domain-stubs route tests green; tsc + eslint clean. If product later wants the roster gated, the ungated tripwire + the unauthenticated unit injects must move to an admin bearer — a separate, explicit decision.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (web data layer) bffPost/bffPatch/bffDelete for the SSO endpoints. (Done — apps/oshun/web/src/operator/ssoClient.ts, a thin auth-bearing wrapper over the shared api client for the full CRUD + test-connection: list(tenantId?)/get/create/update(PATCH)/remove(DELETE)/test (POST /:id/test), with typed DTOs mirroring SsoConnection + SsoConnectionTestResult. The operator session carries the admin scope; the BFF gate stays authoritative (a non-admin → real 403). 7 unit tests (paths + bodies + tenant-filter URL-encoding). tsc + eslint clean. The OperatorSsoEditor UI that consumes it needs the in-browser visual pass (CLAUDE.md) — the deferred piece.)" checked=""/> <strong>(web data layer)</strong> <code>bffPost/bffPatch/bffDelete</code> for the SSO endpoints. <em>(Done — <code>apps/oshun/web/src/operator/ssoClient.ts</code>, a thin auth-bearing wrapper over the shared <code>api</code> client for the full CRUD + test-connection: <code>list(tenantId?)</code>/<code>get</code>/<code>create</code>/<code>update</code>(PATCH)/<code>remove</code>(DELETE)/<code>test</code> (POST <code>/:id/test</code>), with typed DTOs mirroring <code>SsoConnection</code> + <code>SsoConnectionTestResult</code>. The operator session carries the admin scope; the BFF gate stays authoritative (a non-admin → real 403). 7 unit tests (paths + bodies + tenant-filter URL-encoding). tsc + eslint clean. The <code>OperatorSsoEditor</code> UI that consumes it needs the in-browser visual pass (CLAUDE.md) — the deferred piece.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (UI) Extend app/operator/sso/page.tsx with "New connection" + per-card edit/delete via OperatorSsoEditor.tsx: protocol, idpEntityId, endpoints, JWKS URL, signing cert/thumbprint, idp/sp-initiated toggles, JIT toggle, session/refresh lifetimes, a claim-mapping table (externalClaim → internalAttribute union identity/sso.ts:12-19, required, transform :22), and a Test-connection button. (DONE — apps/oshun/web/src/components/operator/OperatorSsoEditor.tsx ('use client') added below the existing read-only roster on /operator/sso (additive; the SSR roster reads /v1/sso, the editor uses ssoClient → /v1/admin/sso). A full connection form aligned to the BFF validator: tenant id, protocol (oidc/saml2), IdP entity id, signing-cert thumbprint, DYNAMIC IdP endpoint rows (sso/slo + url, add/remove), a DYNAMIC claim-mapping table (externalClaim → the internalAttribute union + required + transform, add/remove), idp/sp-initiated + JIT toggles, session/refresh lifetimes. Create / Edit (populate→update) / Delete / Test (renders the real per-check verdict) via ssoClient. Client pre-flight mirrors the validator (a required→email mapping is mandatory, ≥1 endpoint, positive lifetimes) but the BFF stays authoritative — a 400 surfaces its issues[], a 403 surfaces an honest admin-scope error; never fabricates a result. 8 component tests (list, create with validator-aligned input, client email-mapping block w/ NO API call, 400 issues surfaced, test→checks, delete, edit→update, 403) + the SSR page test mocks the editor (sibling convention). 10 web tests green, bounded tsc 0, eslint 0. The JWKS-URL field is folded into the IdP entity/issuer (OIDC discovery derives JWKS from the issuer; the tenant-console model has no separate JWKS field — matches the 3.2 test-connection design). Browser visual pass deferred (RAM-bound).)" checked=""/> <strong>(UI)</strong> Extend <code>app/operator/sso/page.tsx</code> with "New connection" + per-card edit/delete via <code>OperatorSsoEditor.tsx</code>: protocol, idpEntityId, endpoints, JWKS URL, signing cert/thumbprint, idp/sp-initiated toggles, JIT toggle, session/refresh lifetimes, a <strong>claim-mapping table</strong> (externalClaim → <code>internalAttribute</code> union <code>identity/sso.ts:12-19</code>, required, transform <code>:22</code>), and a Test-connection button. <em>(DONE — <code>apps/oshun/web/src/components/operator/OperatorSsoEditor.tsx</code> ('use client') added below the existing read-only roster on <code>/operator/sso</code> (additive; the SSR roster reads <code>/v1/sso</code>, the editor uses <code>ssoClient</code> → <code>/v1/admin/sso</code>). A full connection form aligned to the BFF validator: tenant id, protocol (oidc/saml2), IdP entity id, signing-cert thumbprint, DYNAMIC IdP endpoint rows (sso/slo + url, add/remove), a DYNAMIC claim-mapping table (externalClaim → the <code>internalAttribute</code> union + required + transform, add/remove), idp/sp-initiated + JIT toggles, session/refresh lifetimes. Create / Edit (populate→update) / Delete / Test (renders the real per-check verdict) via <code>ssoClient</code>. Client pre-flight mirrors the validator (a required→email mapping is mandatory, ≥1 endpoint, positive lifetimes) but the BFF stays authoritative — a 400 surfaces its <code>issues[]</code>, a 403 surfaces an honest admin-scope error; never fabricates a result. 8 component tests (list, create with validator-aligned input, client email-mapping block w/ NO API call, 400 issues surfaced, test→checks, delete, edit→update, 403) + the SSR page test mocks the editor (sibling convention). 10 web tests green, bounded tsc 0, eslint 0. The JWKS-URL field is folded into the IdP entity/issuer (OIDC discovery derives JWKS from the issuer; the tenant-console model has no separate JWKS field — matches the 3.2 test-connection design). Browser visual pass deferred (RAM-bound).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (unit store/route/claim) create/update/delete + validation rejects; 401/403/400/404/201/200 + structured test-connection; processSsoLogin consumes a saved required-claim mapping (right attribute or missing-required-claim). (13 store + 5 route tests; processSsoLogin round-trip both ways. Structured test-connection assertions now landed with the route above — 12 pure-fn + 4 route tests.)" checked=""/> <strong>(unit store/route/claim)</strong> create/update/delete + validation rejects; 401/403/400/404/201/200 + structured test-connection; <code>processSsoLogin</code> consumes a saved required-claim mapping (right attribute or <code>missing-required-claim</code>). <em>(13 store + 5 route tests; processSsoLogin round-trip both ways. Structured test-connection assertions now landed with the route above — 12 pure-fn + 4 route tests.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e) Extend tenant-sso-config.spec.ts beyond the fixture: create → roster → edit claim mapping → delete; + a pure-BFF CRUD spec. (PARTIAL 2026-06-09 — ran the suite via Playwright against the live BFF and found the spec had been RED since the 3.2 fixture-retirement: 3 render tests still asserted the deleted SSO_FIXTURE (the bottom tripwire was reconciled then; these were missed). Reconciled them to the create → roster leg for real: a beforeEach idempotently SEEDS two connections via the genuine admin BFF write route (POST /v1/admin/sso, operator-admin bearer — also exercising the pure-BFF list/create CRUD path) and the render tests assert the roster paints their derived operator cards (mapSsoConnectionToOperatorCard). Spec 5-failing → 6-green. ALSO found + fixed a real a11y bug the e2e surfaced (axe select-name critical): the OperatorSsoEditor endpoint-use / claim-internal / claim-transform <select>s had no accessible name — added aria-labels (commit f0073d8d8a). REMAINING for [x]: the editor-UI-driven edit-claim-mapping → delete legs (drive the form + assert the roster mutates) + a dedicated pure-BFF CRUD spec file.) (COMPLETED → [x] 2026-06-10 — both remaining legs built. Editor legs (tenant-sso-config.spec.ts, new describe): the OperatorSsoEditor's unauthenticated browser calls are re-issued with the operator-admin bearer via a page.route bridge (body-aware: a body-less DELETE must NOT claim a JSON content-type or Fastify's strict parser 400s it — pinned in a comment); EDIT opens the prefilled form, changes the required email mapping's externalClaim (mail→emailAddress), saves, and the PERSISTED store reflects it via an expect.poll API read-back; DELETE removes the roster row + the store entry. Service worker blocked (it would serve stale /v1/admin/sso GETs around the bridge). Dedicated pure-BFF spec tenant-sso-crud-bff.spec.ts: create 201 → list → PATCH claim mapping → REAL fail-closed test-connection (non-existent IdP → ok:false + failing checks over real network calls) → delete → gone → re-delete 404; + required-email-claim 400 + 401/403 gates. 11/11 green across both files at 1 worker.)" checked=""/> <strong>(e2e)</strong> Extend <code>tenant-sso-config.spec.ts</code> beyond the fixture: create → roster → edit claim mapping → delete; + a pure-BFF CRUD spec. <em>(PARTIAL 2026-06-09 — ran the suite via Playwright against the live BFF and found the spec had been RED since the 3.2 fixture-retirement: 3 render tests still asserted the deleted <code>SSO_FIXTURE</code> (the bottom tripwire was reconciled then; these were missed). Reconciled them to the <strong>create → roster</strong> leg for real: a beforeEach idempotently SEEDS two connections via the genuine admin BFF write route (<code>POST /v1/admin/sso</code>, operator-admin bearer — also exercising the pure-BFF list/create CRUD path) and the render tests assert the roster paints their derived operator cards (<code>mapSsoConnectionToOperatorCard</code>). Spec 5-failing → 6-green. ALSO found + fixed a real <strong>a11y bug</strong> the e2e surfaced (axe <code>select-name</code> critical): the <code>OperatorSsoEditor</code> endpoint-use / claim-internal / claim-transform <code><select></code>s had no accessible name — added aria-labels (commit <code>f0073d8d8a</code>). REMAINING for <code>[x]</code>: the editor-UI-driven <strong>edit-claim-mapping → delete</strong> legs (drive the form + assert the roster mutates) + a dedicated pure-BFF CRUD spec file.)</em> <em>(COMPLETED → [x] 2026-06-10 — both remaining legs built. Editor legs (tenant-sso-config.spec.ts, new describe): the OperatorSsoEditor's unauthenticated browser calls are re-issued with the operator-admin bearer via a page.route bridge (body-aware: a body-less DELETE must NOT claim a JSON content-type or Fastify's strict parser 400s it — pinned in a comment); EDIT opens the prefilled form, changes the required email mapping's externalClaim (mail→emailAddress), saves, and the PERSISTED store reflects it via an expect.poll API read-back; DELETE removes the roster row + the store entry. Service worker blocked (it would serve stale /v1/admin/sso GETs around the bridge). Dedicated pure-BFF spec <code>tenant-sso-crud-bff.spec.ts</code>: create 201 → list → PATCH claim mapping → REAL fail-closed test-connection (non-existent IdP → ok:false + failing checks over real network calls) → delete → gone → re-delete 404; + required-email-claim 400 + 401/403 gates. 11/11 green across both files at 1 worker.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (cross-link + deploy) Update the SSO journey + operator walkthrough + coverage; document the runtime validator/exchanger adapter injection. (DONE 2026-06-10 — the journey doc's E2E-coverage section was STALE ("render-only / no BFF write routes / SSO_FIXTURE") and is rewritten with a superseding banner: both specs linked with their legs, the adapter-injection note documents RegisterSsoRoutesOptions.store (snapshot-durable ssoConnectionStore default) + testFetch (the REAL-network fail-closed probe seam in sso-connection-test.ts) and that saved claim mappings are consumed by processSsoLogin at login (missing-required-claim fails closed). coverage.md row upgraded shallow→deep (in-web surface + BFF contract verified; the separate tenant-admin app + a live IdP sign-in stay external).)" checked=""/> <strong>(cross-link + deploy)</strong> Update the SSO journey + operator walkthrough + coverage; document the runtime validator/exchanger adapter injection. <em>(DONE 2026-06-10 — the journey doc's E2E-coverage section was STALE ("render-only / no BFF write routes / SSO_FIXTURE") and is rewritten with a superseding banner: both specs linked with their legs, the adapter-injection note documents <code>RegisterSsoRoutesOptions.store</code> (snapshot-durable ssoConnectionStore default) + <code>testFetch</code> (the REAL-network fail-closed probe seam in sso-connection-test.ts) and that saved claim mappings are consumed by <code>processSsoLogin</code> at login (missing-required-claim fails closed). coverage.md row upgraded shallow→<strong>deep</strong> (in-web surface + BFF contract verified; the separate tenant-admin app + a live IdP sign-in stay external).)</em></li> </ul> </li> <li><strong>Acceptance criteria:</strong> An operator can create/view/edit (incl. claim mappings + JWKS)/delete a connector through <code>/operator/sso</code>, persisted + shown in prod (no fixture); test-connection reports real IdP reachability + fails closed; a saved mapping round-trips through <code>processSsoLogin</code>; CRUD enforces auth + scope.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/bff && npx vitest run src/tenant-console/sso-connection-store.test.ts src/tenant-console/sso-route.test.ts</code> · <code>cd libs/oshun/tenant-console && npx vitest run src/identity/sso.test.ts</code></li> <li><strong>Effort / risk:</strong> <strong>M.</strong> Risk: two competing SSO models — pick the tenant-console one, don't half-wire both; keep test-connection fail-closed + out of unit hot paths; don't echo secrets in GET; an end-to-end login (vs config) stays deploy-bound.</li> </ul> <h2 id="33-lms-connector-management" class="hd hd-l2">3.3 LMS connector management<a class="hd-anchor" href="#33-lms-connector-management" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> Runtime complete + tested — routes mounted (<code>tenant-console/ lms-route.ts:135,171,246,287</code>), id_token JWS verifier + OIDC + SCORM RTE implemented, boot validation real (<code>createTenantLmsRuntimeFromEnv:95-129</code> over <code>OSHUN_LMS_CONNECTORS</code>, validates each via <code>lms.ts:910-1002</code>, skips invalid, 404s when none, fetches JWKS live). <strong>No connector-management read/write surface</strong> — no GET/list route, no UI; operators can only edit env JSON + redeploy, and <strong>skip reasons are silently discarded</strong> (<code>lms-route.ts:108-110</code>).</li> <li><strong>The gap:</strong> Operability — visibility (list) + validation feedback; optionally in-app editable connectors.</li> <li><strong>External dependency:</strong> <strong>None</strong> to build the read/list/validate routes + UI (pure code over the existing descriptor/validation/framework). The genuinely external inputs (per-tenant <code>OSHUN_LMS_CONNECTORS</code> values + the platform's live JWKS) are tenant-provided config.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Retain per-connector validation results in createTenantLmsRuntimeFromEnv (the catch at :108-110 currently discards them). (Now structured invalidConnectors[]; malformed JSON surfaced too.)" checked=""/> Retain per-connector validation results in <code>createTenantLmsRuntimeFromEnv</code> (the catch at <code>:108-110</code> currently discards them). <em>(Now structured <code>invalidConnectors[]</code>; malformed JSON surfaced too.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Add listConnectors(tenantId?) to LmsConnectorFramework (lms.ts:346-382). (Already present at lms.ts:397 — the GET route consumes it.)" checked=""/> Add <code>listConnectors(tenantId?)</code> to <code>LmsConnectorFramework</code> (<code>lms.ts:346-382</code>). <em>(Already present at <code>lms.ts:397</code> — the GET route consumes it.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (GET list) GET /v1/admin/lms/connectors → {connectors[], invalid:[{id, issues}]}, admin-gated. (Highest-value, lowest-risk addition.) (Summary omits secrets.)" checked=""/> <strong>(GET list)</strong> <code>GET /v1/admin/lms/connectors</code> → <code>{connectors[], invalid:[{id, issues}]}</code>, admin-gated. (Highest-value, lowest-risk addition.) <em>(Summary omits secrets.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (POST validate) POST /v1/admin/lms/connectors/validate → run createLmsConnectorDescriptor + validateLmsConnectorDescriptor, return the ConnectorValidationResult (no persistence)." checked=""/> <strong>(POST validate)</strong> <code>POST /v1/admin/lms/connectors/validate</code> → run <code>createLmsConnectorDescriptor</code> + <code>validateLmsConnectorDescriptor</code>, return the <code>ConnectorValidationResult</code> (no persistence).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (decision-gated CRUD) Only if redeploy-free changes are needed: a durable lms-connector-store.ts (tenant_lms_connectors snapshot) merged with env at boot" checked=""/> <strong>(decision-gated CRUD)</strong> Only if redeploy-free changes are needed: a durable <code>lms-connector-store.ts</code> (<code>tenant_lms_connectors</code> snapshot) merged with env at boot <ul> <li><code>POST/PATCH/DELETE</code> that validate-then-register; document precedence. <strong>Recommend env + list/validate first.</strong> <em>(Followed the recommendation — env + list/validate only; no CRUD store built.)</em></li> </ul> </li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (web) bffGet/bffPost helpers; app/operator/lms/page.tsx listing connectors + a prominent invalid-connector panel + a dry-run validate form. (app/operator/lms/page.tsx built — a read-only server component over the existing bffGet: live connector roster (provider / status / environment / LTI 1.3 / SCORM) + a prominent invalid-connector panel rendering each boot-skipped descriptor with its exact code/path/severity/message (no silent skips). Fail-soft empty state on null/403/no-connectors. Linked from /operator/sso. 4 server-component unit tests (__tests__/page.test.tsx, the blessed render(await Page()) + mocked bffGet pattern). Dry-run validate form now done too — LmsConnectorValidateForm ('use client') posts a pasted descriptor to POST /v1/admin/lms/connectors/validate and renders the {valid, issues[]} verdict; malformed-JSON caught client-side (no POST), honest 400/403 errors. 4 unit tests.)" checked=""/> <strong>(web)</strong> <code>bffGet</code>/<code>bffPost</code> helpers; <code>app/operator/lms/page.tsx</code> listing connectors + a prominent <strong>invalid-connector panel</strong> + a dry-run validate form. <em>(<code>app/operator/lms/page.tsx</code> built — a read-only server component over the existing <code>bffGet</code>: live connector roster (provider / status / environment / LTI 1.3 / SCORM) + a <strong>prominent invalid-connector panel</strong> rendering each boot-skipped descriptor with its exact code/path/severity/message (no silent skips). Fail-soft empty state on null/403/no-connectors. Linked from <code>/operator/sso</code>. 4 server-component unit tests (<code>__tests__/page.test.tsx</code>, the blessed render(await Page()) + mocked bffGet pattern). <strong>Dry-run validate form now done too</strong> — <code>LmsConnectorValidateForm</code> ('use client') posts a pasted descriptor to <code>POST /v1/admin/lms/connectors/validate</code> and renders the <code>{valid, issues[]}</code> verdict; malformed-JSON caught client-side (no POST), honest 400/403 errors. 4 unit tests.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (unit) Extend lms-route.test.ts: mixed valid/invalid env → registers valid" checked=""/> <strong>(unit)</strong> Extend <code>lms-route.test.ts</code>: mixed valid/invalid env → registers valid <ul> <li>reports invalid w/ issues; GET returns them; validate returns the result for a malformed descriptor (missing deploymentId, http jwksUrl in prod, missing AGS scope); 401/403 gates; 404/empty when none.</li> </ul> </li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (e2e) e2e/lms-connectors.spec.ts + a pure-BFF list/validate spec. (Pure-BFF list/validate spec DONE 2026-06-09 — GREEN (2/2). e2e/lms-connectors.spec.ts drives the REAL routes over HTTP via the Playwright request fixture (pattern from admin-auth-policy-bff.spec.ts, dev.<base64url> bearer): GET /v1/admin/lms/connectors → 401 no-bearer / 403 admin_scope_missing (customer scope) / 200 admin → the {generatedAt, connectors[], invalid[]} envelope (empty, no OSHUN_LMS_CONNECTORS seeded); POST …/validate → 401/403, 400 invalid_request (missing ids), and a well-formed-but-no-protocol descriptor → 200 valid:false with a real issue (lms_protocol_required/descriptor_malformed — the validator REJECTS it, never fabricates valid:true). Asserts domain correctness, not just shape; exercises the real createApp auth chain (vs. the existing inject-only unit tests). No bugs found — the 3.3 routes are well-built. eslint 0, stub-scan clean. The operator/lms UI page e2e is the deferred half: it only ever renders the empty state without seeded OSHUN_LMS_CONNECTORS + an admin-scoped operator session, and reuses the same operator-shell components (LWebShell/LMasthead/LBtn) already axe-scanned on /operator/sso — modest marginal value for the operator-auth-helper setup; left for a session that seeds a real connector roster.) 2026-09-18: open for an agent. The residue is the operator LMS page e2e with a seeded OSHUN_LMS_CONNECTORS roster and an admin-scoped operator session."/> <strong>(e2e)</strong> <code>e2e/lms-connectors.spec.ts</code> + a pure-BFF list/validate spec. <em>(Pure-BFF list/validate spec DONE 2026-06-09 — GREEN (2/2). <code>e2e/lms-connectors.spec.ts</code> drives the REAL routes over HTTP via the Playwright <code>request</code> fixture (pattern from <code>admin-auth-policy-bff.spec.ts</code>, <code>dev.<base64url></code> bearer): GET <code>/v1/admin/lms/connectors</code> → 401 no-bearer / 403 <code>admin_scope_missing</code> (customer scope) / 200 admin → the <code>{generatedAt, connectors[], invalid[]}</code> envelope (empty, no <code>OSHUN_LMS_CONNECTORS</code> seeded); POST <code>…/validate</code> → 401/403, 400 <code>invalid_request</code> (missing ids), and a well-formed-but-no-protocol descriptor → 200 <code>valid:false</code> with a real issue (<code>lms_protocol_required</code>/<code>descriptor_malformed</code> — the validator REJECTS it, never fabricates <code>valid:true</code>). Asserts domain correctness, not just shape; exercises the real createApp auth chain (vs. the existing inject-only unit tests). No bugs found — the 3.3 routes are well-built. eslint 0, stub-scan clean. <strong>The operator/lms UI page e2e is the deferred half</strong>: it only ever renders the empty state without seeded <code>OSHUN_LMS_CONNECTORS</code> + an admin-scoped operator session, and reuses the same operator-shell components (LWebShell/LMasthead/LBtn) already axe-scanned on <code>/operator/sso</code> — modest marginal value for the operator-auth-helper setup; left for a session that seeds a real connector roster.)</em> <em>2026-09-18: open for an agent. The residue is the operator LMS page e2e with a seeded <code>OSHUN_LMS_CONNECTORS</code> roster and an admin-scoped operator session.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (cross-link + deploy) Add an LMS-connectors journey + operator walkthrough" checked=""/> <strong>(cross-link + deploy)</strong> Add an LMS-connectors journey + operator walkthrough <ul> <li>coverage; expand §3 with a copy-pasteable per-provider <code>OSHUN_LMS_CONNECTORS</code> JSON example. <em>(DONE 2026-06-10 — NEW <code>WALKTHROUGH/journeys/lms-connectors-operator.md</code> (author → dry-run validate → boot validation w/ retained skip reasons → operate the roster → launch path; failure modes; e2e/unit coverage; the deliberate env-not-CRUD decision documented) + NEW per-view <code>WALKTHROUGH/operator/operator-lms.md</code> (honest last_walked: verified via the render(await Page()) unit suite + the live-BFF API e2e; browser probe flagged as the open gap) + a coverage.md row. §3 of V1_DEPLOYMENT_REQUIREMENTS.md expanded from one Canvas example to THREE per-provider examples (Canvas LTI 1.3+AGS, Moodle LTI 1.3+NRPS incl. its real /mod/lti/* endpoints, SCORM-only legacy) — <strong>all three verified <code>valid:true, issues:[]</code> against the REAL <code>validateLmsConnectorDescriptor</code></strong> via tsx before committing (a copy-pasteable example that doesn't validate would be a doc stub).)</em></li> </ul> </li> </ul> </li> <li><strong>Acceptance criteria:</strong> <code>GET .../connectors</code> returns live connectors + skipped ones with issues; <code>POST .../validate</code> returns the full result; an operator sees every connector + exactly why a malformed one was skipped (no silent skips); (if CRUD) add/edit/delete in-app, validated, durable, env-merged.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/bff && npx vitest run src/tenant-console/lms-route.test.ts</code> · <code>cd libs/shared/inbound-integrations && npx vitest run src/lms.spec.ts src/lti-verification.spec.ts</code></li> <li><strong>Effort / risk:</strong> <strong>S–M.</strong> Risk: don't build the heavier store unless product needs redeploy-free changes; the one real code change (retain + surface skip reasons) must be test-locked.</li> </ul> <hr /> <hr /> <h1 id="part-4--privacydeletion-library-sync--payments" class="hd hd-l1">Part 4 — Privacy/Deletion, Library Sync & Payments<a class="hd-anchor" href="#part-4--privacydeletion-library-sync--payments" aria-label="Permalink to this section">#</a></h1> <blockquote> <p><strong>Shared primitive:</strong> <code>libs/oshun/payments-bridge/src/receipt-signer</code> (Ed25519 signed receipts) backs <strong>both</strong> 4.1 (per-tombstone attestation) and 4.3 (payment receipts). Standardize on it — don't write two attestation implementations.</p> </blockquote> <h2 id="41-iris-6-service-deletion-fan-out--per-tombstone-attestation" class="hd hd-l2">4.1 Iris 6-service deletion fan-out + per-tombstone attestation<a class="hd-anchor" href="#41-iris-6-service-deletion-fan-out--per-tombstone-attestation" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> Customer deletion is a <strong>pure in-memory state machine with no real erasure</strong> — <code>apps/oshun/bff/src/data-deletion/state.ts</code> imports only <code>@oshun/contracts</code>; <code>buildTombstones</code> (<code>:394-410</code>) + <code>buildDerivedArtifactJobs</code> (<code>:371-392</code>) fabricate records; <code>markCompleted</code> (<code>:276-296</code>) + <code>applyTransitions</code> (<code>:412-444</code>) just flip statuses — <strong>no service is ever called</strong>. The "6 services" = the 6 derived-artifact cascade types (<code>:377-384</code>: <code>memory_scope, voice_profile, avatar_pack, generated_artifact, personalization_vector, conversation_history</code>). A <strong>real erasure engine exists but is orphaned</strong>: <code>libs/oshun/persistence/src/ dsar-deletion-cascade.ts</code> (real Prisma purge + memory forget, fail-closed <code>DsarDeletionReceipt</code>), reachable only via operator <code>POST /v1/admin/privacy/dsar/erase</code> (<code>privacy/dsar-erasure-route.ts:35-64</code>, caller must supply the subject→records map). Attestation primitive available (<code>payments-bridge/src/receipt-signer/receipt-signer.ts:21-78</code>). Tombstone schema has <strong>no attestation field</strong> (<code>libs/contracts/src/common/customer-data-deletion.ts:128-134</code>).</li> <li><strong>The gap:</strong> (1) the 6 jobs are never dispatched; (2) no per-tombstone signed attestation; (3) no orchestration linking <code>POST /deletions</code> → cascade → per-service receipts → completed/failed; (4) no A-to-Z test that reads each service back.</li> <li><strong>External dependency:</strong> Mostly pure code, but each of the 6 services needs a "delete artifacts of kind K for subject S" entrypoint + record enumeration (the cascade delegates subject→records, <code>dsar-deletion-cascade.ts:14-15</code>). The signing key is a deploy input (Ed25519, like <code>OSHUN_LIVING_SCENES_C2PA_*</code>). A service with no programmatic delete is the irreducible blocker for that service.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (contract) Extend CustomerDataDeletionTombstoneSchema (:128-134) with attestation:{service,status:'erased'|'not_found'|'failed',rowsRemoved, receiptSignatureHex,auditKeyId,attestedAt}|null; add a DeletionServiceId enum; add service to CustomerDataDeletionCascadeJobSchema (:120-126). (Done — DeletionServiceIdSchema (the 6 ids, matching @oshun/privacy DELETION_SERVICE_IDS 1:1), DeletionAttestationStatusSchema ('erased'|'not_found'|'failed'), and CustomerDataDeletionTombstoneAttestationSchema added + exported via the existing barrel. The attestation schema is field-for-field the engine's DeletionAttestation — incl. detail: string|null and attestedAtUnixSeconds:number (NOT the checklist's attestedAt timestamp), so an engine attestation maps onto the tombstone with ZERO translation when the BFF wiring lands. Both new fields are .nullable().optional() / .optional() → non-breaking, contract-only: the BFF state.ts buildTombstones/buildDerivedArtifactJobs compile UNCHANGED (verified — BFF tsc still 0 errors), so this doesn't touch the still-blocked erasure wiring nor risk a conflict with the fan-out session. 7 new spec tests (24 total): the 6-id enum (accept/reject), the status enum, the engine-shape alignment 1:1, not_found+detail, rejects bad status / negative rows / empty sig / missing key, tombstone parses with none/null/full attestation + rejects malformed, cascade job parses with/without service + rejects unknown. The remaining 4.1 BFF erasers/state.ts rewire stays blocked on per-domain deleteForSubject APIs.)" checked=""/> <strong>(contract)</strong> Extend <code>CustomerDataDeletionTombstoneSchema</code> (<code>:128-134</code>) with <code>attestation:{service,status:'erased'|'not_found'|'failed',rowsRemoved, receiptSignatureHex,auditKeyId,attestedAt}|null</code>; add a <code>DeletionServiceId</code> enum; add <code>service</code> to <code>CustomerDataDeletionCascadeJobSchema</code> (<code>:120-126</code>). <em>(Done — <code>DeletionServiceIdSchema</code> (the 6 ids, matching <code>@oshun/privacy</code> <code>DELETION_SERVICE_IDS</code> 1:1), <code>DeletionAttestationStatusSchema</code> ('erased'|'not_found'|'failed'), and <code>CustomerDataDeletionTombstoneAttestationSchema</code> added + exported via the existing barrel. <strong>The attestation schema is field-for-field the engine's <code>DeletionAttestation</code></strong> — incl. <code>detail: string|null</code> and <code>attestedAtUnixSeconds:number</code> (NOT the checklist's <code>attestedAt</code> timestamp), so an engine attestation maps onto the tombstone with ZERO translation when the BFF wiring lands. Both new fields are <code>.nullable().optional()</code> / <code>.optional()</code> → <strong>non-breaking, contract-only</strong>: the BFF <code>state.ts</code> <code>buildTombstones</code>/<code>buildDerivedArtifactJobs</code> compile UNCHANGED (verified — BFF tsc still 0 errors), so this doesn't touch the still-blocked erasure wiring nor risk a conflict with the fan-out session. 7 new spec tests (24 total): the 6-id enum (accept/reject), the status enum, the engine-shape alignment 1:1, not_found+detail, rejects bad status / negative rows / empty sig / missing key, tombstone parses with none/null/full attestation + rejects malformed, cascade job parses with/without service + rejects unknown. The remaining 4.1 BFF erasers/state.ts rewire stays blocked on per-domain <code>deleteForSubject</code> APIs.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (domain port) libs/oshun/privacy/src/export-deletion/deletion-fanout.ts: interface DeletionServiceEraser { serviceId; eraseForSubject(input):Promise<{status, rowsRemoved,detail?}> } + executeDeletionFanout(request,erasers,signer) calling each of the 6, signing each outcome (reuse receipt-signer), fail-closed (complete only if no failed, mirror dsar-deletion-cascade.ts:149-158). Built — DELETION_SERVICE_IDS (the 6), DeletionServiceEraser, executeDeletionFanout (per-service erase → sign → fail-closed complete iff none failed; a throwing eraser is captured as failed, never dropped), plus a deletion-specific createDeletionAttestationSigner / canonicaliseDeletionAttestation / verifyDeletionAttestation over @noble/curves/ed25519 (NOT the payments ReceiptSigner — see below). Exported from the lib index; tsc + lint clean. The BFF erasers + the state.ts rewire (replacing the fabricated markCompleted) remain — the L wiring blocked on per-domain deleteForSubject APIs. Correction (verified against source): "reuse receipt-signer" does NOT work as-is — ReceiptSigner.sign(payload: ReceiptPayload) is hard-typed to the payments payload (invoiceId / txId / blockHash / asset / amount-in-chain-units / fiat / tax; receipt-signer/types.ts:38). A deletion attestation (service / subjectId / deletionId / status / rowsRemoved) does not fit it. The honest build is a deletion-specific Ed25519 signer over @noble/curves/ed25519 (the same primitive ReceiptSigner wraps internally) with its own canonical payload — NOT the payments ReceiptSigner. This + the orphaned wiring (the BFF state.ts still fabricates; real wiring is blocked on per-domain deleteForSubject APIs) makes this an L build, not a quick reuse.)" checked=""/> <strong>(domain port)</strong> <code>libs/oshun/privacy/src/export-deletion/deletion-fanout.ts</code>: <code>interface DeletionServiceEraser { serviceId; eraseForSubject(input):Promise<{status, rowsRemoved,detail?}> }</code> + <code>executeDeletionFanout(request,erasers,signer)</code> calling each of the 6, signing each outcome (reuse <code>receipt-signer</code>), fail-closed (<code>complete</code> only if no <code>failed</code>, mirror <code>dsar-deletion-cascade.ts:149-158</code>). <em><strong>Built</strong> — <code>DELETION_SERVICE_IDS</code> (the 6), <code>DeletionServiceEraser</code>, <code>executeDeletionFanout</code> (per-service erase → sign → fail-closed <code>complete</code> iff none <code>failed</code>; a throwing eraser is captured as <code>failed</code>, never dropped), plus a deletion-specific <code>createDeletionAttestationSigner</code> / <code>canonicaliseDeletionAttestation</code> / <code>verifyDeletionAttestation</code> over <code>@noble/curves/ed25519</code> (NOT the payments <code>ReceiptSigner</code> — see below). Exported from the lib index; tsc + lint clean. The BFF erasers + the <code>state.ts</code> rewire (replacing the fabricated <code>markCompleted</code>) remain — the L wiring blocked on per-domain <code>deleteForSubject</code> APIs.</em> <em><strong>Correction (verified against source):</strong> "reuse <code>receipt-signer</code>" does NOT work as-is — <code>ReceiptSigner.sign(payload: ReceiptPayload)</code> is hard-typed to the <strong>payments</strong> payload (invoiceId / txId / blockHash / asset / amount-in-chain-units / fiat / tax; <code>receipt-signer/types.ts:38</code>). A deletion attestation (service / subjectId / deletionId / status / rowsRemoved) does not fit it. The honest build is a deletion-specific Ed25519 signer over <code>@noble/curves/ed25519</code> (the same primitive <code>ReceiptSigner</code> wraps internally) with its own canonical payload — NOT the payments <code>ReceiptSigner</code>. This + the orphaned wiring (the BFF <code>state.ts</code> still fabricates; real wiring is blocked on per-domain <code>deleteForSubject</code> APIs) makes this an L build, not a quick reuse.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (domain test) deletion-fanout.test.ts: 6 mock erasers — all invoked with correct kind; each tombstone signature verifies; one failure → complete:false + that tombstone failed; not_found counts as satisfied. (6 tests: all-six-invoked + every attestation's Ed25519 signature re-verifies; single failure → complete:false (the failed attestation is still signed — an honest record); a throwing eraser → failed (detail captured); not_found → satisfied; a tampered field / wrong subject / wrong key → verification fails; bad-key constructor throws. All green.)" checked=""/> <strong>(domain test)</strong> <code>deletion-fanout.test.ts</code>: 6 mock erasers — all invoked with correct kind; each tombstone signature verifies; one failure → <code>complete:false</code> + that tombstone <code>failed</code>; <code>not_found</code> counts as satisfied. <em>(6 tests: all-six-invoked + every attestation's Ed25519 signature re-verifies; single failure → <code>complete:false</code> (the failed attestation is still signed — an honest record); a throwing eraser → <code>failed</code> (detail captured); <code>not_found</code> → satisfied; a tampered field / wrong subject / wrong key → verification fails; bad-key constructor throws. All green.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (BFF erasers) apps/oshun/bff/src/data-deletion/service-erasers.ts mapping each kind to a real delete. (DONE — 2026-06-09, user-directed EVENT-DRIVEN re-architecture. createSubjectRowEraser(serviceId, deleteRows) over an injected SubjectRowDeleter port (count→erased/ not_found; throw→caller signs failed; live Prisma bindings deploy-wired) for generated_artifact (isis ownerId) + memory_scope (iris) + the coming conversation/personalization stores. createNoSubjectDataEraser for voice_profile/avatar_pack — confirmed by product to hold NO per-user rows (shared catalogs; the user's choice is personalization data), so honest not_found, NOT a fabricated erase. buildBffDeletionErasers omits an unwired real category so the orchestrator reports it missing → incomplete (fail-closed, never a silent pass). 10 tests incl. a real executeDeletionFanout pass. PLUS the event-driven core the sync-fanout design didn't have: deletion-event-orchestrator.ts (publishes via a DeletionEventTransport port, CRYPTOGRAPHICALLY VERIFIES each receipt, fail- closed; 7 tests incl. tampered/replayed/forged rejection) + deletion-event-transport.ts (race-free bus adapter — subscribe before publish; 5 tests). Commits 5bd2f2f803 / e7957fa7ef / 31f4dc2f2b.)" checked=""/> <strong>(BFF erasers)</strong> <code>apps/oshun/bff/src/data-deletion/service-erasers.ts</code> mapping each kind to a real delete. <em>(DONE — 2026-06-09, user-directed EVENT-DRIVEN re-architecture. <code>createSubjectRowEraser(serviceId, deleteRows)</code> over an injected <code>SubjectRowDeleter</code> port (count→erased/ not_found; throw→caller signs failed; live Prisma bindings deploy-wired) for generated_artifact (isis ownerId) + memory_scope (iris) + the coming conversation/personalization stores. <code>createNoSubjectDataEraser</code> for voice_profile/avatar_pack — confirmed by product to hold NO per-user rows (shared catalogs; the user's choice is personalization data), so honest <code>not_found</code>, NOT a fabricated erase. <code>buildBffDeletionErasers</code> omits an unwired real category so the orchestrator reports it <code>missing</code> → incomplete (fail-closed, never a silent pass). 10 tests incl. a real <code>executeDeletionFanout</code> pass. <strong>PLUS the event-driven core the sync-fanout design didn't have:</strong> <code>deletion-event-orchestrator.ts</code> (publishes via a <code>DeletionEventTransport</code> port, CRYPTOGRAPHICALLY VERIFIES each receipt, fail- closed; 7 tests incl. tampered/replayed/forged rejection) + <code>deletion-event-transport.ts</code> (race-free bus adapter — subscribe before publish; 5 tests). Commits 5bd2f2f803 / e7957fa7ef / 31f4dc2f2b.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (BFF wire) In state.ts, inject the fanout runner + signer; replace synthetic markCompleted/applyTransitions. (DONE — THE STUB IS DEAD (85c6a17104). The user chose EVENT-DRIVEN, so this is runDeletion(userId, deletionId, runner): runs the real orchestrator→bus→consumers fan-out, records each verified attestation onto its tombstone, sets completed ONLY when every requested service produced a verified non-failed receipt, else failed. Removed the applyTransitions lazy auto-complete (it fabricated completed with zero erasure) + deleted the synthetic markCompleted. The route runs it inline for immediate requests via an injectable deletionRunner (createApp/registerDataDeletionRoutes); no runner → stays scheduled, never fabricated. grace-window cancel kept. 18 route+state tests; whole-BFF tsc 0. Built on: end-to-end consumers ab4a0b77d7, honest completion 3ffad97c2c.)" checked=""/> <strong>(BFF wire)</strong> In <code>state.ts</code>, inject the fanout runner + signer; replace synthetic <code>markCompleted</code>/<code>applyTransitions</code>. <em>(DONE — <strong>THE STUB IS DEAD</strong> (<code>85c6a17104</code>). The user chose EVENT-DRIVEN, so this is <code>runDeletion(userId, deletionId, runner)</code>: runs the real orchestrator→bus→consumers fan-out, records each verified attestation onto its tombstone, sets <code>completed</code> ONLY when every requested service produced a verified non-failed receipt, else <code>failed</code>. Removed the applyTransitions lazy auto-complete (it fabricated <code>completed</code> with zero erasure) + deleted the synthetic <code>markCompleted</code>. The route runs it inline for immediate requests via an injectable <code>deletionRunner</code> (createApp/registerDataDeletionRoutes); no runner → stays <code>scheduled</code>, never fabricated. grace-window cancel kept. 18 route+state tests; whole-BFF tsc 0. Built on: end-to-end consumers <code>ab4a0b77d7</code>, honest completion <code>3ffad97c2c</code>.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (BFF worker) A grace-expiry worker tick, OSHUN_DELETION_WORKER_INTERVAL_MS, fail-closed off. (DONE 5f4e9a6833 — runScheduledDeletionCycle finds due scheduled deletions (listDueDeletions) + runs each through the fan-out; skips concurrently-finalized; 4 tests. The interval tick is armed at boot only when a real runner is wired (deploy).)" checked=""/> <strong>(BFF worker)</strong> A grace-expiry worker tick, <code>OSHUN_DELETION_WORKER_INTERVAL_MS</code>, fail-closed off. <em>(DONE <code>5f4e9a6833</code> — <code>runScheduledDeletionCycle</code> finds due scheduled deletions (<code>listDueDeletions</code>) + runs each through the fan-out; skips concurrently-finalized; 4 tests. The interval tick is armed at boot only when a real runner is wired (deploy).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (key) Read OSHUN_DELETION_ATTESTATION_ED25519_PRIVATE_KEY at boot; dev fallback non-prod, fail-closed prod. (DONE 3d01c7ad24 — resolveDeletionAttestationSigner: 32-byte hex key + KEY_ID; deterministic dev signer non-prod; FAIL-CLOSED null in prod when unset; malformed→throws. 4 tests; .env.example documents it + the worker interval. PLUS the composition root buildDeletionRunner 4f592e534e (assembles consumers+transport+orchestrator → the runner the route/worker consume).)" checked=""/> <strong>(key)</strong> Read <code>OSHUN_DELETION_ATTESTATION_ED25519_PRIVATE_KEY</code> at boot; dev fallback non-prod, fail-closed prod. <em>(DONE <code>3d01c7ad24</code> — <code>resolveDeletionAttestationSigner</code>: 32-byte hex key + KEY_ID; deterministic dev signer non-prod; FAIL-CLOSED null in prod when unset; malformed→throws. 4 tests; <code>.env.example</code> documents it + the worker interval. PLUS the composition root <code>buildDeletionRunner</code> <code>4f592e534e</code> (assembles consumers+transport+orchestrator → the runner the route/worker consume).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (web) Surface each tombstone's attestation.status + a verify affordance in the data-rights view + profile/data-deletion-client.ts; data-* hooks. (DONE 2026-06-09 — CustomerDataDeletionSection now renders a "Signed erasure receipts" panel per deletion request: for every tombstone that carries a .attestation, it shows the per-service status (erased / no-data-found / FAILED, colour-toned) + rows-removed + the signed-receipt proof (auditKeyId + truncated receiptSignatureHex) — the cryptographic verify affordance (each Ed25519 receipt is independently verifiable against the published key). Hooks: data-profile-deletion-attestations, data-profile-deletion-attestation={service}, data-profile-deletion-attestation-status={status}. 3-layer wire, minimal: the BFF ALREADY attaches the attestation (state.ts:393 {...tombstone, attestation: toTombstoneAttestation(...)} from the verified fan-out receipts) — it was just untyped, so I declared attestation? + a structurally-matching OshunCustomerDataDeletionTombstoneAttestation on the @oshun/auth-client tombstone DTO (no new dep; BFF tsc 0 confirms the spread stays compatible), then rendered it. Honest: only ATTESTED tombstones render a receipt (unattested → nothing, never fabricated); the real receipt DATA is produced by the deploy-bound deletion fan-out (Redis runner), so dev shows none until that runs — the render + a component test (injected attested tombstone → asserts status/rows/key/sig + exactly-one-receipt) exercise it now. auth tsc 0 + 6 auth tests, web bounded tsc 0, eslint 0, 6 component tests, stub-scan clean. Full CLIENT-SIDE Ed25519 verification (vs. just surfacing the receipt) is the deploy-bound enhancement — needs the published public key.)" checked=""/> <strong>(web)</strong> Surface each tombstone's <code>attestation.status</code> + a verify affordance in the data-rights view + <code>profile/data-deletion-client.ts</code>; <code>data-*</code> hooks. <em>(DONE 2026-06-09 — <code>CustomerDataDeletionSection</code> now renders a "Signed erasure receipts" panel per deletion request: for every tombstone that carries a <code>.attestation</code>, it shows the per-service status (erased / no-data-found / FAILED, colour-toned) + rows-removed + the <strong>signed-receipt proof</strong> (<code>auditKeyId</code> + truncated <code>receiptSignatureHex</code>) — the cryptographic verify affordance (each Ed25519 receipt is independently verifiable against the published key). Hooks: <code>data-profile-deletion-attestations</code>, <code>data-profile-deletion-attestation={service}</code>, <code>data-profile-deletion-attestation-status={status}</code>. <strong>3-layer wire, minimal:</strong> the BFF ALREADY attaches the attestation (<code>state.ts:393</code> <code>{...tombstone, attestation: toTombstoneAttestation(...)}</code> from the verified fan-out receipts) — it was just untyped, so I declared <code>attestation?</code> + a structurally-matching <code>OshunCustomerDataDeletionTombstoneAttestation</code> on the <code>@oshun/auth-client</code> tombstone DTO (no new dep; BFF tsc 0 confirms the spread stays compatible), then rendered it. <strong>Honest: only ATTESTED tombstones render a receipt</strong> (unattested → nothing, never fabricated); the real receipt DATA is produced by the deploy-bound deletion fan-out (Redis runner), so dev shows none until that runs — the render + a component test (injected attested tombstone → asserts status/rows/key/sig + exactly-one-receipt) exercise it now. auth tsc 0 + 6 auth tests, web bounded tsc 0, eslint 0, 6 component tests, stub-scan clean. Full CLIENT-SIDE Ed25519 verification (vs. just surfacing the receipt) is the deploy-bound enhancement — needs the published public key.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (tests) Extend data-deletion-route.test.ts + export-deletion-flows. integration.test.ts: after grace expiry every job completed, every tombstone signed; a forced failure → request failed. New A-to-Z: seed real data across the 6 domains, POST account_full, re-query each service and assert erased, + attestations verify. (Partial — verified 2026-06-09: the BEHAVIORAL half is already covered. data-deletion-route.test.ts: "finalizes completed and records a signed attestation on every cascade tombstone", "finalizes FAILED (never completed) when a service is unsatisfied, naming it", immediate completes-with-verified-erasure / no-runner→stays-scheduled / fail-closed →FAILS. deletion-worker.test.ts: "processes nothing before the grace deadline, then completes due deletions after it" + "marks failed when the fan-out is incomplete". REMAINS: the A-to-Z that seeds real data across all 6 domain stores + re-queries each — integration/deploy-bound (needs the live isis/iris/personalization/conversation stores, not the fakes the unit tests inject).) 2026-09-18: open for an agent. The residue is the A-to-Z: seed real rows in the six domain stores on the local Postgres, post account_full, run the worker past the grace period, re-query each store and assert erasure, and verify every attestation."/> <strong>(tests)</strong> Extend <code>data-deletion-route.test.ts</code> + <code>export-deletion-flows. integration.test.ts</code>: after grace expiry every job <code>completed</code>, every tombstone signed; a forced failure → request <code>failed</code>. <strong>New A-to-Z</strong>: seed real data across the 6 domains, POST <code>account_full</code>, <strong>re-query each service and assert erased</strong>, + attestations verify. <em>(Partial — verified 2026-06-09: the BEHAVIORAL half is already covered. <code>data-deletion-route.test.ts</code>: "finalizes completed and records a signed attestation on every cascade tombstone", "finalizes FAILED (never completed) when a service is unsatisfied, naming it", immediate completes-with-verified-erasure / no-runner→stays-scheduled / fail-closed →FAILS. <code>deletion-worker.test.ts</code>: "processes nothing before the grace deadline, then completes due deletions after it" + "marks failed when the fan-out is incomplete". REMAINS: the A-to-Z that seeds real data across all 6 domain stores + re-queries each — integration/deploy-bound (needs the live isis/iris/personalization/conversation stores, not the fakes the unit tests inject).)</em> <em>2026-09-18: open for an agent. The residue is the A-to-Z: seed real rows in the six domain stores on the local Postgres, post <code>account_full</code>, run the worker past the grace period, re-query each store and assert erasure, and verify every attestation.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e) Customer schedules deletion, sees per-service attestation rows resolve; cross-link to the journey. (DONE 2026-06-10 — profile-customer-data-deletion.spec.ts gained the "per-service erasure receipts" leg (4/4 green): a completed request renders exactly one data-profile-deletion-attestation row per ATTESTED tombstone (status erased + "3 rows erased" + audit key id + truncated Ed25519 signature) and NONE for the unattested tombstone. Honest scope: the schedule/cancel legs stay on the REAL BFF; the receipt leg mocks ONLY the snapshot GET at the page boundary with the exact state.ts:393 wire shape, because real receipt data is produced by the deploy-bound fan-out (Redis runner + Ed25519 signer + worker) the e2e harness BFF does not run — "rows RESOLVE live" is therefore a deploy-environment verification, documented as such in the journey's e2e-coverage section. coverage.md row updated. SW blocked for the mocked leg.)" checked=""/> <strong>(e2e)</strong> Customer schedules deletion, sees per-service attestation rows resolve; cross-link to the journey. <em>(DONE 2026-06-10 — <code>profile-customer-data-deletion.spec.ts</code> gained the "per-service erasure receipts" leg (4/4 green): a completed request renders exactly one <code>data-profile-deletion-attestation</code> row per ATTESTED tombstone (status <code>erased</code> + "3 rows erased" + audit key id + truncated Ed25519 signature) and NONE for the unattested tombstone. Honest scope: the schedule/cancel legs stay on the REAL BFF; the receipt leg mocks ONLY the snapshot GET at the page boundary with the exact <code>state.ts:393</code> wire shape, because real receipt data is produced by the deploy-bound fan-out (Redis runner + Ed25519 signer + worker) the e2e harness BFF does not run — "rows RESOLVE live" is therefore a deploy-environment verification, documented as such in the journey's e2e-coverage section. coverage.md row updated. SW blocked for the mocked leg.)</em></li> </ul> </li> <li><strong>2026-06-09 session update — all 6 categories now ERASE for real, end-to-end.</strong> The earlier <code>[x]</code> erasers were ports awaiting backing stores; this session built <ul> <li>wired them live in <code>server.ts</code> (conditional on Redis + the attestation signer, fail-closed; default boot unchanged):</li> </ul> <ul> <li><strong>generated_artifact</strong> → real isis <code>GeneratedOutput.deleteMany({ownerId})</code> via <code>@oshun/privacy/deletion-erasers</code> (lazy <code>@isis/database</code> import behind the module boundary). Loader bug fixed first — the Prisma-7 generator emits <code>client.ts</code>, not <code>index.js</code>, so every consumer threw "client not generated". SQL-proven against the live local isis DB.</li> <li><strong>personalization_vector</strong> + <strong>conversation_history</strong> → new durable snapshot- backed BFF stores (<code>src/personalization/</code>, <code>src/conversation/</code>), each fed by a real cross-process event consumer (<code>personalization.vector.updated</code> / <code>conversation.turn.recorded</code>) and erased per-subject. Proven over live Redis (publish → durably recorded → eraser removes exactly that subject).</li> <li><strong>memory_scope</strong> → real <code>MemoryService.deleteAllForUser</code> in iris/api (sweeps every tier via <code>userMemories</code> + core blocks, durable across restart), erased by iris's OWN event-driven deletion consumer. After extracting the fan-out core to <code>@oshun/deletion-fanout</code> (scope:shared), iris runs <code>startMemoryDeletionConsumer</code> (<code>apps/iris/api/src/deletion/</code>) which subscribes to <code>subject.deletion.requested</code> on the shared bus, erases, signs an Ed25519 receipt, and replies — the BFF orchestrator verifies it cross-process. (Initially shipped as a token-guarded HTTP route, then re-wired event-driven once the primitives were shared — the architecturally-consistent design, since scope:iris can import scope:shared but not scope:oshun.)</li> <li><strong>voice_profile / avatar_pack</strong> → honest no-subject-data (shared catalogs).</li> <li>Server-boot fan-out wiring proven over live Redis; the grace-expiry worker + bus teardown wired; BFF + iris tsc/eslint clean. Commits <code>bfda1c51f4</code>, <code>268a6b6e70</code>, <code>1394a89afa</code>, <code>a5e0747aef</code>, <code>77ec1adb6a</code>, <code>ff350b5e82</code>. Still open (left <code>[ ]</code> above): the (web) attestation UI, the single (tests) A-to-Z route test, and the (e2e).</li> </ul> </li> <li><strong>Acceptance criteria:</strong> An <code>account_full</code> deletion makes a real delete call to each of the 6 services, produces one verifiable Ed25519 attestation per tombstone, sets <code>completed</code> iff all succeeded/absent else <code>failed</code> with the offending service, and an integration test reads each service back and finds the subject erased.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/bff && npx vitest run src/__tests__/data-deletion-route.test.ts src/__tests__/export-deletion-flows.integration.test.ts</code> · <code>cd libs/oshun/privacy && npx vitest run src/export-deletion/deletion-fanout.test.ts</code></li> <li><strong>Effort / risk:</strong> <strong>L.</strong> Risk: a domain may lack <code>deleteForSubject</code> (per-domain work, possible hard blocker); subject→records enumeration is non-trivial; keep fail-closed (a missing delete API must surface as <code>failed</code>, never silent <code>completed</code>).</li> </ul> <h2 id="42-library-cross-device-item-sync--inline-paywall" class="hd hd-l2">4.2 Library cross-device item sync + inline paywall<a class="hd-anchor" href="#42-library-cross-device-item-sync--inline-paywall" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> Saved items are <strong>localStorage-only on web</strong> (<code>apps/oshun/web/src/lib/library/webLibraryStore.ts:12,105-180</code>, no server call). <code>GET /v1/library</code> is read-only aggregation, <strong>no POST/DELETE to save arbitrary items</strong> (<code>routes/library.ts:189-311</code>). <code>library-sharing.ts</code> governs sharing only (its comment says collections CRUD is elsewhere + the web store "lacked" server sync, <code>:14-18</code>). A <strong>real cross-device sync engine exists, unused</strong>: <code>@oshun/customer-curation/bookmarks</code> (<code>DeviceCursor</code>, <code>updateCursor</code>/<code>farthestCursor</code>/<code>resumeCursor</code> with a defined conflict policy, <code>bookmarks.ts:33-158</code>). <strong>Paywall UI already built</strong> (<code>OshunPaywallCard.tsx</code>, <code>ShellFeatureGate.tsx</code>). <code>GET /v1/entitlements/aaa</code> exists (<code>entitlements.ts:101-106</code>).</li> <li><strong>The gap:</strong> No BFF item-store; no sync routes; conflict policy unbound; web store never reads/writes server; no inline paywall on the save affordance.</li> <li><strong>External dependency:</strong> None for sync (pure code; durable via Postgres snapshot). The paywall's upgrade <em>purchase</em> depends on 4.3, but the gate + CTA are pure code.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (contract) libs/contracts/src/common/library-saved-items.ts: SavedLibraryItemSchema, LibrarySyncRequestSchema{deviceId,items[],lastSyncRevision}, LibrarySyncResponseSchema{revision,items,conflictsResolved}. (Built + WIRED into all three layers as the single source of truth (not orphaned): zod v4 schemas + LibrarySnapshotSchema + LibrarySaveItemRequestSchema, exported via the common barrel. The BFF store now imports the canonical types from @oshun/contracts (re-exports them so its route+test consumers are untouched); the BFF route's PUT /sync now validates the body with LibrarySyncRequestSchema.safeParse (replacing the hand-rolled isSavedItemArray — the malformed-body 400 is now contract-driven and deep-validates every item in the batch); the web savedItemsClient aliases its *Dto names to the contract types (dropping the "promote later" duplicate). 18 contract spec tests (domain-correctness: rejects missing-deleted/empty-domain/non-int-timestamp/negative-clock, deep-item validation, optional-metadata passthrough). All green: contracts 18/18 + tsc 0, BFF store+route 15/15 + tsc 0, web client 5/5 + bounded tsc 0, stub-scan + eslint clean. The web webLibraryStore refactor + inline paywall remain the frontend (browser-gated) pieces.)" checked=""/> <strong>(contract)</strong> <code>libs/contracts/src/common/library-saved-items.ts</code>: <code>SavedLibraryItemSchema</code>, <code>LibrarySyncRequestSchema{deviceId,items[],lastSyncRevision}</code>, <code>LibrarySyncResponseSchema{revision,items,conflictsResolved}</code>. <em>(Built + WIRED into all three layers as the single source of truth (not orphaned): zod v4 schemas + <code>LibrarySnapshotSchema</code> + <code>LibrarySaveItemRequestSchema</code>, exported via the <code>common</code> barrel. The BFF store now imports the canonical types from <code>@oshun/contracts</code> (re-exports them so its route+test consumers are untouched); the BFF route's <code>PUT /sync</code> now validates the body with <code>LibrarySyncRequestSchema.safeParse</code> (replacing the hand-rolled <code>isSavedItemArray</code> — the malformed-body 400 is now contract-driven and deep-validates every item in the batch); the web <code>savedItemsClient</code> aliases its <code>*Dto</code> names to the contract types (dropping the "promote later" duplicate). 18 contract spec tests (domain-correctness: rejects missing-<code>deleted</code>/empty-domain/non-int-timestamp/negative-clock, deep-item validation, optional-metadata passthrough). All green: contracts 18/18 + tsc 0, BFF store+route 15/15 + tsc 0, web client 5/5 + bounded tsc 0, stub-scan + eslint clean. The web <code>webLibraryStore</code> refactor + inline paywall remain the frontend (browser-gated) pieces.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (BFF store) apps/oshun/bff/src/library/saved-items-store.ts: per-user Map + revision, snapshot-durable; bind reconciliation to @oshun/customer-curation/bookmarks updateCursor/farthestCursor (don't reimplement); getSnapshot/applySync/save/ unsave. (LWW + tombstones implemented DIRECTLY — the bookmarks engine models per-document reading CURSORS, not a saved-item SET, so it is the wrong abstraction; the same last-write-wins-by-timestamp policy is applied to the item set. types are in the store file; promoting to libs/contracts zod schemas is the deferred web-layer task. SNAPSHOT-DURABLE 2026-06-09: SavedLibraryItemsSnapshot persists every user's item set TOMBSTONES INCLUDED (the deleted markers must persist — they're how an unsave propagates cross-device via applySync; dropping them would resurrect a deleted item on the next sync) + bindSnapshotSink/restoreSnapshot + persist() on the merge chokepoint (save/unsave) AND applySync; wireDurableSavedLibraryItems(store) wired in server.ts on the SAME deploy-bound DurableSnapshotStore as the sibling stores. So a member's saved library survives a restart instead of every member's library being wiped on deploy.)" checked=""/> <strong>(BFF store)</strong> <code>apps/oshun/bff/src/library/saved-items-store.ts</code>: per-user Map + revision, snapshot-durable; bind reconciliation to <code>@oshun/customer-curation/bookmarks</code> <code>updateCursor</code>/<code>farthestCursor</code> (don't reimplement); <code>getSnapshot</code>/<code>applySync</code>/<code>save</code>/ <code>unsave</code>. <em>(LWW + tombstones implemented DIRECTLY — the bookmarks engine models per-document reading CURSORS, not a saved-item SET, so it is the wrong abstraction; the same last-write-wins-by-timestamp policy is applied to the item set. types are in the store file; promoting to libs/contracts zod schemas is the deferred web-layer task. <strong>SNAPSHOT-DURABLE 2026-06-09:</strong> <code>SavedLibraryItemsSnapshot</code> persists every user's item set TOMBSTONES INCLUDED (the deleted markers must persist — they're how an unsave propagates cross-device via <code>applySync</code>; dropping them would resurrect a deleted item on the next sync) + <code>bindSnapshotSink</code>/<code>restoreSnapshot</code> + <code>persist()</code> on the <code>merge</code> chokepoint (save/unsave) AND <code>applySync</code>; <code>wireDurableSavedLibraryItems(store)</code> wired in <code>server.ts</code> on the SAME deploy-bound <code>DurableSnapshotStore</code> as the sibling stores. So a member's saved library survives a restart instead of every member's library being wiped on deploy.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (store test) last-write-wins by lastSeenUnixSeconds, regressive rejected, two devices converge to farthestCursor, revision monotonic, tenant isolation. (13 tests incl. tombstone propagation + sticky-tombstone tie-break + 3 durability: a saved item survives a restart; an unsave TOMBSTONE survives so a later sync still propagates the delete; per-user isolation across re-hydrate.)" checked=""/> <strong>(store test)</strong> last-write-wins by <code>lastSeenUnixSeconds</code>, regressive rejected, two devices converge to <code>farthestCursor</code>, revision monotonic, tenant isolation. <em>(13 tests incl. tombstone propagation + sticky-tombstone tie-break + 3 durability: a saved item survives a restart; an unsave TOMBSTONE survives so a later sync still propagates the delete; per-user isolation across re-hydrate.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (routes) GET /v1/library/saved-items, PUT /v1/library/saved-items/sync, POST/DELETE /v1/library/saved-items/:domain/:itemId; reuse library.ts:90-91 preHandlers + resolveAuthorizedShellDomains; register near registerLibraryRoutes. (user-scoped via authContext.userId; registered in app.ts beside registerLibraryRoutes.)" checked=""/> <strong>(routes)</strong> <code>GET /v1/library/saved-items</code>, <code>PUT /v1/library/saved-items/sync</code>, <code>POST</code>/<code>DELETE /v1/library/saved-items/:domain/:itemId</code>; reuse <code>library.ts:90-91</code> preHandlers + <code>resolveAuthorizedShellDomains</code>; register near <code>registerLibraryRoutes</code>. <em>(user-scoped via authContext.userId; registered in app.ts beside registerLibraryRoutes.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (route test) 401; sync round-trips; conflictsResolved surfaced; schema-validated. (5 tests incl. user isolation + malformed-body 400.)" checked=""/> <strong>(route test)</strong> 401; sync round-trips; <code>conflictsResolved</code> surfaced; schema-validated. <em>(5 tests incl. user isolation + malformed-body 400.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (web data layer) apps/oshun/web/src/lib/library/savedItemsClient.ts (mirror profile/data-deletion-client.ts). (getSnapshot/sync/save/unsave over the shared api client; 5 tests. The webLibraryStore refactor that consumes it + the inline paywall remain.)" checked=""/> <strong>(web data layer)</strong> <code>apps/oshun/web/src/lib/library/savedItemsClient.ts</code> (mirror <code>profile/data-deletion-client.ts</code>). <em>(getSnapshot/sync/save/unsave over the shared <code>api</code> client; 5 tests. The webLibraryStore refactor that consumes it + the inline paywall remain.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (web store) Refactor webLibraryStore.ts: localStorage as offline cache; hydrate() pulls GET, save/unsave optimistic then PUT .../sync with a stable deviceId; reconcile server response. (Store refactor DONE 2026-06-09 — webLibraryStore is now backed by the per-user BFF store for a SIGNED-IN member, cross-device. New lib/library/librarySync.ts (pure, tested) maps the RICH web item ↔ the THIN @oshun/contracts SavedLibraryItem (display fields ride in metadata), plus mergeServerSnapshot (surviving demo items stay; server real-saves are authoritative → an unsave on device A propagates as absence). The store: loadLocalCache() keeps localStorage as the offline cache; hydrate() then reconciles via GET for an authed member; save/unsave stay SYNCHRONOUS + optimistic (the ~15 UI call sites + the existing test are untouched) and fire a fail-soft background push — per-item POST/DELETE (immediate propagation; DELETE is the verb 072ad5eaba CORS-unblocked) rather than the checklist's PUT /sync+deviceId batch. Showcase/demo items are NEVER pushed (excluded by id → no account pollution; guests stay localStorage-only with the defaults — no product decision forced). All server calls fail-soft (a sync error never throws into the synchronous path). 11 new tests (6 pure librarySync + 5 store-sync: POST-maps-payload, DELETE-on-unsave, demo-never-pushed, hydrate-reconciles-server-item-while-demo-survives, fail-soft) + the existing 4 backward-compat tests all green; bounded tsc 0, eslint 0, stub-scan clean. DEFERRED: the PUT /sync+deviceId offline-BATCH reconcile (per-item online sync covers the common case), the cross-device BROWSER e2e (2 contexts + a UI save affordance), and the inline paywall (next box).) 2026-09-18: open for an agent. The residue is the offline batch reconcile (PUT …/sync with a stable deviceId) and a two-context browser e2e that saves on one device and reads on the other."/> <strong>(web store)</strong> Refactor <code>webLibraryStore.ts</code>: localStorage as offline cache; <code>hydrate()</code> pulls <code>GET</code>, save/unsave optimistic then <code>PUT .../sync</code> with a stable <code>deviceId</code>; reconcile server response. <em>(Store refactor DONE 2026-06-09 — <code>webLibraryStore</code> is now backed by the per-user BFF store for a SIGNED-IN member, cross-device. New <code>lib/library/librarySync.ts</code> (pure, tested) maps the RICH web item ↔ the THIN <code>@oshun/contracts</code> <code>SavedLibraryItem</code> (display fields ride in <code>metadata</code>), plus <code>mergeServerSnapshot</code> (surviving demo items stay; server real-saves are authoritative → an unsave on device A propagates as absence). The store: <code>loadLocalCache()</code> keeps localStorage as the offline cache; <code>hydrate()</code> then reconciles via <code>GET</code> for an authed member; <code>save</code>/<code>unsave</code> stay SYNCHRONOUS + optimistic (the ~15 UI call sites + the existing test are untouched) and fire a fail-soft background push — <strong>per-item <code>POST</code>/<code>DELETE</code></strong> (immediate propagation; DELETE is the verb <code>072ad5eaba</code> CORS-unblocked) rather than the checklist's <code>PUT /sync</code>+<code>deviceId</code> batch. <strong>Showcase/demo items are NEVER pushed</strong> (excluded by id → no account pollution; guests stay localStorage-only with the defaults — no product decision forced). All server calls fail-soft (a sync error never throws into the synchronous path). 11 new tests (6 pure <code>librarySync</code> + 5 store-sync: POST-maps-payload, DELETE-on-unsave, demo-never-pushed, hydrate-reconciles-server-item-while-demo-survives, fail-soft) + the existing 4 backward-compat tests all green; bounded tsc 0, eslint 0, stub-scan clean. <strong>DEFERRED:</strong> the <code>PUT /sync</code>+<code>deviceId</code> offline-BATCH reconcile (per-item online sync covers the common case), the cross-device BROWSER e2e (2 contexts + a UI save affordance), and the inline paywall (next box).)</em> <em>2026-09-18: open for an agent. The residue is the offline batch reconcile (<code>PUT …/sync</code> with a stable <code>deviceId</code>) and a two-context browser e2e that saves on one device and reads on the other.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (web paywall) Wrap the save affordance (callers of saveOshunWebLibraryItem, e.g. LibraryDashboard.tsx/ShellSavedQueuePanel.tsx) with ShellFeatureGate so exceeding the cap renders OshunPaywallCard inline; read the limit from resolveProfileEntitlements; optionally surface /v1/entitlements/aaa. (DONE 2026-06-10 — product decision provided via AskUserQuestion: free members keep 25 saved items; Pro lifts the cap. Model: shell.library.saved_items added to OshunShellFeatureKey (new 'library' category, anchorDomain nisaba, minimumTier free, limit 25, period lifetime) + a NEW unlimitedAtTier rule field — the existing minimumTier+limit pair could not express "free capped, Pro unlimited"; at/above the lift tier the evaluation reports limit:null, and a limit_exceeded verdict's requiredTier becomes the LIFT tier (the actionable upgrade target). Web: components/billing/LibrarySaveGate.tsx (useLibrarySaveCapEvaluation over the LIVE real-save count — bundled showcase items never consume the cap — + LibrarySaveCapNotice composing ShellFeatureGate→OshunPaywallCard); rendered standing on LibraryDashboard and attempt-triggered above the results on SearchResultsView (whose save handler blocks the save direction at cap; unsave always works). PLUS the store chokepoint: webLibraryStore.save() itself refuses a 26th real save (canSaveAnotherRealItem via evaluateShellFeatureAccess over oshunWebProfileStore) so EVERY scattered save call site enforces the policy — no silent over-cap saves anywhere, never a fabricated success. NB the web SEED profile is the 'pro' showcase plan, so guests keep the showcase posture; the cap binds when a real free member's plan hydrates. 4 lib tests + 4 gate component tests + 3 store-chokepoint tests; auth lib tsc + web tsc + eslint + stub-scan clean; 144 tests green across the touched suites.)" checked=""/> <strong>(web paywall)</strong> Wrap the save affordance (callers of <code>saveOshunWebLibraryItem</code>, e.g. <code>LibraryDashboard.tsx</code>/<code>ShellSavedQueuePanel.tsx</code>) with <code>ShellFeatureGate</code> so exceeding the cap renders <code>OshunPaywallCard</code> inline; read the limit from <code>resolveProfileEntitlements</code>; optionally surface <code>/v1/entitlements/aaa</code>. <em>(DONE 2026-06-10 — <strong>product decision provided via AskUserQuestion: free members keep 25 saved items; Pro lifts the cap.</strong> Model: <code>shell.library.saved_items</code> added to <code>OshunShellFeatureKey</code> (new 'library' category, anchorDomain nisaba, minimumTier free, limit 25, period lifetime) + a NEW <code>unlimitedAtTier</code> rule field — the existing minimumTier+limit pair could not express "free capped, Pro unlimited"; at/above the lift tier the evaluation reports limit:null, and a limit_exceeded verdict's <code>requiredTier</code> becomes the LIFT tier (the actionable upgrade target). Web: <code>components/billing/LibrarySaveGate.tsx</code> (<code>useLibrarySaveCapEvaluation</code> over the LIVE real-save count — bundled showcase items never consume the cap — + <code>LibrarySaveCapNotice</code> composing ShellFeatureGate→OshunPaywallCard); rendered standing on <code>LibraryDashboard</code> and attempt-triggered above the results on <code>SearchResultsView</code> (whose save handler blocks the save direction at cap; unsave always works). PLUS the <strong>store chokepoint</strong>: <code>webLibraryStore.save()</code> itself refuses a 26th real save (<code>canSaveAnotherRealItem</code> via <code>evaluateShellFeatureAccess</code> over <code>oshunWebProfileStore</code>) so EVERY scattered save call site enforces the policy — no silent over-cap saves anywhere, never a fabricated success. NB the web SEED profile is the 'pro' showcase plan, so guests keep the showcase posture; the cap binds when a real free member's plan hydrates. 4 lib tests + 4 gate component tests + 3 store-chokepoint tests; auth lib tsc + web tsc + eslint + stub-scan clean; 144 tests green across the touched suites.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (component test) Inline paywall renders at the limit (data-oshun-paywall-card, data-oshun-paywall-action="upgrade"); save blocked. (DONE 2026-06-10 — LibrarySaveGate.test.tsx: nothing below the cap; AT the cap the card renders with data-oshun-paywall-action="upgrade" and action-target="pro" (the lift tier); a Pro member is never capped at any count; showcase items don't consume the cap. Save-blocked pinned at the store chokepoint (webLibraryStore.test.ts: 26th real save → false + no mutation; unsave frees room; demo ids exempt — with the profile plan pinned 'free' since the seed profile is the pro showcase).)" checked=""/> <strong>(component test)</strong> Inline paywall renders at the limit (<code>data-oshun-paywall-card</code>, <code>data-oshun-paywall-action="upgrade"</code>); save blocked. <em>(DONE 2026-06-10 — <code>LibrarySaveGate.test.tsx</code>: nothing below the cap; AT the cap the card renders with <code>data-oshun-paywall-action="upgrade"</code> and <code>action-target="pro"</code> (the lift tier); a Pro member is never capped at any count; showcase items don't consume the cap. Save-blocked pinned at the store chokepoint (<code>webLibraryStore.test.ts</code>: 26th real save → false + no mutation; unsave frees room; demo ids exempt — with the profile plan pinned 'free' since the seed profile is the pro showcase).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e) Extend library-entitlement-and-save.spec.ts: save on device A → device B (fresh storage) sees it from the server; hit the limit → inline paywall + CTA. (LIMIT-HIT LEG DONE 2026-06-10 — completing the box: a new "Saved-items cap — inline paywall at the limit" describe (spec now 11/11 green): a FRESH signup (a REAL free member — the session plan syncs into the profile store via auth-context syncProfileStore, so the cap binds in the browser exactly as in production) gets 25 real items seeded into their server store, opens /library (SW blocked), and the standing LibrarySaveCapNotice renders the inline data-oshun-paywall-card with data-oshun-paywall-action="upgrade" targeting pro (the lift tier). Showcase items don't count, so the card appears precisely because of the 25 real saves.) (Cross-device server contract DONE 2026-06-09 — added a "Cross-device saved-items — BFF" describe (4 tests, GREEN; full spec 10/10): a save (POST) on one device is seen by a GET on another for the SAME user, an unsave (DELETE) propagates as absence, PUT /sync is last-write-wins (a stale clock loses + is counted in conflictsResolved, the fresher copy's title/clock win), a far-future tombstone in /sync propagates the delete, and saves are isolated per user (+ 401 without a bearer). This is the "sees it from the server" / LWW-reconcile contract behind the webLibraryStore sync (3895c6855b). Pattern from the spec's existing pure-BFF request+devToken style. GOTCHA (re-hit): a body-less DELETE must NOT carry content-type: application/json — Fastify 400s parsing the empty body — so send only the bearer. eslint 0, stub-scan clean. BROWSER render leg ALSO DONE 2026-06-09 — new e2e/library-cross-device-render.spec.ts (GREEN, incl. auto-axe): seeds a real item into the per-user server store (via the session's access token, same user) then opens /library in a fresh browser (SW blocked, no local copy) and asserts the dashboard hydrates useOshunWebLibraryStore from GET /v1/library/saved-items and RENDERS the reconstructed item's title — the user-visible "device B sees it from the server". (uid-match confirmed: seeding with session.tokens.accessToken targets the same user the browser's hydrated token reads as.) So the "save on A → device B sees it from the server" half is now FULLY covered (server contract + browser render). **Only "hit the limit → inline paywall + CTA" remains — and that is PRODUCT-BLOCKED (see the paywall box below: no saved-items entitlement/cap exists).)" checked=""/> <strong>(e2e)</strong> Extend <code>library-entitlement-and-save.spec.ts</code>: save on device A → device B (fresh storage) sees it from the server; hit the limit → inline paywall + CTA. <em>(LIMIT-HIT LEG DONE 2026-06-10 — completing the box: a new "Saved-items cap — inline paywall at the limit" describe (spec now 11/11 green): a FRESH signup (a REAL free member — the session plan syncs into the profile store via auth-context <code>syncProfileStore</code>, so the cap binds in the browser exactly as in production) gets 25 real items seeded into their server store, opens /library (SW blocked), and the standing <code>LibrarySaveCapNotice</code> renders the inline <code>data-oshun-paywall-card</code> with <code>data-oshun-paywall-action="upgrade"</code> targeting <code>pro</code> (the lift tier). Showcase items don't count, so the card appears precisely because of the 25 real saves.)</em> <em>(Cross-device server contract DONE 2026-06-09 — added a "Cross-device saved-items — BFF" describe (4 tests, GREEN; full spec 10/10): a save (POST) on one device is seen by a GET on another for the SAME user, an unsave (DELETE) propagates as absence, <code>PUT /sync</code> is last-write-wins (a stale clock loses + is counted in <code>conflictsResolved</code>, the fresher copy's title/clock win), a far-future tombstone in <code>/sync</code> propagates the delete, and saves are isolated per user (+ 401 without a bearer). This is the "sees it from the server" / LWW-reconcile contract behind the webLibraryStore sync (<code>3895c6855b</code>). Pattern from the spec's existing pure-BFF <code>request</code>+<code>devToken</code> style. <strong>GOTCHA (re-hit): a body-less DELETE must NOT carry <code>content-type: application/json</code> — Fastify 400s parsing the empty body — so send only the bearer.</strong> eslint 0, stub-scan clean. <strong>BROWSER render leg ALSO DONE 2026-06-09</strong> — new <code>e2e/library-cross-device-render.spec.ts</code> (GREEN, incl. auto-axe): seeds a real item into the per-user server store (via the session's access token, same user) then opens <code>/library</code> in a fresh browser (SW blocked, no local copy) and asserts the dashboard hydrates <code>useOshunWebLibraryStore</code> from <code>GET /v1/library/saved-items</code> and RENDERS the reconstructed item's title — the user-visible "device B sees it from the server". (uid-match confirmed: seeding with <code>session.tokens.accessToken</code> targets the same user the browser's hydrated token reads as.) So the "save on A → device B sees it from the server" half is now FULLY covered (server contract + browser render). **Only "hit the limit → inline paywall + CTA" remains — and that is PRODUCT-BLOCKED (see the paywall box below: no saved-items entitlement/cap exists).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (deploy) No new creds; note durability needs OSHUN_V1_DATABASE_URL; document the store in §1's data table. (DONE 2026-06-10 — §1's Postgres row now names the per-user saved-library-items store (cross-device saves + the 25-item free cap's source of truth) as riding the same durable snapshot store under OSHUN_V1_DATABASE_URL, no new creds.)" checked=""/> <strong>(deploy)</strong> No new creds; note durability needs <code>OSHUN_V1_DATABASE_URL</code>; document the store in §1's data table. <em>(DONE 2026-06-10 — §1's Postgres row now names the per-user saved-library-items store (cross-device saves + the 25-item free cap's source of truth) as riding the same durable snapshot store under <code>OSHUN_V1_DATABASE_URL</code>, no new creds.)</em></li> </ul> </li> <li><strong>Acceptance criteria:</strong> An item saved on device A appears on device B after sync; concurrent edits converge per the bookmarks policy; the save affordance renders the paywall inline + working upgrade CTA at the limit; server state survives restart with Postgres.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/bff && npx vitest run src/library/saved-items-store.test.ts</code> · <code>cd libs/oshun/customer-curation && npx vitest run src/bookmarks/bookmarks.test.ts</code> · <code>cd apps/oshun/web && npx playwright test e2e/library-entitlement-and-save.spec.ts --workers=1</code></li> <li><strong>Effort / risk:</strong> <strong>M.</strong> Low risk (conflict engine + paywall UI exist). Risk: offline/online merge edges + stable <code>deviceId</code>; keep localStorage as cache.</li> </ul> <h2 id="43-payments-crypto-settlement-in-repo-composition-built-2026-06-09--live-provisioner-deploy-bound" class="hd hd-l2">4.3 Payments crypto settlement <strong>(in-repo composition BUILT 2026-06-09 — live provisioner deploy-bound)</strong><a class="hd-anchor" href="#43-payments-crypto-settlement-in-repo-composition-built-2026-06-09--live-provisioner-deploy-bound" aria-label="Permalink to this section">#</a></h2> <blockquote> <p><strong>2026-06-09 — user chose to build the fail-closed composition.</strong> BUILT + tested (18 tests, commit <code>ec6897241d</code>): <code>payments/invoice-store.ts</code> (snapshot-durable ledger + confirm/expiry), <code>payments/payments-composition.ts</code> (the composition root: <code>resolvePaymentsReceiptSigner</code> env-resolver [fail-closed null / fail-loud on > a bad key], <code>isPaymentsConfigured</code>, the <code>InvoiceTargetProvisioner</code> PORT, the runtime + bind), <code>payments/quote-builder.ts</code> (validate → <code>gateInvoiceCreation</code> disclosure → provision → assemble the web <code>PaymentsBridgeInvoiceDto</code> → persist), and the three routes (methods = REAL <code>listSupportedAssets</code>; invoices = REAL ledger; quote = fail-closed 503 / real 201 DTO with a bound runtime). <strong>Architecture note:</strong> the lib deliberately omits the fiat→chain amount conversion (asset-precise bigint fixed-point) + the live price feed — those are the settlement provider's job, so the single <code>InvoiceTargetProvisioner</code> PORT owns rate + amount + per-charge address (subsuming the separate <code>price-feed-fetcher</code>/<code>address-provisioner</code> items). DEPLOY wires the real provisioner (BTCPay/OpenNode/@aje) + the webhook + the receipt key (<code>OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY</code> + <code>_AUDIT_KEY_ID</code>). In-repo the quote is honestly 503 — NEVER a fabricated rate/amount/address.</p> </blockquote> <ul> <li><strong>Status today:</strong> BFF <strong>fails closed</strong> — <code>POST /v1/payments/crypto/quote</code> → 503 in <strong>every</strong> env (<code>domain-stubs.ts:1896-1914</code>); <code>GET /v1/payments/{methods,invoices}</code> → 503 in prod, dev fixture otherwise. The real, multi-chain, <strong>already-integrated</strong> stack is <strong><code>@oshun/payments-bridge</code></strong> (<code>index.ts:1-9</code>: real 3-source median price aggregator <code>oracle-aggregator/price-feed.ts:55-89</code>, Ed25519 <code>ReceiptSigner</code>, paywall builders <code>customer-surface/paywall-spec.ts:13-138</code>, QR, disclosure-gate, state-mapper). Already consumed by the Telegram bot + web billing; the web loader <strong>already defines the wire contract</strong> <code>PaymentsBridgeInvoiceDto</code> (<code>billing/crypto/invoice-loader.ts:47-104</code>) whose <strong>BFF endpoint doesn't exist</strong> (<code>activeFetcher</code> defaults null → "issuance pending"). (<code>@aphrodite/crypto-payments</code> has mocked rate-fetch + simulated EVM — do not wire it.)</li> <li><strong>The gap:</strong> Replace the three fail-closed handlers with payments-bridge-backed handlers that aggregate a live rate, obtain a real per-invoice receiving target, sign a receipt, and return <code>PaymentsBridgeInvoiceDto</code> + real methods/invoices; build the composition root.</li> <li><strong>External dependency:</strong> (a) live rate feed (a <code>PriceFeedFetcher</code> over Kraken + CoinGecko + a Uniswap v3 TWAP RPC; math done, HTTP/RPC deploy-bound <code>price-feed.ts:31-42</code>); (b) chain settlement (BTCPay/OpenNode — the BTC processor is real — or <code>@aje/*</code> RPC / a PSP for EVM/others); (c) operator wallet/derivation for per-charge addresses; (d) an Ed25519 receipt-signing key.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (decision) Wire @oshun/payments-bridge; update §7 to point at it + flag the crypto-payments mocks." checked=""/> <strong>(decision)</strong> Wire <code>@oshun/payments-bridge</code>; update §7 to point at it + flag the crypto-payments mocks.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (composition root) apps/oshun/bff/src/payments/payments-composition.ts: from env build a PriceFeedAggregator (+ real fetcher below), a ReceiptSigner (OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY+auditKeyId), an address provisioner over @aje/*/BTCPay; export isPaymentsConfigured() for fail-closed handlers." checked=""/> <strong>(composition root)</strong> <code>apps/oshun/bff/src/payments/payments-composition.ts</code>: from env build a <code>PriceFeedAggregator</code> (+ real fetcher below), a <code>ReceiptSigner</code> (<code>OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY</code>+<code>auditKeyId</code>), an address provisioner over <code>@aje/*</code>/BTCPay; export <code>isPaymentsConfigured()</code> for fail-closed handlers.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (price fetcher) payments/price-feed-fetcher.ts: real fetch to Kraken + CoinGecko + a Uniswap v3 TWAP via RPC; gate each on its key; <2 sources → fail-closed (no fabricated rate). Unit test with mocked HTTP. 2026-09-18: open for an agent; no note ever said why this was marked partial and the file does not exist. Kraken's public ticker needs no key and CoinGecko's public tier needs none or a free one, so build apps/oshun/bff/src/payments/price-feed-fetcher.ts behind the aggregator, fail closed under two sources, and test it with mocked HTTP. The Uniswap TWAP leg needs an RPC URL and stays off until one is configured."/> <strong>(price fetcher)</strong> <code>payments/price-feed-fetcher.ts</code>: real <code>fetch</code> to Kraken + CoinGecko + a Uniswap v3 TWAP via RPC; gate each on its key; <2 sources → fail-closed (no fabricated rate). Unit test with mocked HTTP. <em>2026-09-18: open for an agent; no note ever said why this was marked partial and the file does not exist. Kraken's public ticker needs no key and CoinGecko's public tier needs none or a free one, so build <code>apps/oshun/bff/src/payments/price-feed-fetcher.ts</code> behind the aggregator, fail closed under two sources, and test it with mocked HTTP. The Uniswap TWAP leg needs an RPC URL and stays off until one is configured.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (address provisioner) payments/address-provisioner.ts: per V1PaymentAsset (state-mapper.ts:14-49) issue a real per-charge target — BTC/LN via BTCPay/OpenNode (reuse the real BitcoinPaymentProcessor), EVM/Solana/TON/Monero via @aje/wallets or a PSP. No provisioner for a chain → omit that asset (fail-loud). 2026-09-18: open for an agent under the install-first rule. payments-composition.ts already declares the InvoiceTargetProvisioner port. Implement it for BTCPay Server against a self-hosted regtest instance in Docker (record the install), omitting every chain that has no provisioner. A mainnet store and its wallet are the owner's and are configuration, not code."/> <strong>(address provisioner)</strong> <code>payments/address-provisioner.ts</code>: per <code>V1PaymentAsset</code> (<code>state-mapper.ts:14-49</code>) issue a real per-charge target — BTC/LN via BTCPay/OpenNode (reuse the real <code>BitcoinPaymentProcessor</code>), EVM/Solana/TON/Monero via <code>@aje/wallets</code> or a PSP. No provisioner for a chain → omit that asset (fail-loud). <em>2026-09-18: open for an agent under the install-first rule. <code>payments-composition.ts</code> already declares the <code>InvoiceTargetProvisioner</code> port. Implement it for BTCPay Server against a self-hosted regtest instance in Docker (record the install), omitting every chain that has no provisioner. A mainnet store and its wallet are the owner's and are configuration, not code.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (invoice store) payments/invoice-store.ts: persist issued invoices (id, asset, amount, fiat, target, expiry, status, signed receipt); snapshot-durable." checked=""/> <strong>(invoice store)</strong> <code>payments/invoice-store.ts</code>: persist issued invoices (id, asset, amount, fiat, target, expiry, status, signed receipt); snapshot-durable.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (quote route) Replace /v1/payments/crypto/quote (move to routes/payments.ts): when configured → validate body → gateInvoiceCreation → PriceFeedAggregator.aggregate (spread-reject) → provision address → build PaymentsBridgeInvoiceDto via make*Paywall (paywall-spec.ts:47-138) → sign → persist → return DTO; keep originGuard+csrfGuard; unconfigured → keep 503." checked=""/> <strong>(quote route)</strong> Replace <code>/v1/payments/crypto/quote</code> (move to <code>routes/payments.ts</code>): when configured → validate body → <code>gateInvoiceCreation</code> → <code>PriceFeedAggregator.aggregate</code> (spread-reject) → provision address → build <code>PaymentsBridgeInvoiceDto</code> via <code>make*Paywall</code> (<code>paywall-spec.ts:47-138</code>) → sign → persist → return DTO; keep <code>originGuard</code>+<code>csrfGuard</code>; unconfigured → keep 503.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (methods/invoices) Real listSupportedAssets() filtered to provisionable assets; invoices from the store; 503-in-prod unconfigured." checked=""/> <strong>(methods/invoices)</strong> Real <code>listSupportedAssets()</code> filtered to provisionable assets; invoices from the store; 503-in-prod unconfigured.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (webhook) POST /v1/payments/crypto/webhook: consume BTCPay/OpenNode/PSP → mapAjeStateToV1WithAudit (state-mapper.ts:255) → emit settled/underpaid/expired + grant entitlements at requiredConfirmations (:125); verify signatures (fail-closed). 2026-09-18: open for an agent. The receiving half exists as POST /v1/payments/crypto/settlements (apps/oshun/bff/src/payments/settlement-route.ts). The residue is the sender: an adapter that turns a BTCPay invoice-settled webhook, signature-verified, into that signed call, proven against the regtest instance of the item above."/> <strong>(webhook)</strong> <code>POST /v1/payments/crypto/webhook</code>: consume BTCPay/OpenNode/PSP → <code>mapAjeStateToV1WithAudit</code> (<code>state-mapper.ts:255</code>) → emit settled/underpaid/expired + grant entitlements at <code>requiredConfirmations</code> (<code>:125</code>); verify signatures (fail-closed). <em>2026-09-18: open for an agent. The receiving half exists as <code>POST /v1/payments/crypto/settlements</code> (<code>apps/oshun/bff/src/payments/settlement-route.ts</code>). The residue is the sender: an adapter that turns a BTCPay invoice-settled webhook, signature-verified, into that signed call, proven against the regtest instance of the item above.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (web) setInvoiceFetcher (invoice-loader.ts:85) at web boot → the new quote endpoint, so CryptoPaywall renders real targets. (DONE 2026-06-10 — the missing piece was a BY-ID read: the quote POST returns the DTO but the store kept only a summary. The ledger now RETAINS the full web DTO at issuance (StoredCryptoInvoiceWebDto on CryptoInvoiceRecord; pre-retention records default null and honestly 404 — never reconstructed from summaries) and GET /v1/payments/invoices/:invoiceId re-serves it with the LIVE status (lazy expiry). Web: invoice-fetcher-wiring.ts (side-effect import from page.tsx — the "production wiring sets this once at server boot" the seam documented) points the loader at that endpoint via bffGet; only a still-pending invoice renders as payable (expired/ confirmed/cancelled/404 → the honest PendingIssuanceNotice). 3 new store tests (full-DTO round-trip, lazy-expired status, unknown→null) + 1 route test (issue → GET by id returns the FULL DTO incl. the real receiving target; 404 unknown) + 4 wiring unit tests (wired fetch → real address in the renderable; 404→not-found; expired→not-found; blank id never calls the BFF). BFF 22/22 + web 19/19 green, both tsc clean.)" checked=""/> <strong>(web)</strong> <code>setInvoiceFetcher</code> (<code>invoice-loader.ts:85</code>) at web boot → the new quote endpoint, so <code>CryptoPaywall</code> renders real targets. <em>(DONE 2026-06-10 — the missing piece was a BY-ID read: the quote POST returns the DTO but the store kept only a summary. The ledger now RETAINS the full web DTO at issuance (<code>StoredCryptoInvoiceWebDto</code> on <code>CryptoInvoiceRecord</code>; pre-retention records default null and honestly 404 — never reconstructed from summaries) and <code>GET /v1/payments/invoices/:invoiceId</code> re-serves it with the LIVE status (lazy expiry). Web: <code>invoice-fetcher-wiring.ts</code> (side-effect import from page.tsx — the "production wiring sets this once at server boot" the seam documented) points the loader at that endpoint via <code>bffGet</code>; only a still-<code>pending</code> invoice renders as payable (expired/ confirmed/cancelled/404 → the honest PendingIssuanceNotice). 3 new store tests (full-DTO round-trip, lazy-expired status, unknown→null) + 1 route test (issue → GET by id returns the FULL DTO incl. the real receiving target; 404 unknown) + 4 wiring unit tests (wired fetch → real address in the renderable; 404→not-found; expired→not-found; blank id never calls the BFF). BFF 22/22 + web 19/19 green, both tsc clean.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (tests) quote-builder (spread-reject; receipt verifies); price-fetcher (<2 sources fail-closed); provisioner (per-charge uniqueness); payments-crypto-route.test.ts (unconfigured 503 all envs for quote, 503-in-prod for methods/invoices; configured → real DTO with non-empty target + signature; CSRF/origin)." checked=""/> <strong>(tests)</strong> quote-builder (spread-reject; receipt verifies); price-fetcher (<2 sources fail-closed); provisioner (per-charge uniqueness); <code>payments-crypto-route.test.ts</code> (unconfigured 503 all envs for quote, 503-in-prod for methods/invoices; configured → real DTO with non-empty target + signature; CSRF/origin).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e) Billing crypto page with the fetcher wired to a test BFF → paywall renders address + QR; cross-link to sign-up-and-pay-crypto. (DONE at the achievable scope 2026-06-10 — the fetcher is LIVE in the e2e run (the server page really calls GET /v1/payments/invoices/:id on the harness BFF) and billing-crypto-paywall.spec.ts re-ran 6/6 GREEN: every unresolvable id 404s → the honest PendingIssuanceNotice. The address+QR RENDER leg cannot run end-to-end in the harness — an invoice can only be issued by a bound settlement runtime (BTCPay/PSP creds, deploy-bound) — so it is pinned by the configured-path unit/route chain instead: route test (full DTO with the real target re-served by id) → invoice-fetcher-wiring.test.ts (wired fetch → the real address lands in the paywall renderable) → CryptoPaywall.test.tsx (the rendered paywall). The spec header's stale "no active fetcher outside production" note rewritten to this honest framing; journey cross-link retained in the header.)" checked=""/> <strong>(e2e)</strong> Billing crypto page with the fetcher wired to a test BFF → paywall renders address + QR; cross-link to <code>sign-up-and-pay-crypto</code>. <em>(DONE at the achievable scope 2026-06-10 — the fetcher is LIVE in the e2e run (the server page really calls <code>GET /v1/payments/invoices/:id</code> on the harness BFF) and <code>billing-crypto-paywall.spec.ts</code> re-ran 6/6 GREEN: every unresolvable id 404s → the honest PendingIssuanceNotice. The address+QR RENDER leg cannot run end-to-end in the harness — an invoice can only be issued by a bound settlement runtime (BTCPay/PSP creds, deploy-bound) — so it is pinned by the configured-path unit/route chain instead: route test (full DTO with the real target re-served by id) → <code>invoice-fetcher-wiring.test.ts</code> (wired fetch → the real address lands in the paywall renderable) → <code>CryptoPaywall.test.tsx</code> (the rendered paywall). The spec header's stale "no active fetcher outside production" note rewritten to this honest framing; journey cross-link retained in the header.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (deploy) Document rate-feed keys, chain RPC/BTCPay/OpenNode/PSP creds, wallet/ xpub config, the receipt key + auditKeyId, webhook secrets in §7. (DONE 2026-06-10 — §7 was factually STALE (still pointed at the superseded @aphrodite/crypto-payments path + "replace the fail-closed stubs", and claimed methods/invoices serve dev fixtures); REWRITTEN to the wired payments-bridge truth: the live quote→provision→sign→persist chain, the by-id DTO endpoint + web wiring, the do-NOT-wire flag on the aphrodite mocks, and the full deploy-input list — receipt signer env pair, 3-source rate-feed access, the InvoiceTargetProvisioner creds per chain family (BTCPay/OpenNode/@aje/PSP; unprovisioned chains omitted fail-loud), and the signature-verified webhook secrets.)" checked=""/> <strong>(deploy)</strong> Document rate-feed keys, chain RPC/BTCPay/OpenNode/PSP creds, wallet/ xpub config, the receipt key + auditKeyId, webhook secrets in §7. <em>(DONE 2026-06-10 — §7 was factually STALE (still pointed at the superseded <code>@aphrodite/crypto-payments</code> path + "replace the fail-closed stubs", and claimed methods/invoices serve dev fixtures); REWRITTEN to the wired payments-bridge truth: the live quote→provision→sign→persist chain, the by-id DTO endpoint + web wiring, the do-NOT-wire flag on the aphrodite mocks, and the full deploy-input list — receipt signer env pair, 3-source rate-feed access, the InvoiceTargetProvisioner creds per chain family (BTCPay/OpenNode/@aje/PSP; unprovisioned chains omitted fail-loud), and the signature-verified webhook secrets.)</em></li> </ul> </li> <li><strong>Acceptance criteria:</strong> With creds, <code>quote</code> returns a real per-charge target + live spread-checked rate + verifiable Ed25519 receipt as <code>PaymentsBridgeInvoiceDto</code>; <code>methods</code> lists only provisionable assets; <code>invoices</code> reflects stored state; a webhook advances status + grants entitlement at confirmation depth; without creds all three stay fail-closed (no fabricated address) in every env; <code>CryptoPaywall</code> renders the real address/QR.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/bff && npx vitest run src/__tests__/payments-crypto-route.test.ts src/payments/</code> · <code>cd libs/oshun/payments-bridge && npx vitest run src/oracle-aggregator/price-feed.test.ts src/receipt-signer/receipt-signer.test.ts src/customer-surface/paywall-spec.test.ts</code></li> <li><strong>Effort / risk:</strong> <strong>L.</strong> Risk: EVM per-charge derivation + on-chain monitoring are genuinely deploy-bound (in-repo EVM monitor is simulated); losing funds if an address is ever fabricated (preserve fail-closed until each chain's provisioner is real); don't re-derive payments-bridge's tested logic.</li> </ul> <hr /> <hr /> <h1 id="part-5--operator--incident--audit--abuse-surfaces" class="hd hd-l1">Part 5 — Operator / Incident / Audit / Abuse Surfaces<a class="hd-anchor" href="#part-5--operator--incident--audit--abuse-surfaces" aria-label="Permalink to this section">#</a></h1> <blockquote> <p><strong>Integration hub:</strong> funnel all events into the single <code>adminAuditEventsStore</code> (<code>apps/oshun/bff/src/admin/admin-audit-events-store.ts:283</code>); pass <code>{auditEventsStore}</code> on every new <code>app.register(...)</code> (as <code>admin-operator-inbox.ts</code> does at <code>app.ts:1005</code>) so 5.1 + 5.3 become searchable in 5.2 for free. Reuse the write-verb template (<code>admin-operator-inbox.ts</code> + <code>operator-inbox-decision-store.ts</code>, <code>admin-metis-byom-decision.ts</code>). <strong>Keep the two incident systems separate</strong> — the lilith <code>/operator/admin</code> triage inbox vs. the admin-app SRE ops system (<code>admin.ts:7389-7510</code>).</p> </blockquote> <h2 id="51-incident-write-verbs-acknowledge--assign--escalate--snooze--resolve" class="hd hd-l2">5.1 Incident write verbs (acknowledge / assign / escalate / snooze / resolve)<a class="hd-anchor" href="#51-incident-write-verbs-acknowledge--assign--escalate--snooze--resolve" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> Wave-7 shipped the selection-reactive console (<code>components/lilith/AdminInboxConsole.tsx:19-125</code>, <code>AdminInboxQueue.tsx</code>) + an <strong>editorial</strong> decision verb (verdict + rationale≥20, <code>admin-operator-inbox.ts:152-167</code>, store <code>operator-inbox-decision-store.ts</code>). The incident ledger <code>/operator/incidents</code> is <strong>read-only</strong> over a static fixture (<code>domain-stubs.ts:1639,447-474</code>). A full incident-lifecycle backend exists but for the <strong>admin SRE app</strong> (<code>admin.ts:7389-7510</code>: mitigate/blast-radius/postmortem/close — no resolve/escalate/assign/acknowledge). The inbox renders a hardcoded fixture (<code>lib/lilith-data/operator-depth.ts:202-260</code>).</li> <li><strong>The gap:</strong> No acknowledge/assign/escalate/snooze/resolve verbs for the lilith inbox INCs; the wave-7 decision is editorial-only + per-operator (doesn't mutate shared incident state).</li> <li><strong>External dependency:</strong> None — pure code (in-memory baseline acceptable; durable when DB set).</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (store) apps/oshun/bff/src/admin/operator-incident-store.ts: OperatorIncident{id,severity:'S1'|'S2'|'S3',status:'open'|'acknowledged'|'escalated'| 'snoozed'|'resolved',assignee,resolutionClass,...,history[]}; seed from the same catalog as operator-depth.ts (INC-2036..2041). Methods: acknowledge (reject 2nd acker → ALREADY_ACKNOWLEDGED), assign, escalate (rationale≥50, monotonic S3→S2→S1), snooze (cap 24h S2 / 7d S3), resolve (valid class + rationale≥50); typed OperatorIncidentError mirroring operator-inbox-decision-store.ts:45-53. (SNAPSHOT-DURABLE 2026-06-09: OperatorIncidentSnapshot + bindSnapshotSink/restoreSnapshot + a private persist() on the single commit() write-chokepoint (one hook covers all 5 verbs); wireDurableOperatorIncidents(store) hydrates on boot then binds the write-through sink, wired in server.ts beside the persona-lifecycle / model-governance / device-tokens stores on the SAME deploy-bound DurableSnapshotStore. restoreSnapshot OVERLAYS the seed. An operator's triage now survives a restart instead of resetting to seeded open.)" checked=""/> <strong>(store)</strong> <code>apps/oshun/bff/src/admin/operator-incident-store.ts</code>: <code>OperatorIncident{id,severity:'S1'|'S2'|'S3',status:'open'|'acknowledged'|'escalated'| 'snoozed'|'resolved',assignee,resolutionClass,...,history[]}</code>; seed from the same catalog as <code>operator-depth.ts</code> (INC-2036..2041). Methods: <code>acknowledge</code> (reject 2nd acker → <code>ALREADY_ACKNOWLEDGED</code>), <code>assign</code>, <code>escalate</code> (rationale≥50, monotonic S3→S2→S1), <code>snooze</code> (cap 24h S2 / 7d S3), <code>resolve</code> (valid class + rationale≥50); typed <code>OperatorIncidentError</code> mirroring <code>operator-inbox-decision-store.ts:45-53</code>. <em>(SNAPSHOT-DURABLE 2026-06-09: <code>OperatorIncidentSnapshot</code> + <code>bindSnapshotSink</code>/<code>restoreSnapshot</code> + a private <code>persist()</code> on the single <code>commit()</code> write-chokepoint (one hook covers all 5 verbs); <code>wireDurableOperatorIncidents(store)</code> hydrates on boot then binds the write-through sink, wired in <code>server.ts</code> beside the persona-lifecycle / model-governance / device-tokens stores on the SAME deploy-bound <code>DurableSnapshotStore</code>. restoreSnapshot OVERLAYS the seed. An operator's triage now survives a restart instead of resetting to seeded <code>open</code>.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (store test) each transition mutates status; concurrent-ack rejects the 2nd; escalate enforces monotonic severity + rationale; snooze caps; resolve requires class; history accumulates one entry per transition. (24 tests — incl. 3 durability cases: a triage sequence survives a restart, a resolved incident stays resolved [re-resolve→ALREADY_RESOLVED], restoreSnapshot overlays-not-replaces.)" checked=""/> <strong>(store test)</strong> each transition mutates status; concurrent-ack rejects the 2nd; escalate enforces monotonic severity + rationale; snooze caps; resolve requires class; <code>history</code> accumulates one entry per transition. <em>(24 tests — incl. 3 durability cases: a triage sequence survives a restart, a resolved incident stays resolved [re-resolve→ALREADY_RESOLVED], restoreSnapshot overlays-not-replaces.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (route) routes/admin-operator-incidents.ts (model on admin-operator-inbox.ts): reuse hasOperatorAdminScope + auth + abuse preHandlers; zod-validate; each verb writes an adminAuditEventsStore.record({eventType:'incident.<verb>', workspaceId:'incident',targetId,deepLinkPath:'/operator/admin'}). Endpoints: GET list, GET :id, POST :id/{acknowledge,assign,escalate,snooze,resolve}; dual-prefix /admin + /v1/admin. (Manual body validation rather than zod — the store validates defensively.)" checked=""/> <strong>(route)</strong> <code>routes/admin-operator-incidents.ts</code> (model on <code>admin-operator-inbox.ts</code>): reuse <code>hasOperatorAdminScope</code> + auth + abuse preHandlers; zod-validate; each verb writes an <code>adminAuditEventsStore.record({eventType:'incident.<verb>', workspaceId:'incident',targetId,deepLinkPath:'/operator/admin'})</code>. Endpoints: GET list, GET :id, POST :id/{acknowledge,assign,escalate,snooze,resolve}; dual-prefix <code>/admin</code> + <code>/v1/admin</code>. <em>(Manual body validation rather than zod — the store validates defensively.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (register) app.register(registerAdminOperatorIncidentsRoutes,{auditEventsStore}) near app.ts:1005 (shared store so 5.2 sees the events)." checked=""/> <strong>(register)</strong> <code>app.register(registerAdminOperatorIncidentsRoutes,{auditEventsStore})</code> near <code>app.ts:1005</code> (shared store so 5.2 sees the events).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (route test) 401/403, 200/201 per verb, 400 short rationale, 409 already_acknowledged; assert an audit event per successful verb. (9 tests; audit asserted via an injected capturing store.)" checked=""/> <strong>(route test)</strong> 401/403, 200/201 per verb, 400 short rationale, 409 <code>already_acknowledged</code>; assert an audit event per successful verb. <em>(9 tests; audit asserted via an injected capturing store.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (UI) components/lilith/AdminInboxIncidentActions.tsx ('use client', plain buttons + constants — keep the client-boundary discipline from AdminInboxConsole.tsx:16-17): Acknowledge / Assign / Escalate (severity + rationale≥50) / Snooze (duration) / Resolve (class + rationale≥50), each POSTing + surfacing honest errors; data-incident-action hooks. Render in the console detail card keyed on selectedId. (Panel built + tested via api.post to the verb routes; honest 403/409/400 errors. NOW RENDERED in AdminInboxConsole's detail card (keyed on selectedId, severity from selected.sev). Remaining: the console reading LIVE status from the GET (vs the operator-depth fixture) so a verb's result repaints after reload — the larger fixture→live rewire.)" checked=""/> <strong>(UI)</strong> <code>components/lilith/AdminInboxIncidentActions.tsx</code> (<code>'use client'</code>, plain buttons + constants — keep the client-boundary discipline from <code>AdminInboxConsole.tsx:16-17</code>): Acknowledge / Assign / Escalate (severity + rationale≥50) / Snooze (duration) / Resolve (class + rationale≥50), each POSTing + surfacing honest errors; <code>data-incident-action</code> hooks. Render in the console detail card keyed on <code>selectedId</code>. <em>(Panel built + tested via <code>api.post</code> to the verb routes; honest 403/409/400 errors. NOW RENDERED in <code>AdminInboxConsole</code>'s detail card (keyed on <code>selectedId</code>, severity from <code>selected.sev</code>). Remaining: the console reading LIVE status from the GET (vs the operator-depth fixture) so a verb's result repaints after reload — the larger fixture→live rewire.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (UI test) rationale-gate on escalate/resolve; severity monotonicity; 403 error surface." checked=""/> <strong>(UI test)</strong> rationale-gate on escalate/resolve; severity monotonicity; 403 error surface.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e) Extend incident-triage.spec.ts (add a **/v1/admin/operator-incidents/** re-issue alongside the existing inbox one): acknowledge → escalate(rationale) → resolve, asserting the status repaints + the bad-actor blocks (escalate/resolve w/o rationale). (DONE 2026-06-09 via Playwright — and first CONFIRMED the wiring memory thought was missing is actually LIVE: AdminInboxIncidentActions is rendered unconditionally in AdminInboxConsole's detail card (AdminInboxConsole.tsx:146-150, incidentId={selectedId} severity={selected.sev}), which /operator/admin renders via AdminInbox → so a later session wired it after the "never rendered" note. Added the **/v1/admin/operator-incidents/** admin-bearer re-issue to the beforeEach (mirroring the inbox one) + two tests: (1) select INC-2039 (S3 — Escalate is offered; S1/INC-2041 correctly hides it) → click Acknowledge → eyebrow repaints "acknowledged" → fill a ≥50-char escalate rationale → "S2 · escalated" → fill a ≥50-char resolve rationale + class → "resolved", each a REAL POST to the operator-incident store; (2) a pure-HTTP rationale-gate test (own skipAxe describe — no page) asserting escalate/resolve below the 50-char floor → 400 RATIONALE_TOO_SHORT, no state change. 11/11 green against a fresh BFF (the store re-seeds open at boot — no HTTP reset, so run on a clean process). GOTCHA: a pure-request test still triggers the auto-axe on about:blank → document-title violation; isolate it in a test.use({ skipAxe: true }) describe (the tenant-sso-config tripwire pattern).)" checked=""/> <strong>(e2e)</strong> Extend <code>incident-triage.spec.ts</code> (add a <code>**/v1/admin/operator-incidents/**</code> re-issue alongside the existing inbox one): acknowledge → escalate(rationale) → resolve, asserting the status repaints + the bad-actor blocks (escalate/resolve w/o rationale). <em>(DONE 2026-06-09 via Playwright — and first CONFIRMED the wiring memory thought was missing is actually LIVE: <code>AdminInboxIncidentActions</code> is rendered unconditionally in <code>AdminInboxConsole</code>'s detail card (<code>AdminInboxConsole.tsx:146-150</code>, <code>incidentId={selectedId}</code> <code>severity={selected.sev}</code>), which <code>/operator/admin</code> renders via <code>AdminInbox</code> → so a later session wired it after the "never rendered" note. Added the <code>**/v1/admin/operator-incidents/**</code> admin-bearer re-issue to the beforeEach (mirroring the inbox one) + two tests: (1) select INC-2039 (S3 — Escalate is offered; S1/INC-2041 correctly hides it) → click Acknowledge → eyebrow repaints "acknowledged" → fill a ≥50-char escalate rationale → "S2 · escalated" → fill a ≥50-char resolve rationale + class → "resolved", each a REAL POST to the operator-incident store; (2) a pure-HTTP rationale-gate test (own <code>skipAxe</code> describe — no page) asserting escalate/resolve below the 50-char floor → 400 RATIONALE_TOO_SHORT, no state change. 11/11 green against a fresh BFF (the store re-seeds <code>open</code> at boot — no HTTP reset, so run on a clean process). GOTCHA: a pure-<code>request</code> test still triggers the auto-axe on <code>about:blank</code> → <code>document-title</code> violation; isolate it in a <code>test.use({ skipAxe: true })</code> describe (the tenant-sso-config tripwire pattern).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (cross-link) Update incident-triage.md "Uncovered" + coverage.md + spec header. (DONE 2026-06-09 — the mandatory triangle for the lifecycle-verbs spec change: spec header Covers: now lists the acknowledge→escalate→resolve + bad-actor coverage; incident-triage.md ## E2E coverage gained a lifecycle-verbs bullet AND its "Uncovered" bullet was corrected (it falsely claimed "no write endpoint is wired to the admin inbox" — that's now wired + covered); coverage.md's incident-triage row notes updated. Grade stays partial (INC creation / on-call paging / deep-link routing still uncovered) · ✅ verified.)" checked=""/> <strong>(cross-link)</strong> Update <code>incident-triage.md</code> "Uncovered" + <code>coverage.md</code> + spec header. <em>(DONE 2026-06-09 — the mandatory triangle for the lifecycle-verbs spec change: spec header <code>Covers:</code> now lists the acknowledge→escalate→resolve + bad-actor coverage; <code>incident-triage.md</code> <code>## E2E coverage</code> gained a lifecycle-verbs bullet AND its "Uncovered" bullet was corrected (it falsely claimed "no write endpoint is wired to the admin inbox" — that's now wired + covered); <code>coverage.md</code>'s incident-triage row notes updated. Grade stays <code>partial</code> (INC creation / on-call paging / deep-link routing still uncovered) · <code>✅ verified</code>.)</em></li> </ul> </li> <li><strong>Acceptance criteria:</strong> Selecting an INC and clicking Acknowledge flips its shared status + assignee; Escalate with rationale bumps severity one step (rejected without); Resolve with class + rationale → resolved; each verb emits a discrete <code>incident.*</code> audit event visible in 5.2; a concurrent second acknowledge is rejected.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/bff && npx vitest run src/__tests__/admin-operator-incidents-route.test.ts src/admin/operator-incident-store.test.ts</code> · <code>cd apps/oshun/web && npx playwright test e2e/incident-triage.spec.ts --workers=1</code></li> <li><strong>Effort / risk:</strong> <strong>L.</strong> Risk: the inbox renders fixture data — the UI must read live status from the new GET (not the fixture) or changes won't render after reload; audit <code>eventType</code> naming must match the <code>incident.*</code> vocabulary so the explorer filters work; don't cross-wire the admin-app SRE incident system.</li> </ul> <h2 id="52-audit-explorer-ui-port-the-existing-admin-app-explorer--quick-win" class="hd hd-l2">5.2 Audit explorer UI <strong>(port the existing admin-app explorer — quick win)</strong><a class="hd-anchor" href="#52-audit-explorer-ui-port-the-existing-admin-app-explorer--quick-win" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> Web <code>/operator/audit</code> is a read-only static table over <code>/v1/audit</code> fixture (<code>app/operator/audit/page.tsx:42-129</code>, <code>domain-stubs.ts:1642,476-512</code>), no filter/search/drill/export (wave-7 added only the empty-branch unit test). <strong>A complete explorer BACKEND already exists</strong>: <code>routes/admin-audit-log.ts</code> (<code>GET /v1/admin/audit-log/ events</code> with actorIds/workspaceIds/eventTypePrefix/needle/time/limit <code>:62-85</code>, saved-investigation CRUD <code>:87-183</code>, share-token <code>:185-208</code>, export json|markdown <code>:210-257</code>; scope <code>admin:*|admin:studio</code>). <strong>A complete explorer UI already exists — but only in the admin app</strong>: <code>apps/oshun/admin/src/components/AdminAuditLogExplorerPanel.tsx</code> (+ proxy routes + test).</li> <li><strong>The gap:</strong> The web operator surface has no explorer; the backend + a reference UI both exist. Port + surface; don't re-implement filter/CRUD/export.</li> <li><strong>External dependency:</strong> None. Caveat: the backend needs <code>admin:*|admin:studio</code> scope — the web operator session must carry it or the proxy must attach it.</li> <li><strong>Granular tasks:</strong> <ul> <li><strong>(web proxy)</strong> <code>app/api/operator/audit-log/events/route.ts</code> + <code>.../investigations/[...]/route.ts</code> forwarding to BFF <code>/v1/admin/audit-log/*</code> with the caller's session (mirror the admin app's <code>admin-bff-proxy</code>), keeping the BFF scope gate authoritative (non-admin → real 403). <em>(No custom proxy route needed for the events read: the web app already proxies <code>/v1/admin/*</code> with the operator session via the shared <code>api</code> client + <code>proxy.ts</code> — exactly how 5.1's <code>AdminInboxIncidentActions</code> hits <code>/v1/admin/operator-incidents/*</code>. The explorer uses <code>api.get('/v1/admin/audit-log/events?…')</code> directly; the BFF scope gate (<code>admin:*</code>|<code>admin:studio</code>) stays authoritative → a real 403 is surfaced honestly. A dedicated investigations-CRUD proxy would only be needed if/when the saved-investigation UI is added.)</em> <em>Not a task (2026-09-18): its own note records that no proxy route is needed, because the web app already proxies <code>/v1/admin/*</code> with the operator session.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (UI) components/lilith/OperatorAuditExplorer.tsx ('use client', port the logic of AdminAuditLogExplorerPanel.tsx restyled with L/LCode/SERIF_L): needle, eventTypePrefix, actor/workspace multiselect, time range, reset; event list with the deep-link drill-down; saved-investigation create/load/delete; JSON + Markdown export. data-testid hooks paralleling the admin panel. (Built — needle, eventTypePrefix, actor/workspace (comma inputs), datetime-local time range, reset, initial load, and the event list with per-event deep-link drill-down + an honest 403/empty surface (never fabricated rows). Saved-investigation create / list / load (re-search the saved filter) / delete + JSON export now done too (against /v1/admin/audit-log/investigations via the api client; "Export JSON" per row → api.get(.../export?format=json) → a client Blob download — 7 unit tests total). Markdown export NOW DONE too — added an additive RequestConfig.responseType:'text' + api.getText(path) to the shared web api-client (the markdown endpoint returns raw text/markdown, which the JSON-parsing api.get could not consume; getText sends an Accept: text/markdown header + returns response.text()). The explorer's exportInvestigation(inv, 'json'|'markdown') now offers an "Export Markdown" button → api.getText(.../export?format=markdown) → a .md text/markdown Blob download. 11 web tests green (3 api-client incl. the raw-text-not-JSON-parsed assertion + 8 explorer incl. the markdown→getText routing test), bounded tsc 0, eslint 0. So the full 5.2 acceptance ("download JSON + Markdown") is met. Browser visual pass + live-BFF e2e remain deferred (RAM-bound).) 2026-09-18: open for an agent. The acceptance is met in unit tests; the residue is the browser visual pass and the live-BFF e2e of the explorer."/> <strong>(UI)</strong> <code>components/lilith/OperatorAuditExplorer.tsx</code> (<code>'use client'</code>, port the logic of <code>AdminAuditLogExplorerPanel.tsx</code> restyled with <code>L</code>/<code>LCode</code>/<code>SERIF_L</code>): needle, eventTypePrefix, actor/workspace multiselect, time range, reset; event list with the deep-link drill-down; saved-investigation create/load/delete; JSON + Markdown export. <code>data-testid</code> hooks paralleling the admin panel. <em>(Built — needle, eventTypePrefix, actor/workspace (comma inputs), <code>datetime-local</code> time range, reset, initial load, and the event list with per-event deep-link drill-down + an honest 403/empty surface (never fabricated rows). <strong>Saved-investigation create / list / load (re-search the saved filter) / delete + JSON export now done too</strong> (against <code>/v1/admin/audit-log/investigations</code> via the <code>api</code> client; "Export JSON" per row → <code>api.get(.../export?format=json)</code> → a client Blob download — 7 unit tests total). <strong>Markdown export NOW DONE too</strong> — added an additive <code>RequestConfig.responseType:'text'</code> + <code>api.getText(path)</code> to the shared web api-client (the markdown endpoint returns raw <code>text/markdown</code>, which the JSON-parsing <code>api.get</code> could not consume; <code>getText</code> sends an <code>Accept: text/markdown</code> header + returns <code>response.text()</code>). The explorer's <code>exportInvestigation(inv, 'json'|'markdown')</code> now offers an "Export Markdown" button → <code>api.getText(.../export?format=markdown)</code> → a <code>.md</code> <code>text/markdown</code> Blob download. 11 web tests green (3 api-client incl. the raw-text-not-JSON-parsed assertion + 8 explorer incl. the markdown→getText routing test), bounded tsc 0, eslint 0. So the full 5.2 acceptance ("download JSON + Markdown") is met. Browser visual pass + live-BFF e2e remain deferred (RAM-bound).)</em> <em>2026-09-18: open for an agent. The acceptance is met in unit tests; the residue is the browser visual pass and the live-BFF e2e of the explorer.</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (page) In app/operator/audit/page.tsx keep the existing ledger table (its rows are e2e-pinned — don't remove) and add <OperatorAuditExplorer/> below; keep the empty-state branch (unit-tested). (Done — the fixture ledger + its empty-state branch are untouched; the explorer renders below it. Existing audit page unit test still green.)" checked=""/> <strong>(page)</strong> In <code>app/operator/audit/page.tsx</code> keep the existing ledger table (its rows are e2e-pinned — don't remove) and add <code><OperatorAuditExplorer/></code> below; keep the empty-state branch (unit-tested). <em>(Done — the fixture ledger + its empty-state branch are untouched; the explorer renders below it. Existing audit page unit test still green.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (entry point) Add an "Audit log" link on /operator/admin (operator.tsx:500-515); flip the tripwire test (tenant-audit-log-investigation.spec.ts:367-375, currently asserts 0) to expect 1 in the same change. (Added a ghost LBtn href="/operator/audit" to the AdminInbox header (operator.tsx); flipped the KNOWN-GAP tripwire (renamed, comment updated, a[href="/operator/audit"] count .toBe(0) → .toBeGreaterThanOrEqual(1)). Deterministic + tsc-clean; the other spec test runs on /operator/incidents (different page) so no ambiguity, and the link is above the console so the 6-INC data-operator-inbox-count e2e is unaffected. Playwright run itself deferred — RAM-bound on this box.)" checked=""/> <strong>(entry point)</strong> Add an "Audit log" link on <code>/operator/admin</code> (<code>operator.tsx:500-515</code>); <strong>flip the tripwire test</strong> (<code>tenant-audit-log-investigation.spec.ts:367-375</code>, currently asserts 0) to expect 1 in the same change. <em>(Added a ghost <code>LBtn href="/operator/audit"</code> to the <code>AdminInbox</code> header (<code>operator.tsx</code>); flipped the KNOWN-GAP tripwire (renamed, comment updated, <code>a[href="/operator/audit"]</code> count <code>.toBe(0)</code> → <code>.toBeGreaterThanOrEqual(1)</code>). Deterministic + tsc-clean; the other spec test runs on <code>/operator/incidents</code> (different page) so no ambiguity, and the link is above the console so the 6-INC <code>data-operator-inbox-count</code> e2e is unaffected. Playwright run itself deferred — RAM-bound on this box.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (UI test) mock fetch; filters issue the right query params; list renders with deep links; create-investigation POSTs; a 403 surfaces an honest error (no fabricated rows). (OperatorAuditExplorer.test.tsx — 4 tests: initial load renders rows + deep link; needle/eventTypePrefix/ actorIds map onto the query params on Search; a 403 surfaces the honest scope error with NO rows; empty result → honest empty state. The create-investigation POST assertion belongs with the deferred CRUD UI.)" checked=""/> <strong>(UI test)</strong> mock fetch; filters issue the right query params; list renders with deep links; create-investigation POSTs; a 403 surfaces an honest error (no fabricated rows). <em>(<code>OperatorAuditExplorer.test.tsx</code> — 4 tests: initial load renders rows + deep link; needle/eventTypePrefix/ actorIds map onto the query params on Search; a 403 surfaces the honest scope error with NO rows; empty result → honest empty state. The create-investigation POST assertion belongs with the deferred CRUD UI.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e) Extend tenant-audit-log-investigation.spec.ts web block (intercept **/api/operator/audit-log/** → real BFF w/ admin bearer): needle + actor narrows the list; create → export(json) → delete via the UI. Remove the "KNOWN GAP" notes (:15-24). (DONE 2026-06-10 — new "Audit explorer UI — investigation round-trip" describe (spec 11/11 green, first run): the explorer's browser calls are bridged to the live BFF with the operator-admin bearer (body-aware — no JSON content-type on body-less requests); the needle + actor land on the REAL GET /v1/admin/audit-log/events wire (request-capture assertions — event-feed CONTENT narrowing over live data needs real operator activity and stays pinned by the BFF route tests); create → the named investigation lists → Export JSON is a real Blob download (waitForEvent('download'), .json filename) → Delete removes it from the UI AND the store (API read-back). The stale header notes (the "explorer UI is a documented V1 gap" framing + the KNOWN-GAP tripwire bullet) rewritten to the shipped reality. SW blocked around the bridge.)" checked=""/> <strong>(e2e)</strong> Extend <code>tenant-audit-log-investigation.spec.ts</code> web block (intercept <code>**/api/operator/audit-log/**</code> → real BFF w/ admin bearer): needle + actor narrows the list; create → export(json) → delete via the UI. Remove the "KNOWN GAP" notes (<code>:15-24</code>). <em>(DONE 2026-06-10 — new "Audit explorer UI — investigation round-trip" describe (spec 11/11 green, first run): the explorer's browser calls are bridged to the live BFF with the operator-admin bearer (body-aware — no JSON content-type on body-less requests); the needle + actor land on the REAL <code>GET /v1/admin/audit-log/events</code> wire (request-capture assertions — event-feed CONTENT narrowing over live data needs real operator activity and stays pinned by the BFF route tests); create → the named investigation lists → <strong>Export JSON is a real Blob download</strong> (<code>waitForEvent('download')</code>, .json filename) → Delete removes it from the UI AND the store (API read-back). The stale header notes (the "explorer UI is a documented V1 gap" framing + the KNOWN-GAP tripwire bullet) rewritten to the shipped reality. SW blocked around the bridge.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (cross-link) Update the journey + coverage.md. (DONE 2026-06-10 — the journey doc's E2E section gained the explorer round-trip + the closed /operator/admin entry point, depth partial→deep, and the uncovered list narrowed to the honest remainder (bookmark/share UI-less, live-data feed narrowing, custody hash). coverage.md row updated to deep.)" checked=""/> <strong>(cross-link)</strong> Update the journey + <code>coverage.md</code>. <em>(DONE 2026-06-10 — the journey doc's E2E section gained the explorer round-trip + the closed /operator/admin entry point, depth partial→<strong>deep</strong>, and the uncovered list narrowed to the honest remainder (bookmark/share UI-less, live-data feed narrowing, custody hash). coverage.md row updated to deep.)</em></li> </ul> </li> <li><strong>Acceptance criteria:</strong> <code>/operator/audit</code> shows the ledger AND an interactive explorer; search/actor/time narrows via the real route; an operator can save a named investigation, reload it, download JSON + Markdown; a non-admin gets an honest 403; <code>/operator/admin</code> links to it.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/web && npx vitest run src/components/lilith/OperatorAuditExplorer.test.tsx src/app/operator/audit/__tests__/page.test.tsx</code> · <code>cd apps/oshun/web && npx playwright test e2e/tenant-audit-log-investigation.spec.ts --workers=1</code></li> <li><strong>Effort / risk:</strong> <strong>M.</strong> Most logic ports from the admin panel. Risk: the web operator session must carry <code>admin:*|admin:studio</code> (verify real operator scopes); don't regress the fixture-row ledger assertions.</li> </ul> <h2 id="53-public-scene-abuse-report--operator-inbox-surfacing" class="hd hd-l2">5.3 Public-scene abuse report → operator inbox surfacing<a class="hd-anchor" href="#53-public-scene-abuse-report--operator-inbox-surfacing" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> The public report endpoint works for the reporter (<code>POST /v1/living-scenes/public/:shortCode/report</code>, <code>routes/living-scenes.ts:1136-1155</code>, rate-limited; e2e green to 202). <strong>But the report is NOT persisted</strong> — <code>recordAbuseReport</code> (<code>libs/yemaya/living-scenes-runtime/src/personal-artifacts/personal-artifacts.ts:729-750</code>) is a <strong>pure function</strong> that builds + freezes an <code>AbuseReport</code> (with <code>routedQueue: 'lilith-living-scene-public-reports'</code>) and returns it; the route sends it in the 202 and <strong>discards</strong> it. No store, no list endpoint, no queue consumer. The operator inbox renders a hardcoded fixture (<code>operator-depth.ts:202-260</code>).</li> <li><strong>The gap:</strong> (a) persist reports; (b) bridge them into the operator inbox (which is itself fixture-only). Surfacing = persist → expose to operators → map report→INC (severity from reason: S1 self-harm, S2 harassment/copyright/misinformation, S3 privacy/other) → inbox reads a live source unioned with the fixture.</li> <li><strong>External dependency:</strong> None for the in-repo flow (in-memory baseline; durable when DB set). Crisis-class routing for self-harm + brigade-clustering are richer T&S features to stage later.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (store) In personal-artifacts.ts, add an AbuseReportStore (record/list/get) alongside recordAbuseReport — keep recordAbuseReport pure (don't make it persist). Export from the lib index. (yemaya lib — in scope, not euterpe. Idempotent by reportId.)" checked=""/> <strong>(store)</strong> In <code>personal-artifacts.ts</code>, add an <code>AbuseReportStore</code> (record/list/get) alongside <code>recordAbuseReport</code> — <strong>keep <code>recordAbuseReport</code> pure</strong> (don't make it persist). Export from the lib index. <em>(yemaya lib — in scope, not euterpe. Idempotent by reportId.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (store test) record→list newest-first; routedQueue stamped." checked=""/> <strong>(store test)</strong> record→list newest-first; <code>routedQueue</code> stamped.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (BFF persist) In living-scenes.ts:1146-1153, after recordAbuseReport, write to a BFF store (living-scenes/abuse-report-store.ts, module singleton like operatorInboxDecisionStore); optionally snapshot-durable. Record a living_scene.public_report_created audit event at creation. (Persist + the living_scene.public_report_created audit event both DONE — the route records via the adminAuditEventsStore singleton directly. End-to-end firing is exercised by the deferred public-report→operator e2e.)" checked=""/> <strong>(BFF persist)</strong> In <code>living-scenes.ts:1146-1153</code>, after <code>recordAbuseReport</code>, write to a BFF store (<code>living-scenes/abuse-report-store.ts</code>, module singleton like <code>operatorInboxDecisionStore</code>); optionally snapshot-durable. Record a <code>living_scene.public_report_created</code> audit event at creation. <em>(Persist + the <code>living_scene.public_report_created</code> audit event both DONE — the route records via the <code>adminAuditEventsStore</code> singleton directly. End-to-end firing is exercised by the deferred public-report→operator e2e.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (route) routes/admin-abuse-reports.ts (model on admin-operator-inbox.ts): GET {prefix}/abuse-reports (admin-scoped, dual-prefix), mapping each AbuseReport → inbox item (severity from reason; deterministic INC-... from reportId; deep link to /scene/:shortCode; category:'auto'). Register with {auditEventsStore} near app.ts:1005. (GET + dual-prefix + mapping + operator-scope DONE; auditEventsStore registration deferred with the audit event.)" checked=""/> <strong>(route)</strong> <code>routes/admin-abuse-reports.ts</code> (model on <code>admin-operator-inbox.ts</code>): <code>GET {prefix}/abuse-reports</code> (admin-scoped, dual-prefix), mapping each <code>AbuseReport</code> → inbox item (severity from reason; deterministic <code>INC-...</code> from reportId; deep link to <code>/scene/:shortCode</code>; <code>category:'auto'</code>). Register with <code>{auditEventsStore}</code> near <code>app.ts:1005</code>. <em>(GET + dual-prefix + mapping + operator-scope DONE; auditEventsStore registration deferred with the audit event.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (route test) seed via the public POST → admin GET returns it w/ correct severity mapping; 401/403; the audit event recorded. (6 tests: severity mapping for all 6 reasons, inbox mapping, 401/403, newest-first list. Seeded via the store singleton — the public-POST→operator path is the deferred e2e since it needs share creation.)" checked=""/> <strong>(route test)</strong> seed via the public POST → admin GET returns it w/ correct severity mapping; 401/403; the audit event recorded. <em>(6 tests: severity mapping for all 6 reasons, inbox mapping, 401/403, newest-first list. Seeded via the store singleton — the public-POST→operator path is the deferred e2e since it needs share creation.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (bridge) In getAdminInbox() (operator-depth.ts:198-200) fetch /v1/admin/abuse-reports and union abuse-derived INCs with the static fixture (fixture stays the dev/test fallback; prepend live items, category:'auto'); keep the AdminInboxData['items'] shape. (Done via the server bffGet (fail-soft → fixture-only on null/403/unreachable, so the six-INC e2e count holds). Maps reason→severity, auto lane, INC-<reportId>, anon label + synthetic subtitle fallbacks, HH:MM-UTC when; drops malformed rows. 6 unit tests in operator-depth.test.ts.)" checked=""/> <strong>(bridge)</strong> In <code>getAdminInbox()</code> (<code>operator-depth.ts:198-200</code>) fetch <code>/v1/admin/abuse-reports</code> and <strong>union</strong> abuse-derived INCs with the static fixture (fixture stays the dev/test fallback; prepend live items, <code>category:'auto'</code>); keep the <code>AdminInboxData['items']</code> shape. <em>(Done via the server <code>bffGet</code> (fail-soft → fixture-only on null/403/unreachable, so the six-INC e2e count holds). Maps reason→severity, <code>auto</code> lane, <code>INC-<reportId></code>, anon label + synthetic subtitle fallbacks, HH:MM-UTC <code>when</code>; drops malformed rows. 6 unit tests in <code>operator-depth.test.ts</code>.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (detail deep link) In AdminInboxConsole.tsx detail card, for an abuse INC render "Open offering ↗" → /scene/:shortCode (carry shortCode on the item). (Added optional shortCode to AdminInboxData['items']; the console renders the deep link (data-operator-incident-deeplink) only when present. 2 unit tests in AdminInboxConsole.test.tsx (present for abuse INC, absent for fixture INC).)" checked=""/> <strong>(detail deep link)</strong> In <code>AdminInboxConsole.tsx</code> detail card, for an abuse INC render "Open offering ↗" → <code>/scene/:shortCode</code> (carry <code>shortCode</code> on the item). <em>(Added optional <code>shortCode</code> to <code>AdminInboxData['items']</code>; the console renders the deep link (<code>data-operator-incident-deeplink</code>) only when present. 2 unit tests in <code>AdminInboxConsole.test.tsx</code> (present for abuse INC, absent for fixture INC).)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: (optional) Reuse 5.1's resolve/dismiss verbs for abuse INCs → audit events living_scene.public_report_actioned_*; wire "Revoke share" to the real POST /v1/living-scenes/shares/:shareId/revoke. _(RESOLVE/DISMISS + AUDIT DONE — operators could see but not action abuse reports. Extended the yemaya AbuseReportStore with actionReport(reportId,{action,actionedByUserId, actionedAtUnixSeconds,note?}) (re-freezes the report with status resolved/dismissed + actor/when/note; recordAbuseReport stays pure — the store is the mutable triage owner; unknown id → null, never a fabricated action) + a status:'open' default + the AbuseReportStatus type (5 lib tests). Added POST /v1/admin/abuse-reports/:id/{resolve,dismiss} (dual-prefix, admin/operator-scoped) → persists via the store → emits a discrete adminAuditEventsStore.record event living_scene.public_report_resolved/ _dismissed (chose the public_report_prefix to match the existing _createdevent for explorer faceting, rather than the checklist's awkward actioned_\*literal) → returns the updated inbox item with its newstatus; 404 on unknown id. 4 route tests (401/403, resolve→status+persist+audit-event asserted via adminAuditEventsStore.list, dismiss, 404). lib tsc 0, BFF tsc 0, eslint 0, stub-scan clean. **CORRECTS the prior "revoke needs an unverified shortCode→shareId map" blocker: AbuseReportALREADY carriesshareId(personal-artifacts.ts:188), so revoke is NOT map-blocked.** Revoke-share wiring itself deferred — it's a separate cross-route concern (the revoke route has its own owner-auth model; an admin-initiated revoke should call the underlying share service, a distinct slice). The web inbox surfacing of the new status + the operator-leg e2e remain frontend/RAM-bound.)_ _2026-09-18: open for an agent. The residue is the revoke wiring (an admin-initiated revoke that calls the share service, sinceAbuseReportcarries theshareId), the inbox showing the new status, and the operator-leg e2e._"/> <strong>(optional)</strong> Reuse 5.1's resolve/dismiss verbs for abuse INCs → audit events <code>living_scene.public_report_actioned_*</code>; wire "Revoke share" to the real <code>POST /v1/living-scenes/shares/:shareId/revoke</code>. _(RESOLVE/DISMISS + AUDIT DONE — operators could see but not action abuse reports. Extended the yemaya <code>AbuseReportStore</code> with <code>actionReport(reportId,{action,actionedByUserId, actionedAtUnixSeconds,note?})</code> (re-freezes the report with status <code>resolved</code>/<code>dismissed</code> + actor/when/note; <strong><code>recordAbuseReport</code> stays pure</strong> — the store is the mutable triage owner; unknown id → null, never a fabricated action) + a <code>status:'open'</code> default + the <code>AbuseReportStatus</code> type (5 lib tests). Added <code>POST /v1/admin/abuse-reports/:id/{resolve,dismiss}</code> (dual-prefix, admin/operator-scoped) → persists via the store → emits a discrete <code>adminAuditEventsStore.record</code> event <strong><code>living_scene.public_report_resolved</code>/ <code>_dismissed</code></strong> (chose the <code>public_report_</code>prefix to match the existing <code>_created</code>event for explorer faceting, rather than the checklist's awkward <code>actioned_\*</code>literal) → returns the updated inbox item with its new<code>status</code>; 404 on unknown id. 4 route tests (401/403, resolve→status+persist+audit-event asserted via <code>adminAuditEventsStore.list</code>, dismiss, 404). lib tsc 0, BFF tsc 0, eslint 0, stub-scan clean. **CORRECTS the prior "revoke needs an unverified shortCode→shareId map" blocker: <code>AbuseReport</code>ALREADY carries<code>shareId</code>(personal-artifacts.ts:188), so revoke is NOT map-blocked.** Revoke-share wiring itself deferred — it's a separate cross-route concern (the revoke route has its own owner-auth model; an admin-initiated revoke should call the underlying share service, a distinct slice). The web inbox surfacing of the new status + the operator-leg e2e remain frontend/RAM-bound.)_ _2026-09-18: open for an agent. The residue is the revoke wiring (an admin-initiated revoke that calls the share service, since<code>AbuseReport</code>carries the<code>shareId</code>), the inbox showing the new status, and the operator-leg e2e._</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e) Extend public-scene-abuse-report.spec.ts with an operator leg: seed a report → (admin bearer, re-issue **/v1/admin/abuse-reports**) open /operator/admin, assert the abuse INC appears with the right severity pill + deep link; + self-harm→S1. (DONE 2026-06-10 — and it SURFACED + FIXED a real production bug: the checklist's "re-issue via page.route" approach was impossible (getAdminInbox is an RSC bffGet, not a browser fetch), and investigating revealed bff-fetch.ts forwarded NO auth at all in any environment — it called Next 16's async cookies() synchronously (throws → swallowed → nothing forwarded) AND only ever forwarded session cookies, which the BFF authz never reads (Authorization bearer only). So the live-rows abuse bridge — and every authenticated RSC read — silently degraded to anonymous/fixture. FIX: bffGet now awaits cookies() and forwards the oshun-access token as the bearer, with authenticated fetches forced cache:'no-store' (Next's data cache keys by URL only — a cached authenticated payload would have leaked across users once auth started flowing). The operator-leg test plants an operator-admin dev token as the oshun-access cookie: a self-harm report filed via the REAL public endpoint renders on /operator/admin as the live INC-<reportId> auto INC with the REAL S1→"critical" pill and the working /scene deep link in the detail card. Spec 3/3 stable; operator-depth + operator page suites 14/14; web tsc clean.)" checked=""/> <strong>(e2e)</strong> Extend <code>public-scene-abuse-report.spec.ts</code> with an operator leg: seed a report → (admin bearer, re-issue <code>**/v1/admin/abuse-reports**</code>) open <code>/operator/admin</code>, assert the abuse INC appears with the right severity pill + deep link; + self-harm→S1. <em>(DONE 2026-06-10 — and it SURFACED + FIXED a real production bug: the checklist's "re-issue via page.route" approach was impossible (getAdminInbox is an RSC <code>bffGet</code>, not a browser fetch), and investigating revealed <code>bff-fetch.ts</code> forwarded NO auth at all in any environment — it called Next 16's async <code>cookies()</code> synchronously (throws → swallowed → nothing forwarded) AND only ever forwarded session cookies, which the BFF authz never reads (Authorization bearer only). So the live-rows abuse bridge — and every authenticated RSC read — silently degraded to anonymous/fixture. FIX: <code>bffGet</code> now awaits cookies() and forwards the <code>oshun-access</code> token as the bearer, with authenticated fetches forced <code>cache:'no-store'</code> (Next's data cache keys by URL only — a cached authenticated payload would have leaked across users once auth started flowing). The operator-leg test plants an operator-admin dev token as the oshun-access cookie: a self-harm report filed via the REAL public endpoint renders on /operator/admin as the live <code>INC-<reportId></code> auto INC with the REAL S1→"critical" pill and the working /scene deep link in the detail card. Spec 3/3 stable; operator-depth + operator page suites 14/14; web tsc clean.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (cross-link) Update public-scene-abuse-report.md + incident-triage.md + coverage.md. (COMPLETED 2026-06-10 — public-scene-abuse-report.md's E2E section gained the operator leg + the bffGet-fix note, depth partial→deep; incident-triage.md's stale "abuse path remains blocked" honesty note superseded with the live-union reality; coverage.md row upgraded to deep.) (public-scene-abuse-report.md updated: documents the wired bridge + deep link with unit coverage, and resolves the "operator inbox filter-chip mapping" open question. The coverage.md grade + incident-triage.md E2E-coverage rows are tied to the deferred Playwright operator-leg, so they're left for that spec to avoid claiming e2e coverage that doesn't exist yet.) GET /v1/admin/abuse-reports returns it; it surfaces on /operator/admin as an INC with severity mapped from reason, under the Auto filter, with a working /scene/:shortCode deep link; a living_scene.public_report_created audit event is visible in 5.2; e2e drives reporter→operator end-to-end." checked=""/> <strong>(cross-link)</strong> Update <code>public-scene-abuse-report.md</code> + <code>incident-triage.md</code> + <code>coverage.md</code>. <em>(COMPLETED 2026-06-10 — public-scene-abuse-report.md's E2E section gained the operator leg + the bffGet-fix note, depth partial→<strong>deep</strong>; incident-triage.md's stale "abuse path remains blocked" honesty note superseded with the live-union reality; coverage.md row upgraded to deep.)</em> <em>(<code>public-scene-abuse-report.md</code> updated: documents the wired bridge + deep link with unit coverage, and resolves the "operator inbox filter-chip mapping" open question. The <code>coverage.md</code> grade + <code>incident-triage.md</code> E2E-coverage rows are tied to the deferred Playwright operator-leg, so they're left for that spec to avoid claiming e2e coverage that doesn't exist yet.)</em> <code>GET /v1/admin/abuse-reports</code> returns it; it surfaces on <code>/operator/admin</code> as an INC with severity mapped from reason, under the Auto filter, with a working <code>/scene/:shortCode</code> deep link; a <code>living_scene.public_report_created</code> audit event is visible in 5.2; e2e drives reporter→operator end-to-end.</li> </ul> </li> <li><strong>Verification:</strong> <code>cd apps/oshun/bff && npx vitest run src/__tests__/admin-abuse-reports-route.test.ts</code> · <code>cd libs/yemaya/living-scenes-runtime && npx vitest run src/personal-artifacts/personal-artifacts.test.ts</code> · <code>cd apps/oshun/web && npx playwright test e2e/public-scene-abuse-report.spec.ts --workers=1</code></li> <li><strong>Effort / risk:</strong> <strong>M–L.</strong> Risk: the inbox is 100% client fixture — union must <em>append</em> live items + keep the fixture baseline or the six-INC e2e (<code>incident-triage.spec.ts:185-242</code>, pins <code>data-operator-inbox-count=6</code>) breaks; keep <code>recordAbuseReport</code> pure; the severity table lives only in the journey doc — confirm against the T&S runbook; crisis-routing + brigade meta-INC are larger T&S features (stage after basic surfacing).</li> </ul> <hr /> <hr /> <h1 id="part-6--pwa-edge-cases--ssr-deep-read-coverage" class="hd hd-l1">Part 6 — PWA Edge Cases & SSR Deep-Read Coverage<a class="hd-anchor" href="#part-6--pwa-edge-cases--ssr-deep-read-coverage" aria-label="Permalink to this section">#</a></h1> <h2 id="61-captive-portal-healthz-false-online-genuine-correctness-bug--fix-first" class="hd hd-l2">6.1 Captive-portal <code>/healthz</code> false-online <strong>(genuine correctness bug — fix first)</strong><a class="hd-anchor" href="#61-captive-portal-healthz-false-online-genuine-correctness-bug--fix-first" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> <code>OfflineBanner.tsx</code> (mounted globally, <code>app/layout.tsx:201</code>) does <code>fetch(BFF_HEALTH_URL,{cache:'no-store'})</code> and <strong>treats any non-throwing response as "online"</strong> — it never checks <code>response.ok</code>/<code>status</code>/content-type (<code>OfflineBanner.tsx:57-62</code>); only a thrown error sets offline. <code>/healthz</code> returns JSON <code>{status:'ok',service:'oshun-bff'}</code> (<code>routes/health.ts:5-11</code>).</li> <li><strong>The gap:</strong> A captive portal returning <code>200 text/html</code> makes <code>fetch</code> resolve → banner reports "online" with no real connectivity. Clean seam, genuine bug.</li> <li><strong>Classification:</strong> NEEDS-PRODUCT-SEAM, then TESTABLE-NOW.</li> <li><strong>External dependency:</strong> None — pure code/test.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Edit OfflineBanner.tsx:57-62: require response.ok AND a JSON content-type (and optionally parse {status:'ok',service:'oshun-bff'}); else setOffline(true). Keep the forceOffline/disableAnimation test hatches." checked=""/> Edit <code>OfflineBanner.tsx:57-62</code>: require <code>response.ok</code> AND a JSON content-type (and optionally parse <code>{status:'ok',service:'oshun-bff'}</code>); else <code>setOffline(true)</code>. Keep the <code>forceOffline</code>/<code>disableAnimation</code> test hatches.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: OfflineBanner.test.tsx: a 200 text/html resolves → banner shows; valid JSON 200 → dismisses; navigator.onLine===false still forces offline." checked=""/> <code>OfflineBanner.test.tsx</code>: a <code>200 text/html</code> resolves → banner shows; valid JSON 200 → dismisses; <code>navigator.onLine===false</code> still forces offline.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: e2e in pwa-failure-modes.spec.ts: page.route('**/healthz', r=>r.fulfill({status:200, contentType:'text/html',body:'<html>captive</html>'})) → assert [data-offline-banner]." checked=""/> e2e in <code>pwa-failure-modes.spec.ts</code>: <code>page.route('**/healthz', r=>r.fulfill({status:200, contentType:'text/html',body:'<html>captive</html>'}))</code> → assert <code>[data-offline-banner]</code>.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (Optional hardening) BFF sets an explicit x-oshun-health: ok sentinel header on /healthz so the client can assert origin authenticity." checked=""/> (Optional hardening) BFF sets an explicit <code>x-oshun-health: ok</code> sentinel header on <code>/healthz</code> so the client can assert origin authenticity.</li> </ul> </li> <li><strong>Acceptance criteria:</strong> <code>/healthz</code> as <code>200 text/html</code> shows the banner; valid JSON 200 keeps it hidden; <code>navigator.onLine===false</code> unchanged.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/web && npx vitest run src/components/OfflineBanner.test.tsx</code> · <code>npx playwright test e2e/pwa-failure-modes.spec.ts --workers=1</code></li> <li><strong>Effort / risk:</strong> <strong>S.</strong> Risk: a stricter check could false-positive if a proxy strips content-type — accept <code>ok && json</code> OR the sentinel header.</li> </ul> <h2 id="62-manifest-404-affordance" class="hd hd-l2">6.2 Manifest-404 affordance<a class="hd-anchor" href="#62-manifest-404-affordance" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> <code>PwaInstallPrompt.tsx</code> drives <code>available</code> purely from <code>beforeinstallprompt</code>/<code>appinstalled</code> (<code>:58-91</code>); it never fetches/validates the manifest (declared <code>layout.tsx:49</code>, file <code>public/manifest.json</code>). Mounted only on <code>/welcome/download</code> <ul> <li><code>HomeWorkspace</code>. The existing spec already documents this infeasible (<code>pwa-failure-modes.spec.ts:150-159</code>).</li> </ul> </li> <li><strong>The gap:</strong> No behavioral link between manifest availability and the affordance, so "404 → hide install" can't be tested because the product doesn't implement it.</li> <li><strong>Classification:</strong> NEEDS-PRODUCT-SEAM + HARNESS-LIMITATION (<code>beforeinstallprompt</code> is browser-gated, not deterministically drivable).</li> <li><strong>External dependency:</strong> None — but requires a product decision (likely over-engineering for the auto path since the browser already gates installability).</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (decision) Decide whether a manifest-health gate is wanted at all. Recommendation: NO for the auto path; YES only for the manual install trigger (which bypasses the browser gate). DECIDED: NO (by-design). Rationale: (1) the auto path is browser-gated — beforeinstallprompt only fires when the manifest is valid + installability criteria are met, so a broken manifest already yields no prompt; (2) PwaInstallPrompt renders null in idle/dismissed (lines 466-467), so there is NO false affordance to correct — a manual trigger without a deferredPrompt is a correct no-op; (3) adding a /manifest.json fetch + a new unavailable render branch for that rare edge is over-engineering for marginal value (the checklist's own "lowest-risk outcome: add nothing"). The "If YES" tasks below are intentionally left undone.)" checked=""/> <strong>(decision)</strong> Decide whether a manifest-health gate is wanted at all. <strong>Recommendation: NO for the auto path; YES only for the manual install trigger</strong> (which bypasses the browser gate). <em><strong>DECIDED: NO</strong> (by-design). Rationale: (1) the auto path is browser-gated — <code>beforeinstallprompt</code> only fires when the manifest is valid + installability criteria are met, so a broken manifest already yields no prompt; (2) <code>PwaInstallPrompt</code> renders <code>null</code> in idle/dismissed (lines 466-467), so there is NO false affordance to correct — a manual trigger without a <code>deferredPrompt</code> is a correct no-op; (3) adding a <code>/manifest.json</code> fetch + a new <code>unavailable</code> render branch for that rare edge is over-engineering for marginal value (the checklist's own "lowest-risk outcome: add nothing"). The "If YES" tasks below are intentionally left undone.)</em></li> <li>If YES: in <code>PwaInstallPrompt.tsx</code>, add a <code>HEAD/GET /manifest.json</code> check that gates the <strong>manual</strong> trigger handler (<code>:113-125</code>) to a no-op + <code>data-pwa-install-unavailable</code> state on non-200 / wrong content-type. <em>(NOT BUILT — the decision above is NO (by-design); this and the two boxes below are the conditional "If YES" branch, deliberately unexecuted, mirroring the 1.4 unchosen-alternative convention.)</em> <em>Not a task (2026-09-18): the conditional branch of a decision that was answered NO.</em></li> <li>Add the <code>data-pwa-install-unavailable</code> attribute; add an e2e routing <code>/manifest.json</code>→404 and firing the manual trigger; remove the "infeasible" note (<code>:150-159</code>). <em>(NOT BUILT — see the NO decision.)</em> <em>Not a task (2026-09-18): the conditional branch of a decision that was answered NO.</em></li> <li>Add a <code>PwaInstallPrompt.test.tsx</code> unit test mocking fetch. <em>(NOT BUILT — see the NO decision.)</em> <em>Not a task (2026-09-18): the conditional branch of a decision that was answered NO.</em></li> </ul> </li> <li><strong>Acceptance criteria:</strong> With <code>/manifest.json</code>→404, the manual trigger shows <code>data-pwa-install-unavailable</code> instead of a no-op; 200 proceeds; the auto path is unchanged (documented browser-gated).</li> <li><strong>Verification:</strong> <code>cd apps/oshun/web && npx vitest run src/components/PwaInstallPrompt.test.tsx</code> · <code>npx playwright test e2e/pwa-failure-modes.spec.ts --workers=1</code></li> <li><strong>Effort / risk:</strong> <strong>S.</strong> Lowest-risk outcome may be to keep the documented "by-design" stance and add nothing — decide deliberately.</li> </ul> <h2 id="63-skip-waiting-lost-stuck-apply-timeout" class="hd hd-l2">6.3 Skip-waiting-lost stuck-apply timeout<a class="hd-anchor" href="#63-skip-waiting-lost-stuck-apply-timeout" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> <code>handleApplyUpdate</code> (<code>PwaBootstrap.tsx:244-265</code>) posts <code>OSHUN_SKIP_WAITING</code>, sets <code>applyingUpdate</code>, and arms a <strong>4s</strong> fallback timer that silently <code>window.location.reload()</code>s (<code>:256-261</code>); normally <code>controllerchange</code> reloads first (<code>:122-128</code>). <code>PwaUpdatePrompt</code> only shows a disabled "Refreshing…" spinner — <strong>no manual-reload hint</strong> (<code>pwa-failure-modes.spec.ts:160-167</code>). (The prompt cited line 243; the real timer is 256-261.)</li> <li><strong>The gap:</strong> (1) the silent reload has no DOM affordance to assert in e2e; (2) the 4s timer logic isn't unit-covered.</li> <li><strong>Classification:</strong> timer logic TESTABLE-NOW (component unit test w/ fake timers); e2e assertion NEEDS-PRODUCT-SEAM + HARNESS-LIMITATION (no real SW takeover in the mock harness).</li> <li><strong>External dependency:</strong> None — pure code/test.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (optional seam) Add a data-pwa-update-manual-reload hint to PwaUpdatePrompt.tsx after applying >Ns ("Taking too long? Reload now" → window.location.reload()), converting the silent fallback into an assertable affordance. (DONE 2026-06-10 — the hint renders after applying persists 2s (MANUAL_RELOAD_HINT_AFTER_MS), reloads on click, clears when applying stops, and never renders otherwise; the bootstrap's 4s silent reload stays the backstop. New PwaUpdatePrompt.test.tsx (3 fake-timer tests: 1999ms→no hint / 2000ms→hint, click→exactly one reload, not-applying/reset behavior). Web tsc clean.)" checked=""/> <strong>(optional seam)</strong> Add a <code>data-pwa-update-manual-reload</code> hint to <code>PwaUpdatePrompt.tsx</code> after applying >Ns ("Taking too long? Reload now" → <code>window.location.reload()</code>), converting the silent fallback into an assertable affordance. <em>(DONE 2026-06-10 — the hint renders after <code>applying</code> persists 2s (MANUAL_RELOAD_HINT_AFTER_MS), reloads on click, clears when applying stops, and never renders otherwise; the bootstrap's 4s silent reload stays the backstop. New <code>PwaUpdatePrompt.test.tsx</code> (3 fake-timer tests: 1999ms→no hint / 2000ms→hint, click→exactly one reload, not-applying/reset behavior). Web tsc clean.)</em></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (unit) PwaBootstrap.test.tsx with vi.useFakeTimers(): Apply → no controllerchange → advance 4000ms → assert reload called once; controllerchange before 4s → immediate reload" checked=""/> <strong>(unit)</strong> <code>PwaBootstrap.test.tsx</code> with <code>vi.useFakeTimers()</code>: Apply → no controllerchange → advance 4000ms → assert <code>reload</code> called once; controllerchange before 4s → immediate reload <ul> <li>timer cleared (<code>:122-128,257-261</code>). (Mock <code>window.location.reload</code> via <code>Object.defineProperty</code>.) <em>(Added a <code>stuck-apply fallback reload timer</code> describe to the existing <code>__tests__/PwaBootstrap.test.tsx</code> (reusing its full mock harness): test A clicks Refresh now, advances 3999ms (no reload) then 1ms (exactly one reload); test B dispatches <code>controllerchange</code> → immediate single reload + advancing 10s does NOT reload again (timer cleared). Mocks <code>window.location.reload</code> via <code>Object.defineProperty</code> on <code>window</code>, restored in afterEach. All 7 tests green.)</em></li> </ul> </li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: (e2e, if seam) pwa-failure-modes.spec.ts using mockWaitingServiceWorkerUpdate: click Apply, never fire controllerchange, assert [data-pwa-update-manual-reload]; update the "infeasible" note (:160-167). (DONE 2026-06-10 — mockWaitingServiceWorkerUpdate gained a respondToSkipWaiting:false option (the waiting worker swallows OSHUN_SKIP_WAITING → controllerchange never fires, modelling the lost message); the new "stuck apply" test clicks Apply, asserts the hint within the 2–4s window (before the silent fallback), clicks it, and the page reload lands. The "infeasible" note rewritten RESOLVED. Spec 4/4; the three other mock consumers (pwa-smoke / pwa-lifecycle-deepening / pwa-install-update-offline) re-ran 32/32 — the default behavior is unchanged.)" checked=""/> <strong>(e2e, if seam)</strong> <code>pwa-failure-modes.spec.ts</code> using <code>mockWaitingServiceWorkerUpdate</code>: click Apply, never fire controllerchange, assert <code>[data-pwa-update-manual-reload]</code>; update the "infeasible" note (<code>:160-167</code>). <em>(DONE 2026-06-10 — <code>mockWaitingServiceWorkerUpdate</code> gained a <code>respondToSkipWaiting:false</code> option (the waiting worker swallows OSHUN_SKIP_WAITING → controllerchange never fires, modelling the lost message); the new "stuck apply" test clicks Apply, asserts the hint within the 2–4s window (before the silent fallback), clicks it, and the page reload lands. The "infeasible" note rewritten RESOLVED. Spec 4/4; the three other mock consumers (pwa-smoke / pwa-lifecycle-deepening / pwa-install-update-offline) re-ran 32/32 — the default behavior is unchanged.)</em></li> </ul> </li> <li><strong>Acceptance criteria:</strong> Unit: Apply → no controllerchange → reload at 4s; controllerchange before 4s → immediate reload + timer cleared. If seam: a manual-reload affordance appears while stuck and reloads on click.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/web && npx vitest run src/components/PwaBootstrap.test.tsx</code></li> <li><strong>Effort / risk:</strong> <strong>S</strong> (unit) / <strong>M</strong> (seam + e2e). Risk: mocking non-configurable <code>window.location.reload</code> in jsdom.</li> </ul> <h2 id="64-nisaba-ssr-deep-read-unit-test" class="hd hd-l2">6.4 Nisaba SSR deep-read unit test<a class="hd-anchor" href="#64-nisaba-ssr-deep-read-unit-test" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> <code>/nisaba/page.tsx:13-16</code> is a server component: <code>getNisaba()</code> → <code>bffGet('/v1/nisaba/room')</code> (<code>lib/lilith-data/nisaba.ts:14-20</code>), falling back to <code>nisabaUnavailable()</code> when <code>!room?.passageBody</code>. <code>NisabaRoom</code> (<code>components/lilith/rooms.tsx:1114</code>) is a pure server component. In e2e, <code>bffGet</code> is server-side and <code>page.route</code> can't reach it → always the fallback/live BFF, never a deterministic real passage.</li> <li><strong>The gap:</strong> The real-passage SSR render is never exercised deterministically.</li> <li><strong>Classification:</strong> TESTABLE-NOW via the proven server-component unit-test pattern (operator/ sso + operator/audit are the templates).</li> <li><strong>External dependency:</strong> None — pure test.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Create app/nisaba/__tests__/page.test.tsx (model on operator/sso/__tests__/page.test.tsx): vi.mock('@/lib/server/bff-fetch',()=>({bffGet:vi.fn()}))." checked=""/> Create <code>app/nisaba/__tests__/page.test.tsx</code> (model on <code>operator/sso/__tests__/page.test.tsx</code>): <code>vi.mock('@/lib/server/bff-fetch',()=>({bffGet:vi.fn()}))</code>.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Test A (ready): mock a real room payload → render(await NisabaPage()) → assert the passage title + a passageBody paragraph + a section label." checked=""/> Test A (ready): mock a real room payload → <code>render(await NisabaPage())</code> → assert the passage title + a <code>passageBody</code> paragraph + a section label.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Test B (unavailable): mockResolvedValue(null) → assert "Today's passage is not available right now." + no fabricated body." checked=""/> Test B (unavailable): <code>mockResolvedValue(null)</code> → assert "Today's passage is not available right now." + no fabricated body.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Test C (malformed → fallback): passageBody: undefined → unavailable branch." checked=""/> Test C (malformed → fallback): <code>passageBody: undefined</code> → unavailable branch.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Build typed fixtures from the NisabaData type (lib/lilith-data/types.ts)." checked=""/> Build typed fixtures from the <code>NisabaData</code> type (<code>lib/lilith-data/types.ts</code>).</li> </ul> </li> <li><strong>Acceptance criteria:</strong> A mocked real payload renders the real passage/body/sections/related; <code>null</code>/missing <code>passageBody</code> renders the honest unavailable copy; tests fail if real content is dropped or a body is fabricated.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/web && npx vitest run src/app/nisaba/__tests__/page.test.tsx</code></li> <li><strong>Effort / risk:</strong> <strong>S.</strong> Low (operator tests prove design-system server components render in jsdom).</li> </ul> <h2 id="65-veritas-ssr-deep-read-unit-test" class="hd hd-l2">6.5 Veritas SSR deep-read unit test<a class="hd-anchor" href="#65-veritas-ssr-deep-read-unit-test" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Status today:</strong> <code>/veritas/page.tsx:12-17</code> server component: <code>getVeritasLead()</code> → <code>bffGet('/v1/veritas/briefing/home')</code> → <code>mapHomeBriefingToVeritasData</code> (<code>veritas-briefing.ts:168</code>) or <code>veritasLeadUnavailable()</code>. <code>VeritasRoom</code> (<code>rooms.tsx:568</code>) is a server component. The <strong>mapper is unit-tested</strong> (<code>veritas-briefing.test.ts</code>) but the <strong>page-level SSR render is not</strong>. (NB: <code>components/domains/VeritasSurface.tsx</code> is a <code>'use client'</code> component — NOT the SSR path; the SSR path is <code>VeritasRoom</code>.)</li> <li><strong>The gap:</strong> Same root cause as 6.4 — <code>bffGet</code> is server-side, unreachable from <code>page.route</code>.</li> <li><strong>Classification:</strong> TESTABLE-NOW via server-component unit test.</li> <li><strong>External dependency:</strong> None — pure test.</li> <li><strong>Granular tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Create app/veritas/__tests__/page.test.tsx; vi.mock('@/lib/server/bff-fetch')." checked=""/> Create <code>app/veritas/__tests__/page.test.tsx</code>; <code>vi.mock('@/lib/server/bff-fetch')</code>.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Reuse the briefing() fixture builder from veritas-briefing.test.ts:10-40." checked=""/> Reuse the <code>briefing()</code> fixture builder from <code>veritas-briefing.test.ts:10-40</code>.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Test A (ready): mock briefing() → assert the real headline + claim count + a source name." checked=""/> Test A (ready): mock <code>briefing()</code> → assert the real headline + claim count + a source name.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Test B (null): assert veritasLeadUnavailable() copy + zero claims." checked=""/> Test B (null): assert <code>veritasLeadUnavailable()</code> copy + zero claims.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Test C (degraded): missing briefing/grounding → unavailable branch." checked=""/> Test C (degraded): missing <code>briefing</code>/<code>grounding</code> → unavailable branch.</li> </ul> </li> <li><strong>Acceptance criteria:</strong> A grounded briefing renders the real headline + claims + sources; <code>null</code>/missing fields render the honest unavailable surface with no fabricated claims.</li> <li><strong>Verification:</strong> <code>cd apps/oshun/web && npx vitest run src/app/veritas/__tests__/page.test.tsx</code></li> <li><strong>Effort / risk:</strong> <strong>S.</strong></li> </ul> <blockquote> <p><strong>Architectural note (SSR e2e):</strong> Prefer <strong>server-component unit tests</strong> (<code>render(await Page())</code></p> <ul> <li><code>vi.mock('@/lib/server/bff-fetch')</code>) over building a server-side BFF-fixture-injection seam. The unit-test path is already blessed (operator/sso + audit), deterministic, needs no infra, and avoids a prod-code test seam. Reserve a live-BFF integration test (env-gated) for verifying the <code>bff-fetch.ts</code> transport itself.</li> </ul> </blockquote> <hr /> <hr /> <h1 id="part-7--deploy-time-infra-inputs-provisioning-not-code" class="hd hd-l1">Part 7 — Deploy-Time Infra Inputs (provisioning, not code)<a class="hd-anchor" href="#part-7--deploy-time-infra-inputs-provisioning-not-code" aria-label="Permalink to this section">#</a></h1> <blockquote> <p><strong>Re-read on 2026-09-18.</strong> This part was written for managed cloud services. The platform has since moved to one Hetzner box running the compose stack in <code>infra/hetzner/docker-compose.yml</code> (<code>.github/workflows/deploy-hetzner.yml</code> replaced the ECS workflow), and that stack already has <code>postgres</code>, <code>redis</code>, <code>qdrant</code>, <code>minio</code>, the BFF and the front-ends. So: "provision" items are withdrawn below where the stack provides the thing; every "verify the boot log" and "run the gated suite" item is agent work against the local stack (<code>docker compose -f docker/docker-compose.dev.yml up -d postgres redis</code>, then boot the BFF with the two URLs set) and, for production, against the staging stack once the owner's secrets are in place; items that are the owner's secrets or decisions are tagged.</p> </blockquote> <p>These are <strong>provisioning/config tasks</strong>, not code. Each in-repo consumer fails closed / logs an "in-memory or disabled" warning without its input; activation is observable via a specific boot log line + an env-gated integration test.</p> <h2 id="71-postgres--oshun_v1_database_url--oshun_admin_database_url" class="hd hd-l2">7.1 Postgres — <code>OSHUN_V1_DATABASE_URL</code> / <code>OSHUN_ADMIN_DATABASE_URL</code><a class="hd-anchor" href="#71-postgres--oshun_v1_database_url--oshun_admin_database_url" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Consumer:</strong> <code>apps/oshun/bff/src/server.ts:100-104,137-183</code> (<code>createAdminSnapshotStore</code> + durable admin/audit/living-scene-share/reminder/generation-job/OneRoster stores). Prisma URL <code>libs/oshun/persistence/prisma.config.ts:10</code>.</li> <li><strong>Default without it:</strong> in-memory; warning <code>server.ts:211-213</code>; state lost on restart.</li> <li><strong>Tasks:</strong> <ul> <li><em>Withdrawn 2026-09-18: the <code>postgres</code> service of <code>infra/hetzner/docker-compose.yml</code> is the database; nothing is left to provision.</em> Provision Postgres 16 + <code>pgvector</code>; create the V1 DB + least-privilege role.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: OSHUN_V1_DATABASE_URL=… pnpm exec prisma migrate deploy."/> <code>OSHUN_V1_DATABASE_URL=… pnpm exec prisma migrate deploy</code>.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Set OSHUN_V1_DATABASE_URL (+ OSHUN_ADMIN_DATABASE_URL if isolating) in BFF secrets. Board tag 2026-09-18: production secrets are set by the owner in the Hetzner environment; an agent checks that infra/hetzner/docker-compose.yml passes both variables to the bff service and that the BFF refuses to boot durable stores without them. blocked:external"/> Set <code>OSHUN_V1_DATABASE_URL</code> (+ <code>OSHUN_ADMIN_DATABASE_URL</code> if isolating) in BFF secrets. <em>Board tag 2026-09-18: production secrets are set by the owner in the Hetzner environment; an agent checks that <code>infra/hetzner/docker-compose.yml</code> passes both variables to the <code>bff</code> service and that the BFF refuses to boot durable stores without them.</em> <code>blocked:external</code></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Verify boot log "admin stores: durable (postgres …), hydrated at startup" (server.ts:207-209)."/> Verify boot log "admin stores: durable (postgres …), hydrated at startup" (<code>server.ts:207-209</code>).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Run the env-gated durability integration suite."/> Run the env-gated durability integration suite.</li> </ul> </li> <li><strong>Acceptance:</strong> durable-stores log line (not the in-memory warning); admin state survives restart; <code>OSHUN_V1_DATABASE_URL</code>-gated integration tests pass.</li> <li><strong>Effort / risk:</strong> <strong>M.</strong> Risk: missing pgvector; migration drift (<code>prisma migrate diff</code> first).</li> </ul> <h2 id="72-redis--oshun_redis_url--oshun_bff_idempotency_redis_url" class="hd hd-l2">7.2 Redis — <code>OSHUN_REDIS_URL</code> / <code>OSHUN_BFF_IDEMPOTENCY_REDIS_URL</code><a class="hd-anchor" href="#72-redis--oshun_redis_url--oshun_bff_idempotency_redis_url" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Consumer:</strong> <code>server.ts:112-116,125-135</code> (<code>RedisBffIdempotencyStore</code> + <code>RedisAbuseProtectionStore</code>); the Veritas retraction cascade needs <strong>both</strong> DB + Redis (<code>:220-226</code>).</li> <li><strong>Default without it:</strong> per-process idempotency + rate limiting (warnings <code>:198-203</code>); cascades off.</li> <li><strong>Tasks:</strong> <ul> <li><em>Withdrawn 2026-09-18: the <code>redis</code> service of the same compose file is the store; a managed Redis is no longer planned.</em> Provision managed Redis + an ACL user.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Set OSHUN_BFF_IDEMPOTENCY_REDIS_URL (or OSHUN_REDIS_URL); for cascades ensure DB is also set."/> Set <code>OSHUN_BFF_IDEMPOTENCY_REDIS_URL</code> (or <code>OSHUN_REDIS_URL</code>); for cascades ensure DB is also set.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Verify boot logs "idempotency store: durable (redis)…" + "rate limiting: durable (redis)…" + "veritas retraction cascade: worker subscribed…" (:193-196,227-228)."/> Verify boot logs "idempotency store: durable (redis)…" + "rate limiting: durable (redis)…" + "veritas retraction cascade: worker subscribed…" (<code>:193-196,227-228</code>).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Run the Redis-gated integration tests (idempotency cross-instance, cascade, crisis-frame)."/> Run the Redis-gated integration tests (idempotency cross-instance, cascade, crisis-frame).</li> </ul> </li> <li><strong>Acceptance:</strong> durable-idempotency + durable-rate-limit logs; a replayed idempotent write returns the cached result from a <em>second</em> instance; cascade subscribes.</li> <li><strong>Effort / risk:</strong> <strong>M.</strong> Risk: half-provisioning silently leaves cascades off (only a log signals it).</li> </ul> <h2 id="73-c2pa-ed25519-signing-key--oshun_living_scenes_c2pa_" class="hd hd-l2">7.3 C2PA Ed25519 signing key — <code>OSHUN_LIVING_SCENES_C2PA_*</code><a class="hd-anchor" href="#73-c2pa-ed25519-signing-key--oshun_living_scenes_c2pa_" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Consumer:</strong> <code>resolveC2paSigner()</code> (<code>routes/living-scenes.ts:1331-1356</code>): reads <code>OSHUN_LIVING_SCENES_C2PA_SIGNING_KEY</code> (64-hex) + <code>_KEY_ID</code>; non-prod dev fallback, prod → null → share route 503 <code>c2pa_signing_not_configured</code> (<code>:645-652</code>).</li> <li><strong>Default without it:</strong> non-prod dev signature; prod share returns 503.</li> <li><strong>Tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Generate an Ed25519 keypair; export the 32-byte private scalar as 64-hex."/> Generate an Ed25519 keypair; export the 32-byte private scalar as 64-hex.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Store in the secret manager; set OSHUN_LIVING_SCENES_C2PA_SIGNING_KEY + a stable _KEY_ID. Board tag 2026-09-18: the production signing key is generated and held by the owner. blocked:external"/> Store in the secret manager; set <code>OSHUN_LIVING_SCENES_C2PA_SIGNING_KEY</code> + a stable <code>_KEY_ID</code>. <em>Board tag 2026-09-18: the production signing key is generated and held by the owner.</em> <code>blocked:external</code></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Confirm NODE_ENV=production (or OSHUN_ENV/RUNTIME_ENV) so fail-closed is active (:1312-1318)."/> Confirm <code>NODE_ENV=production</code> (or <code>OSHUN_ENV</code>/<code>RUNTIME_ENV</code>) so fail-closed is active (<code>:1312-1318</code>).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Verify a real share → 201 with provenanceSigner.publicKeyHex (:699-702); independently verify the manifest signature; negative-check unset-in-prod → 503."/> Verify a real share → 201 with <code>provenanceSigner.publicKeyHex</code> (<code>:699-702</code>); independently verify the manifest signature; negative-check unset-in-prod → 503.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Publish/rotate the public key to verifiers. Board tag 2026-09-18: follows the owner creating the production key. blocked:upstream"/> Publish/rotate the public key to verifiers. <em>Board tag 2026-09-18: follows the owner creating the production key.</em> <code>blocked:upstream</code></li> </ul> </li> <li><strong>Acceptance:</strong> prod share → 201 with a manifest signature that verifies against the published key; unset-in-prod → 503.</li> <li><strong>Effort / risk:</strong> <strong>S–M.</strong> Risk: key must be exactly 64-hex (else silently falls to dev/null); ensure <code>NODE_ENV</code> is genuinely production.</li> </ul> <h2 id="74-editorial--cms-content-source-5-domain-rooms" class="hd hd-l2">7.4 Editorial / CMS content source (5 domain rooms)<a class="hd-anchor" href="#74-editorial--cms-content-source-5-domain-rooms" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Consumer:</strong> the room endpoints behind <code>/veritas,/nyx,/tara,/metis,/nisaba</code> (e.g. <code>/nisaba</code> ← <code>bffGet('/v1/nisaba/room')</code>). Curated copy currently ships as the BFF default content set (a real baseline, not a stub).</li> <li><strong>The gap (deploy):</strong> wire a real editorial/CMS backend feeding the same room endpoints. <strong>Caveat:</strong> if no content-source adapter interface exists, "feed the same endpoints" needs a code adapter — verify before treating as pure provisioning.</li> <li><strong>Tasks:</strong> <ul> <li><em>Withdrawn 2026-09-18: the owner's direction is first-party authoring, not an external CMS (the Tara content workbench and the V1 workbenches ledger publish room content; the Presentation and Docs Centers were ruled to stay CMS-free on 2026-09-11). Room content arrives through the workbench publish path.</em> Choose the CMS; model room schemas to match the BFF room response shapes.</li> <li><em>Withdrawn 2026-09-18: the owner's direction is first-party authoring, not an external CMS (the Tara content workbench and the V1 workbenches ledger publish room content; the Presentation and Docs Centers were ruled to stay CMS-free on 2026-09-11). Room content arrives through the workbench publish path.</em> Author/import the editorial corpus.</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Verify whether the BFF room handlers read from a swappable source; if not, that adapter is a code task (flag it). 2026-09-18: with the CMS withdrawn above, this becomes: confirm the room handlers read their content through one adapter interface that the workbench publish path can feed, and if any handler reads a constant or a fixture in production code, replace it with that adapter failing loud when unconfigured. Verify: a spec per room handler with the adapter doubled, and a grep that finds no production import of a fixture module."/> Verify whether the BFF room handlers read from a swappable source; if not, that adapter is a code task (flag it). <em>2026-09-18: with the CMS withdrawn above, this becomes: confirm the room handlers read their content through one adapter interface that the workbench publish path can feed, and if any handler reads a constant or a fixture in production code, replace it with that adapter failing loud when unconfigured. <strong>Verify:</strong> a spec per room handler with the adapter doubled, and a grep that finds no production import of a fixture module.</em></li> <li><em>Withdrawn 2026-09-18: the owner's direction is first-party authoring, not an external CMS (the Tara content workbench and the V1 workbenches ledger publish room content; the Presentation and Docs Centers were ruled to stay CMS-free on 2026-09-11). Room content arrives through the workbench publish path.</em> Point the endpoints at the CMS; verify each room returns CMS content with the unavailable fallback still firing when the CMS is unreachable.</li> <li><em>Withdrawn 2026-09-18: the owner's direction is first-party authoring, not an external CMS (the Tara content workbench and the V1 workbenches ledger publish room content; the Presentation and Docs Centers were ruled to stay CMS-free on 2026-09-11). Room content arrives through the workbench publish path.</em> Run the room SSR unit tests (6.4/6.5) against CMS-shaped fixtures.</li> </ul> </li> <li><strong>Acceptance:</strong> each room serves CMS content; pulling the CMS offline yields the honest unavailable surface (no crash, no fabricated copy).</li> <li><strong>Effort / risk:</strong> <strong>L.</strong> Risk: schema mismatch; the "same endpoints" claim may hide a missing adapter (verify first).</li> </ul> <h2 id="75-reminder-worker-interval--oshun_reminder_worker_interval_ms" class="hd hd-l2">7.5 Reminder worker interval — <code>OSHUN_REMINDER_WORKER_INTERVAL_MS</code><a class="hd-anchor" href="#75-reminder-worker-interval--oshun_reminder_worker_interval_ms" aria-label="Permalink to this section">#</a></h2> <ul> <li><strong>Consumer:</strong> <code>server.ts:316-367</code> (<code>startReminderDeliveryWorker</code> when <code>>0</code>; produce hook sweeps known users → Arete streak stats + Nyx event reminders; idempotent on <code>deliveredIds</code>).</li> <li><strong>Default without it:</strong> worker disabled (log <code>:364-366</code>); reminder routes stay live.</li> <li><strong>Tasks:</strong> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Choose an interval; set OSHUN_REMINDER_WORKER_INTERVAL_MS on exactly one replica (or behind leader election, :313-315)."/> Choose an interval; set <code>OSHUN_REMINDER_WORKER_INTERVAL_MS</code> on <strong>exactly one</strong> replica (or behind leader election, <code>:313-315</code>).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Ensure delivery transports are provisioned if external channels are wanted (§4 / 2.5); in-app needs nothing extra. Board tag 2026-09-18: external channel credentials are the owner's; in-app delivery needs nothing. blocked:external"/> Ensure delivery transports are provisioned if external channels are wanted (§4 / 2.5); in-app needs nothing extra. <em>Board tag 2026-09-18: external channel credentials are the owner's; in-app delivery needs nothing.</em> <code>blocked:external</code></li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Verify boot log "reminders: in-process delivery worker started (every Nms)" (:360-362)."/> Verify boot log "reminders: in-process delivery worker started (every Nms)" (<code>:360-362</code>).</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: Verify a scheduled reminder delivers on the next tick + is idempotent (deliveredIds)."/> Verify a scheduled reminder delivers on the next tick + is idempotent (<code>deliveredIds</code>).</li> </ul> </li> <li><strong>Acceptance:</strong> worker-started log on one replica; an at-risk-streak user gets one deduped in-app reminder per cycle; unset → disabled log + routes still respond.</li> <li><strong>Effort / risk:</strong> <strong>S.</strong> Risk: setting on multiple replicas without leader election (wasted work, never double-send).</li> </ul> <h2 id="76-provider-credentials-consolidated-activation-matrix" class="hd hd-l2">7.6 Provider credentials (consolidated activation matrix)<a class="hd-anchor" href="#76-provider-credentials-consolidated-activation-matrix" aria-label="Permalink to this section">#</a></h2> <p>All of the following are deploy-bound credentials; the in-repo code is fail-closed without them.</p> <div class="table-wrap"><table> <thead> <tr> <th>Capability</th> <th>Env vars</th> <th>Item</th> </tr> </thead> <tbody> <tr> <td>Image (illustration/explainer)</td> <td><code>OSHUN_STABILITY_API_KEY</code> (<code>OSHUN_STABILITY_MODEL?</code>)</td> <td>1.2</td> </tr> <tr> <td>Music</td> <td><code>OSHUN_SUNO_API_KEY</code> (<code>OSHUN_SUNO_BASE_URL?</code>, <code>OSHUN_SUNO_MODEL_VERSION?</code>)</td> <td>1.2</td> </tr> <tr> <td>Sky-briefing 3D</td> <td><code>OSHUN_MESHY_API_KEY</code></td> <td>1.2</td> </tr> <tr> <td>Narration (TTS)</td> <td><code>OSHUN_ELEVENLABS_API_KEY</code> + <code>OSHUN_ELEVENLABS_VOICE_ID</code></td> <td>1.2</td> </tr> <tr> <td>caption-dub / a11y ML + asset store</td> <td>dubbing/ASR/vision service + <code>OSHUN_ASSET_STORE_BUCKET</code>/<code>_ACCESS_KEY_ID</code>/<code>_SECRET_ACCESS_KEY</code> (<code>_REGION?</code>,<code>_ENDPOINT?</code>)</td> <td>1.3</td> </tr> <tr> <td>AI video</td> <td><code>OSHUN_VIDEO_FAL_KEY</code> (<code>OSHUN_VIDEO_MODEL?</code>) — wired fal LTX-Video path; or a ComfyUI/RunPod endpoint + weights for the alt path</td> <td>1.4</td> </tr> <tr> <td>Sophia prose</td> <td><code>OSHUN_LLM_API_BASE</code> + <code>OSHUN_LLM_API_KEY</code> + <code>OSHUN_LLM_MODEL</code></td> <td>1.5</td> </tr> <tr> <td>Email</td> <td><code>OSHUN_SENDGRID_API_KEY</code> + <code>OSHUN_MESSAGING_EMAIL_FROM</code> (<code>_SUBJECT?</code>)</td> <td>2.1/2.2/2.5</td> </tr> <tr> <td>SMS</td> <td><code>OSHUN_TWILIO_ACCOUNT_SID</code> + <code>_AUTH_TOKEN</code> + <code>_FROM</code></td> <td>2.1/2.5</td> </tr> <tr> <td>Push FCM</td> <td><code>OSHUN_FCM_ACCESS_TOKEN</code> + <code>_PROJECT_ID</code></td> <td>2.3a/2.5</td> </tr> <tr> <td>Push APNs</td> <td><code>OSHUN_APNS_TEAM_ID</code> + <code>_KEY_ID</code> + <code>_PRIVATE_KEY</code>(.p8) + <code>_BUNDLE_ID</code> (<code>_SANDBOX?</code>) <strong>+ HTTP/2 fetchImpl</strong></td> <td>2.3a/2.5</td> </tr> <tr> <td>Push Expo</td> <td><code>OSHUN_EXPO_ACCESS_TOKEN?</code> (OPTIONAL — basic-mode push needs none; channel is recipient-gated)</td> <td>2.3a/2.5</td> </tr> <tr> <td>Push Web (VAPID)</td> <td><code>OSHUN_VAPID_PUBLIC_KEY</code> + <code>_PRIVATE_KEY</code> + <code>_SUBJECT</code> (all three → <code>config.webPush</code>)</td> <td>2.3a/2.5</td> </tr> <tr> <td>WhatsApp</td> <td><code>OSHUN_WHATSAPP_ACCESS_TOKEN</code> + <code>_PHONE_NUMBER_ID</code> + <code>_TEMPLATE</code> + <code>_LANGUAGE</code></td> <td>2.1/2.5</td> </tr> <tr> <td>Slack / Discord</td> <td><code>OSHUN_SLACK_BOT_TOKEN</code> / <code>OSHUN_DISCORD_BOT_TOKEN</code></td> <td>2.5</td> </tr> <tr> <td>STT</td> <td>(provider key — Whisper/Deepgram/Google)</td> <td>2.3b</td> </tr> <tr> <td>Two-way calendar</td> <td>Google/CalDAV/Outlook OAuth creds</td> <td>2.3c</td> </tr> <tr> <td>LMS connectors</td> <td><code>OSHUN_LMS_CONNECTORS</code> (per-tenant JSON)</td> <td>3.3</td> </tr> <tr> <td>Deletion attestation</td> <td><code>OSHUN_DELETION_ATTESTATION_ED25519_PRIVATE_KEY</code></td> <td>4.1</td> </tr> <tr> <td>Payments</td> <td>rate-feed keys + chain RPC/BTCPay/OpenNode/PSP + wallet/xpub + <code>OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY</code> + webhook secrets</td> <td>4.3</td> </tr> </tbody> </table></div> <ul> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Completed: Add every var above to .env.example with one-line comments (today it documents none of the messaging ones — 2.5). _(Every var read by LIVE, WIRED code is now documented. Added a "Generation providers (AI media — Isis)" block — Stability (OSHUN_STABILITY_API_KEY/_MODEL), Suno (OSHUN_SUNO_API_KEY/_BASE_URL/_MODEL_VERSION), Meshy (OSHUN_MESHY_API_KEY), ElevenLabs (OSHUN_ELEVENLABS_API_KEY/_VOICE_ID), the S3 asset store (OSHUN_ASSET_STORE_BUCKET/_ACCESS_KEY_ID/_SECRET_ACCESS_KEY required, _REGION/_ENDPOINT optional), each with the fail-closed + release-gate note — plus OSHUN_LMS_CONNECTORS (3.3) and the C2PA signer (OSHUN_LIVING_SCENES_C2PA_SIGNING_KEY/_KEY_ID, 7.3) which were also wired-but-undocumented. Messaging/STT/email-verify/calendar were already present (2.5/2.2/2.3c). NOW [x] (2026-06-09 — the prior [~] rationale went stale): the two vars previously held back — OSHUN_DELETION_ATTESTATION_ED25519_PRIVATE_KEY (4.1) and OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY (4.3) — are now BOTH wired (4.1's full fan-out + resolveDeletionAttestationSigner, and 4.3's fail-closed resolvePaymentsReceiptSigner both shipped 2026-06-09) AND documented in .env.example (verified present). Also added the two wired-but-undocumented push channels from the session-6 transport work — OSHUN_EXPO_ACCESS_TOKEN (optional) + the OSHUN_VAPID_\*Web-Push triple — and added their rows to the matrix above. The only abstract matrix entries WITHOUT a concrete.env.examplevar are deploy-constructed providers with no in-repo var by design (caption-dub/a11y ML execution service, the payments chain-settlement RPC/BTCPay/wallet — owned by the InvoiceTargetProvisionerport, not a BFF env) — honestly no inert rows. So every concretely-named var the live code reads is documented:[x].)_" checked=""/> Add <strong>every</strong> var above to <code>.env.example</code> with one-line comments (today it documents none of the messaging ones — 2.5). _(Every var read by LIVE, WIRED code is now documented. Added a "Generation providers (AI media — Isis)" block — Stability (<code>OSHUN_STABILITY_API_KEY</code>/<code>_MODEL</code>), Suno (<code>OSHUN_SUNO_API_KEY</code>/<code>_BASE_URL</code>/<code>_MODEL_VERSION</code>), Meshy (<code>OSHUN_MESHY_API_KEY</code>), ElevenLabs (<code>OSHUN_ELEVENLABS_API_KEY</code>/<code>_VOICE_ID</code>), the S3 asset store (<code>OSHUN_ASSET_STORE_BUCKET</code>/<code>_ACCESS_KEY_ID</code>/<code>_SECRET_ACCESS_KEY</code> required, <code>_REGION</code>/<code>_ENDPOINT</code> optional), each with the fail-closed + release-gate note — plus <code>OSHUN_LMS_CONNECTORS</code> (3.3) and the C2PA signer (<code>OSHUN_LIVING_SCENES_C2PA_SIGNING_KEY</code>/<code>_KEY_ID</code>, 7.3) which were also wired-but-undocumented. Messaging/STT/email-verify/calendar were already present (2.5/2.2/2.3c). <strong>NOW <code>[x]</code> (2026-06-09 — the prior <code>[~]</code> rationale went stale):</strong> the two vars previously held back — <code>OSHUN_DELETION_ATTESTATION_ED25519_PRIVATE_KEY</code> (4.1) and <code>OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY</code> (4.3) — are now BOTH wired (4.1's full fan-out + <code>resolveDeletionAttestationSigner</code>, and 4.3's fail-closed <code>resolvePaymentsReceiptSigner</code> both shipped 2026-06-09) AND documented in <code>.env.example</code> (verified present). Also added the two wired-but-undocumented push channels from the session-6 transport work — <code>OSHUN_EXPO_ACCESS_TOKEN</code> (optional) + the <code>OSHUN_VAPID_\*</code>Web-Push triple — and added their rows to the matrix above. The only abstract matrix entries WITHOUT a concrete<code>.env.example</code>var are deploy-constructed providers with no in-repo var by design (caption-dub/a11y ML execution service, the payments chain-settlement RPC/BTCPay/wallet — owned by the <code>InvoiceTargetProvisioner</code>port, not a BFF env) — honestly no inert rows. So every concretely-named var the live code reads is documented:<code>[x]</code>.)_</li> <li class="task"><input class="task-cb" type="checkbox" disabled="" aria-label="Incomplete: After wiring 2.5's health endpoint, verify each capability activates by hitting its health/boot signal (never echo secret values)."/> After wiring 2.5's health endpoint, verify each capability activates by hitting its health/boot signal (never echo secret values).</li> </ul> <hr /> <hr /> <h1 id="appendix-a--reusable-patterns--helpers-dont-reinvent" class="hd hd-l1">Appendix A — Reusable patterns & helpers (don't reinvent)<a class="hd-anchor" href="#appendix-a--reusable-patterns--helpers-dont-reinvent" aria-label="Permalink to this section">#</a></h1> <ul> <li><strong>Durable snapshot stores:</strong> <code>apps/oshun/bff/src/admin/durable-stores.ts:227-240</code> (<code>attachAdminWorkspaceStateStoreSnapshot</code>); add a key at <code>:96</code>.</li> <li><strong>Admin auth:</strong> <code>getAuthorizedAdminContext(req,reply,'<scope>')</code> + <code>hasAdminScope</code> (<code>routes/admin.ts:7726-7764</code>); scopes <code>admin:*</code>/<code>admin:studio</code>/<code>admin:workspace:*</code>/<code>admin:<domain></code>. Operator helper <code>hasOperatorAdminScope</code> (<code>admin-operator-inbox.ts:33-40</code>).</li> <li><strong>Mutation route registration:</strong> <code>registerAdminMutationRoute</code> (<code>admin.ts:7716</code>); write-verb template <code>admin-operator-inbox.ts</code> + <code>operator-inbox-decision-store.ts</code> and <code>admin-metis-byom-decision.ts</code> + <code>metis/metis-byom-decision-store.ts</code>.</li> <li><strong>Audit hub:</strong> pass <code>{auditEventsStore}</code> on <code>app.register(...)</code> (<code>app.ts:1005</code>); <code>adminAuditEventsStore.record(...)</code>; read via <code>listAcrossOperators</code> (<code>admin-audit-events-store.ts</code>).</li> <li><strong>Ed25519 signed receipts (attestation/payments):</strong> <code>libs/oshun/payments-bridge/src/receipt-signer/receipt-signer.ts:21-78</code>.</li> <li><strong>HTTPS JWKS fetch:</strong> <code>tenant-console/lms-route.ts:116-122</code>.</li> <li><strong>Server-component unit test (SSR):</strong> <code>render(await Page())</code> + <code>vi.mock('@/lib/server/bff-fetch')</code> (templates: <code>app/operator/{sso,audit}/__tests__/page.test.tsx</code>).</li> <li><strong>Pure-BFF e2e with dev token:</strong> <code>dev.${base64url(JSON({sub,scopes,tid,exp}))}</code>; re-issue island fetches server-side via <code>page.route('**/v1/<area>/**', → admin bearer → route.fulfill)</code> (<code>incident-triage.spec.ts:57-65</code>).</li> <li><strong>Cross-link triangle (mandatory on every spec change):</strong> spec <code>E2E · Journey:</code> header ↔ journey doc <code>## E2E coverage</code> ↔ <code>WALKTHROUGH/journeys/coverage.md</code> row+grade. Convention: <code>WALKTHROUGH/00-conventions.md</code> § <em>E2E test coverage</em>.</li> </ul> <h1 id="appendix-b--effort-roll-up" class="hd hd-l1">Appendix B — Effort roll-up<a class="hd-anchor" href="#appendix-b--effort-roll-up" aria-label="Permalink to this section">#</a></h1> <div class="table-wrap"><table> <thead> <tr> <th>Part</th> <th>Items</th> <th>Quick wins (S)</th> <th>Medium (M)</th> <th>Large (L)</th> </tr> </thead> <tbody> <tr> <td>1 Generation</td> <td>6</td> <td>1.1, 1.5, 1.6(doc)</td> <td>1.2</td> <td>1.3, 1.4</td> </tr> <tr> <td>2 Messaging</td> <td>5</td> <td>2.5(docs)</td> <td>2.2, 2.3a/b, 2.4</td> <td>2.1, 2.3c</td> </tr> <tr> <td>3 Persona/SSO/LMS</td> <td>3</td> <td>3.3(list/validate)</td> <td>3.2</td> <td>3.1</td> </tr> <tr> <td>4 Privacy/Library/Payments</td> <td>3</td> <td>—</td> <td>4.2</td> <td>4.1, 4.3</td> </tr> <tr> <td>5 Operator/Audit/Abuse</td> <td>3</td> <td>—</td> <td>5.2</td> <td>5.1, 5.3</td> </tr> <tr> <td>6 PWA/SSR</td> <td>5</td> <td>6.1, 6.4, 6.5 (+6.2/6.3 S)</td> <td>6.3(seam)</td> <td>—</td> </tr> <tr> <td>7 Deploy infra</td> <td>6</td> <td>7.5</td> <td>7.1, 7.2, 7.3</td> <td>7.4</td> </tr> </tbody> </table></div> <p><strong>Recommended first sprint (all S, high leverage):</strong> 1.1 (keystone) · 1.5 (Sophia config) · 6.1 (captive-portal bug) · 6.4 + 6.5 (SSR tests) · 2.5 (.env + health) · 5.2 (port audit explorer). Then 1.2 → 1.3/1.4; the payments-bridge <code>ReceiptSigner</code> shared by 4.1 + 4.3; the audit hub shared by 5.1 + 5.3; the channel-binding store (2.1) feeding 2.3a + 2.4.</p> <hr /> <p><em>Generated 2026-06-08 from six parallel read-only source audits. Every <code>file:line</code> was verified against the working tree at audit time; re-verify before editing (the branch advances under concurrent sessions). Genuinely-blocked items (1.6 upload→governance; the runtime halves of 1.3/1.4/4.3/3.2-login; 7.4 if no adapter) are documented as blocked, not faked — fail loud, or ask.</em></p> </article> <footer class="doc-foot"> <span>Disciplines · Audits</span> <span><a href="../../vdocs-index.html">Documentation portal</a> · <a href="V1_REMAINING_WORK_IMPLEMENTATION_CHECKLIST_2026-06-08.md">Source</a></span> </footer> </main> <aside class="toc-rail" aria-label="On this page"> <div class="toc-head">On this page<span class="toc-count"></span></div> <div class="tocfilter-wrap"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"> <circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> <input id="tocFilter" type="text" placeholder="Filter sections" autocomplete="off" aria-label="Filter sections"/><!-- stub:legitimate: standard HTML placeholder attribute --> </div> <nav><ul class="toc"><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#how-to-read-this" class="toc-link">How to read this</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#-forbidden-stubs-found--fixed-quality-standards-violations" class="toc-link">✅ Forbidden stubs found — FIXED (Quality Standards violations)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#corrections-to-v1_deployment_requirementsmd-verified-against-source" class="toc-link">Corrections to V1_DEPLOYMENT_REQUIREMENTS.md (verified against source)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#master-sequencing--dependency-graph" class="toc-link">Master sequencing & dependency graph</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#11-release-gate-measurement-bridge-keystone--build-first" class="toc-link">1.1 Release-gate measurement bridge (KEYSTONE — build first)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#12-provider-credential-matrix--measurement-emission" class="toc-link">1.2 Provider credential matrix + measurement emission</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#13-caption-dub--accessibility-pass-providers" class="toc-link">1.3 caption-dub + accessibility-pass providers</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#14-ai-video-model-for-living-scenes-comfyuirunpod-enhancement-built-2026-06-09--via-curated-cards--fal-ltx-video-user-build-the-content-drop-producer-too--proceed" class="toc-link">1.4 AI video model for Living Scenes (ComfyUI/RunPod enhancement) (BUILT 2026-06-09 — via curated cards + fal LTX-Video; user: "build the content-drop producer too" → "proceed")</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#15-sophia-answer-prose-synthesis-already-built--config--test--doc-only" class="toc-link">1.5 Sophia /answer prose synthesis (already built — config + test + doc only)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#16-upload--model-governance-enforcement-built-2026-06-09--user-confirmed-v1-scope" class="toc-link">1.6 upload → model-governance enforcement (BUILT 2026-06-09 — user confirmed V1 scope)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#21-channel-binding-ui-whatsapp--sms--email-per-user" class="toc-link">2.1 Channel-binding UI (WhatsApp / SMS / email, per-user)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#22-email-verify-round-trip" class="toc-link">2.2 Email-verify round-trip</a></div></li><li class="toc-h2 has-children collapsed"><div class="toc-row"><button class="toc-toggle" type="button" aria-label="Toggle section"></button><a href="#23-push--stt--two-way-calendar" class="toc-link">2.3 Push / STT / two-way calendar</a></div><ul class="toc-sub"><li><a href="#23a-push-fcmapns" class="toc-link toc-sub-link">2.3(a) Push (FCM/APNs)</a></li><li><a href="#23b-stt-speech-to-text--fixes-two-forbidden-stubs" class="toc-link toc-sub-link">2.3(b) STT (speech-to-text) — FIXES TWO FORBIDDEN STUBS</a></li><li><a href="#23c-two-way-calendar" class="toc-link toc-sub-link">2.3(c) Two-way calendar</a></li></ul></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#24-reminder-producers-due-assignment--upcoming-session--content-drop-all-three-twins-built-2026-06-09--matrix-complete" class="toc-link">2.4 Reminder producers (due-assignment / upcoming-session / content-drop) (ALL THREE TWINS BUILT 2026-06-09 — matrix complete)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#25-provider-credential-matrix--envexample--health-endpoint-quick-win" class="toc-link">2.5 Provider credential matrix + .env.example + health endpoint (quick win)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#31-persona-lifecycle-store--consumer-session-picker" class="toc-link">3.1 Persona lifecycle store + consumer session-picker</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#32-tenant-admin-sso-editor" class="toc-link">3.2 Tenant-admin SSO editor</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#33-lms-connector-management" class="toc-link">3.3 LMS connector management</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#41-iris-6-service-deletion-fan-out--per-tombstone-attestation" class="toc-link">4.1 Iris 6-service deletion fan-out + per-tombstone attestation</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#42-library-cross-device-item-sync--inline-paywall" class="toc-link">4.2 Library cross-device item sync + inline paywall</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#43-payments-crypto-settlement-in-repo-composition-built-2026-06-09--live-provisioner-deploy-bound" class="toc-link">4.3 Payments crypto settlement (in-repo composition BUILT 2026-06-09 — live provisioner deploy-bound)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#51-incident-write-verbs-acknowledge--assign--escalate--snooze--resolve" class="toc-link">5.1 Incident write verbs (acknowledge / assign / escalate / snooze / resolve)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#52-audit-explorer-ui-port-the-existing-admin-app-explorer--quick-win" class="toc-link">5.2 Audit explorer UI (port the existing admin-app explorer — quick win)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#53-public-scene-abuse-report--operator-inbox-surfacing" class="toc-link">5.3 Public-scene abuse report → operator inbox surfacing</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#61-captive-portal-healthz-false-online-genuine-correctness-bug--fix-first" class="toc-link">6.1 Captive-portal /healthz false-online (genuine correctness bug — fix first)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#62-manifest-404-affordance" class="toc-link">6.2 Manifest-404 affordance</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#63-skip-waiting-lost-stuck-apply-timeout" class="toc-link">6.3 Skip-waiting-lost stuck-apply timeout</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#64-nisaba-ssr-deep-read-unit-test" class="toc-link">6.4 Nisaba SSR deep-read unit test</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#65-veritas-ssr-deep-read-unit-test" class="toc-link">6.5 Veritas SSR deep-read unit test</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#71-postgres--oshun_v1_database_url--oshun_admin_database_url" class="toc-link">7.1 Postgres — OSHUN_V1_DATABASE_URL / OSHUN_ADMIN_DATABASE_URL</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#72-redis--oshun_redis_url--oshun_bff_idempotency_redis_url" class="toc-link">7.2 Redis — OSHUN_REDIS_URL / OSHUN_BFF_IDEMPOTENCY_REDIS_URL</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#73-c2pa-ed25519-signing-key--oshun_living_scenes_c2pa_" class="toc-link">7.3 C2PA Ed25519 signing key — OSHUN_LIVING_SCENES_C2PA_*</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#74-editorial--cms-content-source-5-domain-rooms" class="toc-link">7.4 Editorial / CMS content source (5 domain rooms)</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#75-reminder-worker-interval--oshun_reminder_worker_interval_ms" class="toc-link">7.5 Reminder worker interval — OSHUN_REMINDER_WORKER_INTERVAL_MS</a></div></li><li class="toc-h2"><div class="toc-row"><span class="toc-bullet"></span><a href="#76-provider-credentials-consolidated-activation-matrix" class="toc-link">7.6 Provider credentials (consolidated activation matrix)</a></div></li></ul></nav> </aside> </div> </div> </div> <button id="backtop" aria-label="Back to top" aria-hidden="true" tabindex="-1"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" aria-hidden="true"> <path d="M12 19V5M5 12l7-7 7 7"/></svg> </button> <div class="cmdk" id="cmdk" aria-hidden="true"> <div class="cmdk-backdrop" data-cmdk-close></div> <div class="cmdk-panel" role="dialog" aria-modal="true" aria-label="Search documentation"> <div class="cmdk-inputwrap"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"> <circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> <input id="cmdkInput" type="text" autocomplete="off" spellcheck="false" role="combobox" aria-expanded="false" aria-controls="cmdkResults" aria-autocomplete="list" aria-haspopup="listbox" placeholder="Search the documentation center…" aria-label="Search query"/><!-- stub:legitimate: standard HTML placeholder attribute --> <kbd>esc</kbd> </div> <div class="cmdk-facets" id="cmdkFacets" role="group" aria-label="Filter results by type, audience, layer, and version"></div> <div class="cmdk-results" id="cmdkResults" role="listbox"></div> <div class="cmdk-foot"> <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span> <span><kbd>↵</kbd> open</span> <span><kbd>esc</kbd> close</span> <span class="cmdk-foot-sp"></span> <span id="cmdkCount"></span> </div> </div> </div> <div class="kmodal" id="kmodal" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" aria-hidden="true"> <div class="kmodal-backdrop"></div> <div class="kmodal-panel"> <div class="kmodal-head"> <b>Keyboard shortcuts</b> <button class="icon-btn" id="kmClose" aria-label="Close keyboard shortcuts"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"> <path d="M18 6L6 18M6 6l12 12"/></svg> </button> </div> <dl class="kmodal-list"> <div class="kmodal-row"><dt>Search all docs</dt><dd><kbd>/</kbd><kbd>⌘K</kbd></dd></div> <div class="kmodal-row"><dt>Move through results</dt><dd><kbd>↑</kbd><kbd>↓</kbd> then <kbd>Enter</kbd></dd></div> <div class="kmodal-row"><dt>Toggle left navigation</dt><dd><kbd>[</kbd></dd></div> <div class="kmodal-row"><dt>Toggle on-page contents</dt><dd><kbd>]</kbd></dd></div> <div class="kmodal-row"><dt>Close overlays</dt><dd><kbd>Esc</kbd></dd></div> <div class="kmodal-row"><dt>This panel</dt><dd><kbd>?</kbd></dd></div> </dl> </div> </div> <div class="mmodal" id="mmodal" role="dialog" aria-modal="true" aria-label="Expanded diagram viewer" aria-hidden="true"> <div class="mmodal-bar"> <b>Diagram — scroll to zoom, drag to pan</b> <div class="mmodal-tools"> <button id="mmZoomOut" title="Zoom out" aria-label="Zoom out">−</button> <button id="mmReset" title="Reset" aria-label="Reset zoom" style="font-size:13px;">1:1</button> <button id="mmZoomIn" title="Zoom in" aria-label="Zoom in">+</button> <button id="mmClose" title="Close" aria-label="Close diagram">×</button> </div> </div> <div id="mstage"></div> <div class="mmodal-hint">Press Esc to close</div> </div> <script src="../../docs-center/assets/reader.js"></script> <script src="../../docs-center/assets/vendor/highlight.min.js"></script> <script>document.querySelectorAll('.code-card pre code').forEach(function(b){try{hljs.highlightElement(b);}catch(e){}});</script> </body> </html>