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

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-71`** —
   `transcribeTelegramVoice` 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:

```
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,407`→`review`);
  `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:**
  - [x] 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.
  - [x] 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).
  - [x] 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 `releaseMeasurement`** —
  `adaptImageProviderGenerate` 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:**
  - [x] Build 1.1 first. _(Done in the keystone session —
        `provider-measurement.ts`.)_
  - [x] **(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.)_
  - [x] **(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.\_
  - [x] **(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.)*
  - [x] **(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).)_
  - [x] **(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).)_
  - [x] **(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.)_
  - [x] **(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).)_
  - [x] **(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)
      and`OSHUN*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 provider** — `createCaptionDubExecutor(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→`complete`confirm 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: set`OSHUN*ASSET_STORE*\*`through a throwaway env script, submit the card, and assert`complete`
        with a real ratio.\_
  - [x] **(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 at`caption-dub/{dubbingId}/{lang}.{ext}`).     Multi-locale: BCP-47 tags collapse to primary-subtag dubbing languages     (`es-MX`+`es`→ one`es`dub), each gets its own dub/store/scan, and the     executor seam forwards a per-locale`outputs[]`. 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`.)\_
  - [x] **(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".)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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-providers` — `generateVideo` 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/render` → `renderLivingSceneSegmentToPng`,
  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:**
  - [x] 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).
  - [x] Create `generation/video-executor.ts` (mirror `image-executor.ts`;
        `outputKind:'video'`). **DONE** — two executors (t2v + i2v),
        fail-closed, forward governance evidence verbatim.
  - [x] **(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).
  - [x] Build the video `releaseMeasurement` (1.1). **DONE** — honest
        fail-closed (safety/provenance/watermark all absent → blocks; never
        fabricated).
  - [x] **(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.
  - [x] **(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:**
  - [x] Add `OSHUN_LLM_API_BASE`/`_API_KEY`/`_MODEL` to deploy config; add a §8
        row.
  - [x] Fix the stale comment at `routes/sophia.ts:20`; correct §9 to "seam
        built + wired; activate via env."
  - [x] **(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).
  - [x] **(unit)** Confirm `sanitize` (`:98`) drops a claim citing an unknown id
        and coerces `model-only`→`synthesized` (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:**
  - [x] **(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.)_
  - [x] 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.)_
  - [x] 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.)_
  - [x] 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.)_
  - [x] **(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" CONFIRMED** — `DEMO_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:**
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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).)_
  - [x] **(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.)_
  - [x] **(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`).)_
  - [x] **(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).)_
  - [x] **(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.)_
  - [x] **(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).)_
  - [x] **(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.)_
  - [x] **(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 `unbound` →
        `pending` + 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-label`s; (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-config`in prod /`preview`in 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 of`docs/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:**
  - [x] **(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`.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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/*`.
  - [x] **(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.)\_
  - [x] **(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.)_
  - [x] **(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:**
  - [x] 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._
  - [x] 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`.)_
  - [x] 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`/`apns`→`recipient.deviceToken`,
        `expo`→`expoPushToken`, `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`._
  - [x] 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→`unavailable`fail-closed state. 4/4 green at 1 worker; the     adjacent`profile-notification-preferences.spec.ts`
        re-run green (5/5, no regression); web tsc clean; coverage.md row
        updated.)\_
  - [x] 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:**
  - [x] 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.)_
  - [x] 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.)_
  - [x] 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.)_
  - [x] `stt-provider.test.ts`: real transcript extraction, error path, and
        `missing-config` (assert it NEVER returns a fake transcript —
        stub-elimination check).
  - [x] 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:**
  - [x] 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
        side** — `createGoogleOAuthAccessTokenResolver` 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 too** —
        `POST /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 too** —
        `calendar/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-09** —
        `CalendarOAuthConnectionsSnapshot` (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._
  - [x] 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.ts` —
        `GET /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.)_
  - [x] 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.)_
  - [x] `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.)_
  - [x] **(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`.)_
  - [x] **(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.)_
  - [x] 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` → "<title> goes live", 48h window). Wired
> into the worker `produce` hook
> (`return [...streaks, ...nyx, ...assignments, ...sessions, ...contentDrops]`) +
> durable hydrate + shutdown teardown. **AUDIENCE-MODEL RESOLUTION:** 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 `content.drop.scheduled` per recipient, exactly as the LMS
> publishes one `lms.assignment.upserted` 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 `/v1/admin/reminders/produce` (`reminders/reminder-producer-store.ts`)
> remains as the alternative editor-submits-a-batch surface; both coexist. **The
> reminder-producer matrix is now COMPLETE — all four lib mappers have an
> automatic per-recipient worker source: streaks + assignments + sessions +
> content-drops.**

- **Status today:** Streak path fully built (BFF known-user sweep
  `reminders/streak-reminder-producer.ts:62-91` wired `server.ts:330-344`; Arete
  all-users `produceStreakLossReminders` over `listHabitsAtRiskOfStreakLoss`
  (`apps/arete/api/src/repositories/habits.ts:148,545-573`), admin
  `POST /v1/reminders/streak-due`). The **producer mappers for the other two
  kinds already exist**: `produceAssignmentReminders`
  (`messaging-channels/src/reminder-producers.ts:124-135`) and
  `produceV3SessionReminders` (`:76-87`) — pure mappers, unfed.
- **The gap:** The **domain bulk-enumeration queries** that feed them — no LMS
  `listAssignmentsDueBetween(window)` and no session
  `listUpcomingSessions(window)` analogous to `listHabitsAtRiskOfStreakLoss`.
  (Documented at `streak-reminder-producer.ts:5-12`.)
- **External dependency:** None for code — it's identifying/implementing the
  owning service's bulk query (LMS for assignments; Nyx and/or Metis for
  sessions).
- **Granular tasks:**

  > **NOTE (2026-06-10):** the boxes below describe the LMS-/owning-service-
  > native mechanism (bulk queries + per-service admin cron routes). They stay
  > `[ ]` because a DIFFERENT mechanism was chosen and built 2026-06-09 (see the
  > section banner): BFF-side durable projections fed by `lms.assignment.*` /
  > `session.*` / `content.drop.*` 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.
  - **Due-assignment:**
    - _Not built — rejected alternative (2026-06-10 note above):_ Add
      `listAssignmentsDueBetween(fromIso,toIso)` to the LMS assignment repo
      (Memory + Pg), returning
      `{userId,courseId,assignmentId,assignmentTitle,dueAtIso}[]` for active
      enrollments with no submission — mirror `habits.ts:142-148`.
    - _Not built — rejected alternative (2026-06-10 note above):_ Repo tests:
      window boundary, excludes submitted/inactive, ordering.
    - _Not built — rejected alternative (2026-06-10 note above):_
      `apps/<lms>/api/src/reminders/assignment-reminder-production.ts` mapping
      rows
      - `UserRepository` (recipient/tz) → `produceAssignmentReminders`.
    - _Not built — rejected alternative (2026-06-10 note above):_ Admin
      `POST /v1/reminders/assignments-due` (copy
      `apps/arete/api/src/routes/reminders.ts:50-106`).
    - _Not built — rejected alternative (2026-06-10 note above):_ Sweep + route
      tests.
  - **Upcoming-session:**
    - _Not built — rejected alternative (2026-06-10 note above):_ Add
      `listUpcomingSessions(fromIso,toIso)` to the owning service (Nyx/Metis),
      returning `UpcomingV3Session`-shaped rows for opted-in users.
    - _Not built — rejected alternative (2026-06-10 note above):_ Production
      sweep → `produceV3SessionReminders`; recipient via `UserRepository`.
    - _Not built — rejected alternative (2026-06-10 note above):_ Admin
      `POST /v1/reminders/sessions-upcoming`.
    - _Not built — rejected alternative (2026-06-10 note above):_ Query +
      sweep + route tests.
  - **Shared:**
    - _Not built — rejected alternative (2026-06-10 note above):_ Point a daily
      cron (or the BFF worker `produce` hook) at the two sweeps →
      `runReminderCycle` → §4 transports.
    - _Not built — rejected alternative (2026-06-10 note above):_ Verify dedup
      via stable `sessionId` keys (`reminder-producers.ts:129`).
    - _Not built — rejected alternative (2026-06-10 note above):_ Document the
      two cron endpoints in §4 (the "remaining external slices" note `:86-87`
      becomes wired).

- **Acceptance criteria:** 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.
- **Verification:**
  `cd apps/arete/api && npx vitest run src/reminders/streak-reminder-production.test.ts`
  (reference) ·
  `cd libs/oshun/messaging-channels && npx vitest run src/reminder-producers.test.ts`
  · `cd apps/oshun/bff && npx vitest run src/reminders/`
- **Effort / risk:** **M–L.** Risk: identifying the "sessions" owner (Nyx vs
  Metis); tz-correct deadlines (reuse `endOfLocalDayUtcIso`); paginate/index the
  bulk query.

## 2.5 Provider credential matrix + `.env.example` + health endpoint **(quick win)**

- **Status today:** The matrix is complete in `provider-config-env.ts:49-130`
  (all-vars-present gate proven `:56-129`). **But `.env.example` documents NONE
  of the messaging/reminder vars** (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).
- **The gap:** Operational surface — docs, a health endpoint, the H2 injection.
- **Exact var matrix** (source `provider-config-env.ts`):
  email→`OSHUN_SENDGRID_API_KEY`+ `OSHUN_MESSAGING_EMAIL_FROM` (`:62-67`);
  sms→`OSHUN_TWILIO_ACCOUNT_SID`+`_AUTH_TOKEN`+ `_FROM` (`:69-74`);
  push-fcm→`OSHUN_FCM_ACCESS_TOKEN`+`_PROJECT_ID` (`:76-85`);
  push-apns→`OSHUN_APNS_TEAM_ID`+`_KEY_ID`+`_PRIVATE_KEY`(.p8)+`_BUNDLE_ID`
  (+`_SANDBOX?`) (`:89-104`);
  whatsapp→`OSHUN_WHATSAPP_ACCESS_TOKEN`+`_PHONE_NUMBER_ID`+
  `_TEMPLATE`+`_LANGUAGE` (`:106-117`); slack→`OSHUN_SLACK_BOT_TOKEN`; discord→
  `OSHUN_DISCORD_BOT_TOKEN`.
- **External dependency:** The creds themselves + an H2 fetch lib; docs +
  endpoint are pure code.
- **Granular tasks:**
  - [x] Add a "Messaging / Notifications" block to `.env.example` enumerating
        every var above (+ optionals, the APNs `.p8` multi-line note,
        `OSHUN_REMINDER_WORKER_INTERVAL_MS`).
  - [x] Add `GET /v1/admin/messaging/configured-channels` (admin-gated, pattern
        `admin-messaging-channels.ts:97-112`) returning
        `configuredChannelsFromEnv()` — **channel names only, never secret
        values**.
  - [x] Health-endpoint test: lists exactly the configured channels, leaks no
        secret.
  - [x] 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)._
  - [x] Slack/Discord: if user-facing, extend 2.1 to capture `slackChannel`/
        `discordChannelId`; else document as ops-only.
  - [x] 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)._
- **Acceptance criteria:** `.env.example` 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 + `missing-config`;
  APNs uses H2.
- **Verification:**
  `cd libs/oshun/messaging-channels && npx vitest run src/provider-config-env.test.ts`
  · `grep -ni 'sendgrid\|twilio\|fcm\|apns\|whatsapp' .env.example`
- **Effort / risk:** **S** (docs + endpoint) + **M** (H2 injection). Risk: never
  echo secrets; APNs H2 is the one substantive runtime risk.

---

---

# Part 3 — Persona Lifecycle, Tenant SSO & LMS

## 3.1 Persona lifecycle store + consumer session-picker

- **Status today:** The validator is complete: `transitionPersonaLifecycle`
  (`libs/oshun/persona-registry/src/lifecycle.ts:356-483`), transition table
  `:93-219` (13 events), 8 canonical states (11 w/ aliases, `index.ts:71-125`).
  The consumer **browse/select lib is fully built + tested**
  (`customer-persona-browse.ts:284,385`, eligibility `:195`, disclosure gate).
  **But `transitionPersonaLifecycle` has ZERO non-test callers** — no store, no
  audit log; the admin store uses an unrelated 3-value `AdminPersonaStatus`
  (`apps/oshun/bff/src/admin/state.ts:2023`) and `publishPersona` hard-sets
  `'published'` (`:16667`) without the validator. **No oshun consumer
  session-picker** (the only `PersonaSelector.tsx` is in the separate lilith
  app).
- **The gap:** (1) lifecycle persistence + transition route + audit log; (2) a
  BFF route exposing browse/select + a consumer picker UI.
- **External dependency:** None — pure code (durable once
  `OSHUN_ADMIN_DATABASE_URL` set, like all admin stores).
- **Granular tasks:**
  - [x] **(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.)_
  - [x] **(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.)_
  - [ ] **(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._
  - [x] **(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.)_
  - [x] **(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.)_
  - [ ] **(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._
  - [x] **(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).)_
  - [x] **(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).)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(cross-link)** Update
        `ADMIN_WALKTHROUGH/journeys/persona-release-cycle.md`
    - matrix; add a consumer journey doc + `coverage.md` row; source-grounding
      headers. _(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→**deep** 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.)_
- **Acceptance criteria:** A persona drives through all 8 canonical states via
  the lifecycle route, each persisted + audited; every illegal event → 409 +
  `errors[]`, status unchanged. `GET /v1/personas` returns eligible cards;
  `/select` 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.
- **Verification:**
  `cd libs/oshun/persona-registry && npx vitest run src/lifecycle.test.ts src/customer-persona-browse.test.ts`
  ·
  `cd apps/oshun/bff && npx vitest run src/admin/persona-lifecycle-store.test.ts src/__tests__/admin-persona-lifecycle-route.test.ts`
- **Effort / risk:** **L.** Risk: reconcile the coarse 3-state
  `AdminPersonaStatus` with the 8-state model (single source of truth); confirm
  `LAUNCH_PERSONA_CONFIGS` is populated so the picker isn't empty.

## 3.2 Tenant-admin SSO editor

- **Status today:** Read path is a **non-prod fixture** — `/v1/sso` via
  `guardedFixtureRoute` (`domain-stubs.ts:1645`, fixture `:514-544`, blocked in
  prod). `OperatorSsoPage` is **read-only** (`app/operator/sso/page.tsx:43`, no
  CRUD). The auth-policy route (`admin-studio-auth-policy.ts`) is login-risk
  scoring, **not** connector CRUD. **Two real, unwired SSO models exist:**
  `@oshun/tenant-console` `SsoConnection`/`SsoClaimMapping`/`processSsoLogin`
  (`identity/sso.ts:9-125`) and the full `@yemaya/organizations` `SSOService`
  (~1800 lines, CRUD repo + SAML/OIDC adapter seams,
  `sso-service.ts:118-120,196,223,1683`). Neither is route-wired.
- **The gap:** 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.
- **External dependency:** The CRUD store + routes + editor UI are **pure code**
  (both models exist). Runtime-only externals (needed to _exercise_ a login, not
  to build the editor): a concrete
  `ISamlResponseValidator`/`IOidcTokenExchanger` impl
  (`sso-service.ts:196,223`), a live IdP metadata/JWKS, network egress for
  test-connection.
- **Granular tasks:**
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.
  - [x] **(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.)_
  - [x] **(wire)** Register in `server.ts` near LMS/tenant-console (`:282-294`).
        _(Registered in `app.ts` with the other admin routes —
        createApp-testable; same effect.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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).)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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).)_
- **Acceptance criteria:** An operator can create/view/edit (incl. claim
  mappings + JWKS)/delete a connector through `/operator/sso`, persisted + shown
  in prod (no fixture); test-connection reports real IdP reachability + fails
  closed; a saved mapping round-trips through `processSsoLogin`; CRUD enforces
  auth + scope.
- **Verification:**
  `cd apps/oshun/bff && npx vitest run src/tenant-console/sso-connection-store.test.ts src/tenant-console/sso-route.test.ts`
  · `cd libs/oshun/tenant-console && npx vitest run src/identity/sso.test.ts`
- **Effort / risk:** **M.** 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.

## 3.3 LMS connector management

- **Status today:** Runtime complete + tested — routes mounted
  (`tenant-console/ lms-route.ts:135,171,246,287`), id_token JWS verifier +
  OIDC + SCORM RTE implemented, boot validation real
  (`createTenantLmsRuntimeFromEnv:95-129` over `OSHUN_LMS_CONNECTORS`, validates
  each via `lms.ts:910-1002`, skips invalid, 404s when none, fetches JWKS live).
  **No connector-management read/write surface** — no GET/list route, no UI;
  operators can only edit env JSON + redeploy, and **skip reasons are silently
  discarded** (`lms-route.ts:108-110`).
- **The gap:** Operability — visibility (list) + validation feedback; optionally
  in-app editable connectors.
- **External dependency:** **None** to build the read/list/validate routes + UI
  (pure code over the existing descriptor/validation/framework). The genuinely
  external inputs (per-tenant `OSHUN_LMS_CONNECTORS` values + the platform's
  live JWKS) are tenant-provided config.
- **Granular tasks:**
  - [x] Retain per-connector validation results in
        `createTenantLmsRuntimeFromEnv` (the catch at `:108-110` currently
        discards them). _(Now structured `invalidConnectors[]`; malformed JSON
        surfaced too.)_
  - [x] Add `listConnectors(tenantId?)` to `LmsConnectorFramework`
        (`lms.ts:346-382`). _(Already present at `lms.ts:397` — the GET route
        consumes it.)_
  - [x] **(GET list)** `GET /v1/admin/lms/connectors` →
        `{connectors[], invalid:[{id, issues}]}`, admin-gated. (Highest-value,
        lowest-risk addition.) _(Summary omits secrets.)_
  - [x] **(POST validate)** `POST /v1/admin/lms/connectors/validate` → run
        `createLmsConnectorDescriptor` + `validateLmsConnectorDescriptor`,
        return the `ConnectorValidationResult` (no persistence).
  - [x] **(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
    - `POST/PATCH/DELETE` that validate-then-register; document precedence.
      **Recommend env + list/validate first.** _(Followed the recommendation —
      env + list/validate only; no CRUD store built.)_
  - [x] **(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.)_
  - [x] **(unit)** Extend `lms-route.test.ts`: mixed valid/invalid env →
        registers valid
    - 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.
  - [ ] **(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._
  - [x] **(cross-link + deploy)** Add an LMS-connectors journey + operator
        walkthrough
    - coverage; expand §3 with a copy-pasteable per-provider
      `OSHUN_LMS_CONNECTORS` JSON example. _(DONE 2026-06-10 — NEW
      `WALKTHROUGH/journeys/lms-connectors-operator.md` (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 `WALKTHROUGH/operator/operator-lms.md`
      (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) —
      **all three verified `valid:true, issues:[]` against the REAL
      `validateLmsConnectorDescriptor`** via tsx before committing (a
      copy-pasteable example that doesn't validate would be a doc stub).)_
- **Acceptance criteria:** `GET .../connectors` returns live connectors +
  skipped ones with issues; `POST .../validate` 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.
- **Verification:**
  `cd apps/oshun/bff && npx vitest run src/tenant-console/lms-route.test.ts` ·
  `cd libs/shared/inbound-integrations && npx vitest run src/lms.spec.ts src/lti-verification.spec.ts`
- **Effort / risk:** **S–M.** 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.

---

---

# Part 4 — Privacy/Deletion, Library Sync & Payments

> **Shared primitive:** `libs/oshun/payments-bridge/src/receipt-signer` (Ed25519
> signed receipts) backs **both** 4.1 (per-tombstone attestation) and 4.3
> (payment receipts). Standardize on it — don't write two attestation
> implementations.

## 4.1 Iris 6-service deletion fan-out + per-tombstone attestation

- **Status today:** Customer deletion is a **pure in-memory state machine with
  no real erasure** — `apps/oshun/bff/src/data-deletion/state.ts` imports only
  `@oshun/contracts`; `buildTombstones` (`:394-410`) +
  `buildDerivedArtifactJobs` (`:371-392`) fabricate records; `markCompleted`
  (`:276-296`) + `applyTransitions` (`:412-444`) just flip statuses — **no
  service is ever called**. The "6 services" = the 6 derived-artifact cascade
  types (`:377-384`:
  `memory_scope, voice_profile, avatar_pack, generated_artifact, personalization_vector, conversation_history`).
  A **real erasure engine exists but is orphaned**:
  `libs/oshun/persistence/src/ dsar-deletion-cascade.ts` (real Prisma purge +
  memory forget, fail-closed `DsarDeletionReceipt`), reachable only via operator
  `POST /v1/admin/privacy/dsar/erase` (`privacy/dsar-erasure-route.ts:35-64`,
  caller must supply the subject→records map). Attestation primitive available
  (`payments-bridge/src/receipt-signer/receipt-signer.ts:21-78`). Tombstone
  schema has **no attestation field**
  (`libs/contracts/src/common/customer-data-deletion.ts:128-134`).
- **The gap:** (1) the 6 jobs are never dispatched; (2) no per-tombstone signed
  attestation; (3) no orchestration linking `POST /deletions` → cascade →
  per-service receipts → completed/failed; (4) no A-to-Z test that reads each
  service back.
- **External dependency:** 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, `dsar-deletion-cascade.ts:14-15`). The
  signing key is a deploy input (Ed25519, like `OSHUN_LIVING_SCENES_C2PA_*`). A
  service with no programmatic delete is the irreducible blocker for that
  service.
- **Granular tasks:**
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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`.)_
  - [x] **(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).)_
  - [x] **(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).)_
  - [x] **(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.)_
  - [ ] **(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._
  - [x] **(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.)_
- **2026-06-09 session update — all 6 categories now ERASE for real,
  end-to-end.** The earlier `[x]` erasers were ports awaiting backing stores;
  this session built
  - wired them live in `server.ts` (conditional on Redis + the attestation
    signer, fail-closed; default boot unchanged):
  * **generated_artifact** → real isis `GeneratedOutput.deleteMany({ownerId})`
    via `@oshun/privacy/deletion-erasers` (lazy `@isis/database` import behind
    the module boundary). Loader bug fixed first — the Prisma-7 generator emits
    `client.ts`, not `index.js`, so every consumer threw "client not generated".
    SQL-proven against the live local isis DB.
  * **personalization_vector** + **conversation_history** → new durable
    snapshot- backed BFF stores (`src/personalization/`, `src/conversation/`),
    each fed by a real cross-process event consumer
    (`personalization.vector.updated` / `conversation.turn.recorded`) and erased
    per-subject. Proven over live Redis (publish → durably recorded → eraser
    removes exactly that subject).
  * **memory_scope** → real `MemoryService.deleteAllForUser` in iris/api (sweeps
    every tier via `userMemories` + core blocks, durable across restart), erased
    by iris's OWN event-driven deletion consumer. After extracting the fan-out
    core to `@oshun/deletion-fanout` (scope:shared), iris runs
    `startMemoryDeletionConsumer` (`apps/iris/api/src/deletion/`) which
    subscribes to `subject.deletion.requested` 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.)
  * **voice_profile / avatar_pack** → honest no-subject-data (shared catalogs).
  * Server-boot fan-out wiring proven over live Redis; the grace-expiry worker +
    bus teardown wired; BFF + iris tsc/eslint clean. Commits `bfda1c51f4`,
    `268a6b6e70`, `1394a89afa`, `a5e0747aef`, `77ec1adb6a`, `ff350b5e82`. Still
    open (left `[ ]` above): the (web) attestation UI, the single (tests) A-to-Z
    route test, and the (e2e).
- **Acceptance criteria:** An `account_full` deletion makes a real delete call
  to each of the 6 services, produces one verifiable Ed25519 attestation per
  tombstone, sets `completed` iff all succeeded/absent else `failed` with the
  offending service, and an integration test reads each service back and finds
  the subject erased.
- **Verification:**
  `cd apps/oshun/bff && npx vitest run src/__tests__/data-deletion-route.test.ts src/__tests__/export-deletion-flows.integration.test.ts`
  ·
  `cd libs/oshun/privacy && npx vitest run src/export-deletion/deletion-fanout.test.ts`
- **Effort / risk:** **L.** Risk: a domain may lack `deleteForSubject`
  (per-domain work, possible hard blocker); subject→records enumeration is
  non-trivial; keep fail-closed (a missing delete API must surface as `failed`,
  never silent `completed`).

## 4.2 Library cross-device item sync + inline paywall

- **Status today:** Saved items are **localStorage-only on web**
  (`apps/oshun/web/src/lib/library/webLibraryStore.ts:12,105-180`, no server
  call). `GET /v1/library` is read-only aggregation, **no POST/DELETE to save
  arbitrary items** (`routes/library.ts:189-311`). `library-sharing.ts` governs
  sharing only (its comment says collections CRUD is elsewhere + the web store
  "lacked" server sync, `:14-18`). A **real cross-device sync engine exists,
  unused**: `@oshun/customer-curation/bookmarks` (`DeviceCursor`,
  `updateCursor`/`farthestCursor`/`resumeCursor` with a defined conflict policy,
  `bookmarks.ts:33-158`). **Paywall UI already built** (`OshunPaywallCard.tsx`,
  `ShellFeatureGate.tsx`). `GET /v1/entitlements/aaa` exists
  (`entitlements.ts:101-106`).
- **The gap:** No BFF item-store; no sync routes; conflict policy unbound; web
  store never reads/writes server; no inline paywall on the save affordance.
- **External dependency:** None for sync (pure code; durable via Postgres
  snapshot). The paywall's upgrade _purchase_ depends on 4.3, but the gate + CTA
  are pure code.
- **Granular tasks:**
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(route test)** 401; sync round-trips; `conflictsResolved` surfaced;
        schema-validated. _(5 tests incl. user isolation + malformed-body 400.)_
  - [x] **(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.)_
  - [ ] **(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._
  - [x] **(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.)_
  - [x] **(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).)_
  - [x] **(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).)_
  - [x] **(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.)_
- **Acceptance criteria:** 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.
- **Verification:**
  `cd apps/oshun/bff && npx vitest run src/library/saved-items-store.test.ts` ·
  `cd libs/oshun/customer-curation && npx vitest run src/bookmarks/bookmarks.test.ts`
  ·
  `cd apps/oshun/web && npx playwright test e2e/library-entitlement-and-save.spec.ts --workers=1`
- **Effort / risk:** **M.** Low risk (conflict engine + paywall UI exist). Risk:
  offline/online merge edges + stable `deviceId`; keep localStorage as cache.

## 4.3 Payments crypto settlement **(in-repo composition BUILT 2026-06-09 — live provisioner deploy-bound)**

> **2026-06-09 — user chose to build the fail-closed composition.** BUILT +
> tested (18 tests, commit `ec6897241d`): `payments/invoice-store.ts`
> (snapshot-durable ledger + confirm/expiry), `payments/payments-composition.ts`
> (the composition root: `resolvePaymentsReceiptSigner` env-resolver
> [fail-closed null / fail-loud on > a bad key], `isPaymentsConfigured`, the
> `InvoiceTargetProvisioner` PORT, the runtime + bind),
> `payments/quote-builder.ts` (validate → `gateInvoiceCreation` disclosure →
> provision → assemble the web `PaymentsBridgeInvoiceDto` → persist), and the
> three routes (methods = REAL `listSupportedAssets`; invoices = REAL ledger;
> quote = fail-closed 503 / real 201 DTO with a bound runtime). **Architecture
> note:** 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 `InvoiceTargetProvisioner` PORT owns
> rate + amount + per-charge address (subsuming the separate
> `price-feed-fetcher`/`address-provisioner` items). DEPLOY wires the real
> provisioner (BTCPay/OpenNode/@aje) + the webhook + the receipt key
> (`OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY` + `_AUDIT_KEY_ID`). In-repo the
> quote is honestly 503 — NEVER a fabricated rate/amount/address.

- **Status today:** BFF **fails closed** — `POST /v1/payments/crypto/quote` →
  503 in **every** env (`domain-stubs.ts:1896-1914`);
  `GET /v1/payments/{methods,invoices}` → 503 in prod, dev fixture otherwise.
  The real, multi-chain, **already-integrated** stack is
  **`@oshun/payments-bridge`** (`index.ts:1-9`: real 3-source median price
  aggregator `oracle-aggregator/price-feed.ts:55-89`, Ed25519 `ReceiptSigner`,
  paywall builders `customer-surface/paywall-spec.ts:13-138`, QR,
  disclosure-gate, state-mapper). Already consumed by the Telegram bot + web
  billing; the web loader **already defines the wire contract**
  `PaymentsBridgeInvoiceDto` (`billing/crypto/invoice-loader.ts:47-104`) whose
  **BFF endpoint doesn't exist** (`activeFetcher` defaults null → "issuance
  pending"). (`@aphrodite/crypto-payments` has mocked rate-fetch + simulated EVM
  — do not wire it.)
- **The gap:** 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
  `PaymentsBridgeInvoiceDto` + real methods/invoices; build the composition
  root.
- **External dependency:** (a) live rate feed (a `PriceFeedFetcher` over
  Kraken + CoinGecko + a Uniswap v3 TWAP RPC; math done, HTTP/RPC deploy-bound
  `price-feed.ts:31-42`); (b) chain settlement (BTCPay/OpenNode — the BTC
  processor is real — or `@aje/*` RPC / a PSP for EVM/others); (c) operator
  wallet/derivation for per-charge addresses; (d) an Ed25519 receipt-signing
  key.
- **Granular tasks:**
  - [x] **(decision)** Wire `@oshun/payments-bridge`; update §7 to point at it +
        flag the crypto-payments mocks.
  - [x] **(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.
  - [ ] **(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._
  - [ ] **(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._
  - [x] **(invoice store)** `payments/invoice-store.ts`: persist issued invoices
        (id, asset, amount, fiat, target, expiry, status, signed receipt);
        snapshot-durable.
  - [x] **(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.
  - [x] **(methods/invoices)** Real `listSupportedAssets()` filtered to
        provisionable assets; invoices from the store; 503-in-prod unconfigured.
  - [ ] **(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._
  - [x] **(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.)_
  - [x] **(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).
  - [x] **(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.)_
  - [x] **(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.)_
- **Acceptance criteria:** With creds, `quote` returns a real per-charge
  target + live spread-checked rate + verifiable Ed25519 receipt as
  `PaymentsBridgeInvoiceDto`; `methods` lists only provisionable assets;
  `invoices` 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; `CryptoPaywall` renders the real
  address/QR.
- **Verification:**
  `cd apps/oshun/bff && npx vitest run src/__tests__/payments-crypto-route.test.ts src/payments/`
  ·
  `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`
- **Effort / risk:** **L.** 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.

---

---

# Part 5 — Operator / Incident / Audit / Abuse Surfaces

> **Integration hub:** funnel all events into the single `adminAuditEventsStore`
> (`apps/oshun/bff/src/admin/admin-audit-events-store.ts:283`); pass
> `{auditEventsStore}` on every new `app.register(...)` (as
> `admin-operator-inbox.ts` does at `app.ts:1005`) so 5.1 + 5.3 become
> searchable in 5.2 for free. Reuse the write-verb template
> (`admin-operator-inbox.ts` + `operator-inbox-decision-store.ts`,
> `admin-metis-byom-decision.ts`). **Keep the two incident systems separate** —
> the lilith `/operator/admin` triage inbox vs. the admin-app SRE ops system
> (`admin.ts:7389-7510`).

## 5.1 Incident write verbs (acknowledge / assign / escalate / snooze / resolve)

- **Status today:** Wave-7 shipped the selection-reactive console
  (`components/lilith/AdminInboxConsole.tsx:19-125`, `AdminInboxQueue.tsx`) + an
  **editorial** decision verb (verdict + rationale≥20,
  `admin-operator-inbox.ts:152-167`, store `operator-inbox-decision-store.ts`).
  The incident ledger `/operator/incidents` is **read-only** over a static
  fixture (`domain-stubs.ts:1639,447-474`). A full incident-lifecycle backend
  exists but for the **admin SRE app** (`admin.ts:7389-7510`:
  mitigate/blast-radius/postmortem/close — no
  resolve/escalate/assign/acknowledge). The inbox renders a hardcoded fixture
  (`lib/lilith-data/operator-depth.ts:202-260`).
- **The gap:** 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).
- **External dependency:** None — pure code (in-memory baseline acceptable;
  durable when DB set).
- **Granular tasks:**
  - [x] **(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`.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(register)**
        `app.register(registerAdminOperatorIncidentsRoutes,{auditEventsStore})`
        near `app.ts:1005` (shared store so 5.2 sees the events).
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(UI test)** rationale-gate on escalate/resolve; severity monotonicity;
        403 error surface.
  - [x] **(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).)_
  - [x] **(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`.)_
- **Acceptance criteria:** 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 `incident.*` audit event visible in 5.2; a concurrent second
  acknowledge is rejected.
- **Verification:**
  `cd apps/oshun/bff && npx vitest run src/__tests__/admin-operator-incidents-route.test.ts src/admin/operator-incident-store.test.ts`
  ·
  `cd apps/oshun/web && npx playwright test e2e/incident-triage.spec.ts --workers=1`
- **Effort / risk:** **L.** 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 `eventType` naming must match the `incident.*` vocabulary
  so the explorer filters work; don't cross-wire the admin-app SRE incident
  system.

## 5.2 Audit explorer UI **(port the existing admin-app explorer — quick win)**

- **Status today:** Web `/operator/audit` is a read-only static table over
  `/v1/audit` fixture (`app/operator/audit/page.tsx:42-129`,
  `domain-stubs.ts:1642,476-512`), no filter/search/drill/export (wave-7 added
  only the empty-branch unit test). **A complete explorer BACKEND already
  exists**: `routes/admin-audit-log.ts` (`GET /v1/admin/audit-log/ events` with
  actorIds/workspaceIds/eventTypePrefix/needle/time/limit `:62-85`,
  saved-investigation CRUD `:87-183`, share-token `:185-208`, export
  json|markdown `:210-257`; scope `admin:*|admin:studio`). **A complete explorer
  UI already exists — but only in the admin app**:
  `apps/oshun/admin/src/components/AdminAuditLogExplorerPanel.tsx` (+ proxy
  routes + test).
- **The gap:** The web operator surface has no explorer; the backend + a
  reference UI both exist. Port + surface; don't re-implement
  filter/CRUD/export.
- **External dependency:** None. Caveat: the backend needs
  `admin:*|admin:studio` scope — the web operator session must carry it or the
  proxy must attach it.
- **Granular tasks:**
  - **(web proxy)** `app/api/operator/audit-log/events/route.ts` +
    `.../investigations/[...]/route.ts` forwarding to BFF
    `/v1/admin/audit-log/*` with the caller's session (mirror the admin app's
    `admin-bff-proxy`), keeping the BFF scope gate authoritative (non-admin →
    real 403). _(No custom proxy route needed for the events read: the web app
    already proxies `/v1/admin/*` with the operator session via the shared `api`
    client + `proxy.ts` — exactly how 5.1's `AdminInboxIncidentActions` hits
    `/v1/admin/operator-incidents/*`. The explorer uses
    `api.get('/v1/admin/audit-log/events?…')` directly; the BFF scope gate
    (`admin:*`|`admin:studio`) 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.)_ _Not a task (2026-09-18): its own
    note records that no proxy route is needed, because the web app already
    proxies `/v1/admin/*` with the operator session._
  - [ ] **(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._
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
- **Acceptance criteria:** `/operator/audit` 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; `/operator/admin` links to it.
- **Verification:**
  `cd apps/oshun/web && npx vitest run src/components/lilith/OperatorAuditExplorer.test.tsx src/app/operator/audit/__tests__/page.test.tsx`
  ·
  `cd apps/oshun/web && npx playwright test e2e/tenant-audit-log-investigation.spec.ts --workers=1`
- **Effort / risk:** **M.** Most logic ports from the admin panel. Risk: the web
  operator session must carry `admin:*|admin:studio` (verify real operator
  scopes); don't regress the fixture-row ledger assertions.

## 5.3 Public-scene abuse report → operator inbox surfacing

- **Status today:** The public report endpoint works for the reporter
  (`POST /v1/living-scenes/public/:shortCode/report`,
  `routes/living-scenes.ts:1136-1155`, rate-limited; e2e green to 202). **But
  the report is NOT persisted** — `recordAbuseReport`
  (`libs/yemaya/living-scenes-runtime/src/personal-artifacts/personal-artifacts.ts:729-750`)
  is a **pure function** that builds + freezes an `AbuseReport` (with
  `routedQueue: 'lilith-living-scene-public-reports'`) and returns it; the route
  sends it in the 202 and **discards** it. No store, no list endpoint, no queue
  consumer. The operator inbox renders a hardcoded fixture
  (`operator-depth.ts:202-260`).
- **The gap:** (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.
- **External dependency:** 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.
- **Granular tasks:**
  - [x] **(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.)_
  - [x] **(store test)** record→list newest-first; `routedQueue` stamped.
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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.)_
  - [x] **(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`.)_
  - [x] **(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).)_
  - [ ] **(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    `_created`event for explorer faceting, rather than the checklist's awkward    `actioned_\*`literal) → returns the updated inbox item with its new`status`;     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: `AbuseReport`ALREADY carries`shareId`(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`AbuseReport`carries the`shareId`),
        the inbox showing the new status, and the operator-leg e2e.\_
  - [x] **(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.)_
  - [x] **(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.
- **Verification:**
  `cd apps/oshun/bff && npx vitest run src/__tests__/admin-abuse-reports-route.test.ts`
  ·
  `cd libs/yemaya/living-scenes-runtime && npx vitest run src/personal-artifacts/personal-artifacts.test.ts`
  ·
  `cd apps/oshun/web && npx playwright test e2e/public-scene-abuse-report.spec.ts --workers=1`
- **Effort / risk:** **M–L.** Risk: the inbox is 100% client fixture — union
  must _append_ live items + keep the fixture baseline or the six-INC e2e
  (`incident-triage.spec.ts:185-242`, pins `data-operator-inbox-count=6`)
  breaks; keep `recordAbuseReport` 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).

---

---

# Part 6 — PWA Edge Cases & SSR Deep-Read Coverage

## 6.1 Captive-portal `/healthz` false-online **(genuine correctness bug — fix first)**

- **Status today:** `OfflineBanner.tsx` (mounted globally, `app/layout.tsx:201`)
  does `fetch(BFF_HEALTH_URL,{cache:'no-store'})` and **treats any non-throwing
  response as "online"** — it never checks `response.ok`/`status`/content-type
  (`OfflineBanner.tsx:57-62`); only a thrown error sets offline. `/healthz`
  returns JSON `{status:'ok',service:'oshun-bff'}` (`routes/health.ts:5-11`).
- **The gap:** A captive portal returning `200 text/html` makes `fetch` resolve
  → banner reports "online" with no real connectivity. Clean seam, genuine bug.
- **Classification:** NEEDS-PRODUCT-SEAM, then TESTABLE-NOW.
- **External dependency:** None — pure code/test.
- **Granular tasks:**
  - [x] 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.
  - [x] `OfflineBanner.test.tsx`: a `200 text/html` resolves → banner shows;
        valid JSON 200 → dismisses; `navigator.onLine===false` still forces
        offline.
  - [x] 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]`.
  - [x] (Optional hardening) BFF sets an explicit `x-oshun-health: ok` sentinel
        header on `/healthz` so the client can assert origin authenticity.
- **Acceptance criteria:** `/healthz` as `200 text/html` shows the banner; valid
  JSON 200 keeps it hidden; `navigator.onLine===false` unchanged.
- **Verification:**
  `cd apps/oshun/web && npx vitest run src/components/OfflineBanner.test.tsx` ·
  `npx playwright test e2e/pwa-failure-modes.spec.ts --workers=1`
- **Effort / risk:** **S.** Risk: a stricter check could false-positive if a
  proxy strips content-type — accept `ok && json` OR the sentinel header.

## 6.2 Manifest-404 affordance

- **Status today:** `PwaInstallPrompt.tsx` drives `available` purely from
  `beforeinstallprompt`/`appinstalled` (`:58-91`); it never fetches/validates
  the manifest (declared `layout.tsx:49`, file `public/manifest.json`). Mounted
  only on `/welcome/download`
  - `HomeWorkspace`. The existing spec already documents this infeasible
    (`pwa-failure-modes.spec.ts:150-159`).
- **The gap:** No behavioral link between manifest availability and the
  affordance, so "404 → hide install" can't be tested because the product
  doesn't implement it.
- **Classification:** NEEDS-PRODUCT-SEAM + HARNESS-LIMITATION
  (`beforeinstallprompt` is browser-gated, not deterministically drivable).
- **External dependency:** None — but requires a product decision (likely
  over-engineering for the auto path since the browser already gates
  installability).
- **Granular tasks:**
  - [x] **(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.)_
  - If YES: in `PwaInstallPrompt.tsx`, add a `HEAD/GET /manifest.json` check
    that gates the **manual** trigger handler (`:113-125`) to a no-op +
    `data-pwa-install-unavailable` state on non-200 / wrong content-type. _(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.)_ _Not a task (2026-09-18): the
    conditional branch of a decision that was answered NO._
  - Add the `data-pwa-install-unavailable` attribute; add an e2e routing
    `/manifest.json`→404 and firing the manual trigger; remove the "infeasible"
    note (`:150-159`). _(NOT BUILT — see the NO decision.)_ _Not a task
    (2026-09-18): the conditional branch of a decision that was answered NO._
  - Add a `PwaInstallPrompt.test.tsx` unit test mocking fetch. _(NOT BUILT — see
    the NO decision.)_ _Not a task (2026-09-18): the conditional branch of a
    decision that was answered NO._
- **Acceptance criteria:** With `/manifest.json`→404, the manual trigger shows
  `data-pwa-install-unavailable` instead of a no-op; 200 proceeds; the auto path
  is unchanged (documented browser-gated).
- **Verification:**
  `cd apps/oshun/web && npx vitest run src/components/PwaInstallPrompt.test.tsx`
  · `npx playwright test e2e/pwa-failure-modes.spec.ts --workers=1`
- **Effort / risk:** **S.** Lowest-risk outcome may be to keep the documented
  "by-design" stance and add nothing — decide deliberately.

## 6.3 Skip-waiting-lost stuck-apply timeout

- **Status today:** `handleApplyUpdate` (`PwaBootstrap.tsx:244-265`) posts
  `OSHUN_SKIP_WAITING`, sets `applyingUpdate`, and arms a **4s** fallback timer
  that silently `window.location.reload()`s (`:256-261`); normally
  `controllerchange` reloads first (`:122-128`). `PwaUpdatePrompt` only shows a
  disabled "Refreshing…" spinner — **no manual-reload hint**
  (`pwa-failure-modes.spec.ts:160-167`). (The prompt cited line 243; the real
  timer is 256-261.)
- **The gap:** (1) the silent reload has no DOM affordance to assert in e2e; (2)
  the 4s timer logic isn't unit-covered.
- **Classification:** 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).
- **External dependency:** None — pure code/test.
- **Granular tasks:**
  - [x] **(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.)_
  - [x] **(unit)** `PwaBootstrap.test.tsx` with `vi.useFakeTimers()`: Apply → no
        controllerchange → advance 4000ms → assert `reload` called once;
        controllerchange before 4s → immediate reload
    - timer cleared (`:122-128,257-261`). (Mock `window.location.reload` via
      `Object.defineProperty`.) _(Added a `stuck-apply fallback reload timer`
      describe to the existing `__tests__/PwaBootstrap.test.tsx` (reusing its
      full mock harness): test A clicks Refresh now, advances 3999ms (no reload)
      then 1ms (exactly one reload); test B dispatches `controllerchange` →
      immediate single reload + advancing 10s does NOT reload again (timer
      cleared). Mocks `window.location.reload` via `Object.defineProperty` on
      `window`, restored in afterEach. All 7 tests green.)_
  - [x] **(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.)_
- **Acceptance criteria:** 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.
- **Verification:**
  `cd apps/oshun/web && npx vitest run src/components/PwaBootstrap.test.tsx`
- **Effort / risk:** **S** (unit) / **M** (seam + e2e). Risk: mocking
  non-configurable `window.location.reload` in jsdom.

## 6.4 Nisaba SSR deep-read unit test

- **Status today:** `/nisaba/page.tsx:13-16` is a server component:
  `getNisaba()` → `bffGet('/v1/nisaba/room')`
  (`lib/lilith-data/nisaba.ts:14-20`), falling back to `nisabaUnavailable()`
  when `!room?.passageBody`. `NisabaRoom` (`components/lilith/rooms.tsx:1114`)
  is a pure server component. In e2e, `bffGet` is server-side and `page.route`
  can't reach it → always the fallback/live BFF, never a deterministic real
  passage.
- **The gap:** The real-passage SSR render is never exercised deterministically.
- **Classification:** TESTABLE-NOW via the proven server-component unit-test
  pattern (operator/ sso + operator/audit are the templates).
- **External dependency:** None — pure test.
- **Granular tasks:**
  - [x] Create `app/nisaba/__tests__/page.test.tsx` (model on
        `operator/sso/__tests__/page.test.tsx`):
        `vi.mock('@/lib/server/bff-fetch',()=>({bffGet:vi.fn()}))`.
  - [x] Test A (ready): mock a real room payload → `render(await NisabaPage())`
        → assert the passage title + a `passageBody` paragraph + a section
        label.
  - [x] Test B (unavailable): `mockResolvedValue(null)` → assert "Today's
        passage is not available right now." + no fabricated body.
  - [x] Test C (malformed → fallback): `passageBody: undefined` → unavailable
        branch.
  - [x] Build typed fixtures from the `NisabaData` type
        (`lib/lilith-data/types.ts`).
- **Acceptance criteria:** A mocked real payload renders the real
  passage/body/sections/related; `null`/missing `passageBody` renders the honest
  unavailable copy; tests fail if real content is dropped or a body is
  fabricated.
- **Verification:**
  `cd apps/oshun/web && npx vitest run src/app/nisaba/__tests__/page.test.tsx`
- **Effort / risk:** **S.** Low (operator tests prove design-system server
  components render in jsdom).

## 6.5 Veritas SSR deep-read unit test

- **Status today:** `/veritas/page.tsx:12-17` server component:
  `getVeritasLead()` → `bffGet('/v1/veritas/briefing/home')` →
  `mapHomeBriefingToVeritasData` (`veritas-briefing.ts:168`) or
  `veritasLeadUnavailable()`. `VeritasRoom` (`rooms.tsx:568`) is a server
  component. The **mapper is unit-tested** (`veritas-briefing.test.ts`) but the
  **page-level SSR render is not**. (NB: `components/domains/VeritasSurface.tsx`
  is a `'use client'` component — NOT the SSR path; the SSR path is
  `VeritasRoom`.)
- **The gap:** Same root cause as 6.4 — `bffGet` is server-side, unreachable
  from `page.route`.
- **Classification:** TESTABLE-NOW via server-component unit test.
- **External dependency:** None — pure test.
- **Granular tasks:**
  - [x] Create `app/veritas/__tests__/page.test.tsx`;
        `vi.mock('@/lib/server/bff-fetch')`.
  - [x] Reuse the `briefing()` fixture builder from
        `veritas-briefing.test.ts:10-40`.
  - [x] Test A (ready): mock `briefing()` → assert the real headline + claim
        count + a source name.
  - [x] Test B (null): assert `veritasLeadUnavailable()` copy + zero claims.
  - [x] Test C (degraded): missing `briefing`/`grounding` → unavailable branch.
- **Acceptance criteria:** A grounded briefing renders the real headline +
  claims + sources; `null`/missing fields render the honest unavailable surface
  with no fabricated claims.
- **Verification:**
  `cd apps/oshun/web && npx vitest run src/app/veritas/__tests__/page.test.tsx`
- **Effort / risk:** **S.**

> **Architectural note (SSR e2e):** Prefer **server-component unit tests**
> (`render(await Page())`
>
> - `vi.mock('@/lib/server/bff-fetch')`) 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 `bff-fetch.ts` transport itself.

---

---

# Part 7 — Deploy-Time Infra Inputs (provisioning, not code)

> **Re-read on 2026-09-18.** This part was written for managed cloud services.
> The platform has since moved to one Hetzner box running the compose stack in
> `infra/hetzner/docker-compose.yml` (`.github/workflows/deploy-hetzner.yml`
> replaced the ECS workflow), and that stack already has `postgres`, `redis`,
> `qdrant`, `minio`, 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
> (`docker compose -f docker/docker-compose.dev.yml up -d postgres redis`, 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.

These are **provisioning/config tasks**, 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.

## 7.1 Postgres — `OSHUN_V1_DATABASE_URL` / `OSHUN_ADMIN_DATABASE_URL`

- **Consumer:** `apps/oshun/bff/src/server.ts:100-104,137-183`
  (`createAdminSnapshotStore` + durable
  admin/audit/living-scene-share/reminder/generation-job/OneRoster stores).
  Prisma URL `libs/oshun/persistence/prisma.config.ts:10`.
- **Default without it:** in-memory; warning `server.ts:211-213`; state lost on
  restart.
- **Tasks:**
  - _Withdrawn 2026-09-18: the `postgres` service of
    `infra/hetzner/docker-compose.yml` is the database; nothing is left to
    provision._ Provision Postgres 16 + `pgvector`; create the V1 DB +
    least-privilege role.
  - [ ] `OSHUN_V1_DATABASE_URL=… pnpm exec prisma migrate deploy`.
  - [ ] 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`
  - [ ] Verify boot log "admin stores: durable (postgres …), hydrated at
        startup" (`server.ts:207-209`).
  - [ ] Run the env-gated durability integration suite.
- **Acceptance:** durable-stores log line (not the in-memory warning); admin
  state survives restart; `OSHUN_V1_DATABASE_URL`-gated integration tests pass.
- **Effort / risk:** **M.** Risk: missing pgvector; migration drift
  (`prisma migrate diff` first).

## 7.2 Redis — `OSHUN_REDIS_URL` / `OSHUN_BFF_IDEMPOTENCY_REDIS_URL`

- **Consumer:** `server.ts:112-116,125-135` (`RedisBffIdempotencyStore` +
  `RedisAbuseProtectionStore`); the Veritas retraction cascade needs **both**
  DB + Redis (`:220-226`).
- **Default without it:** per-process idempotency + rate limiting (warnings
  `:198-203`); cascades off.
- **Tasks:**
  - _Withdrawn 2026-09-18: the `redis` service of the same compose file is the
    store; a managed Redis is no longer planned._ Provision managed Redis + an
    ACL user.
  - [ ] Set `OSHUN_BFF_IDEMPOTENCY_REDIS_URL` (or `OSHUN_REDIS_URL`); for
        cascades ensure DB is also set.
  - [ ] Verify boot logs "idempotency store: durable (redis)…" + "rate limiting:
        durable (redis)…" + "veritas retraction cascade: worker subscribed…"
        (`:193-196,227-228`).
  - [ ] Run the Redis-gated integration tests (idempotency cross-instance,
        cascade, crisis-frame).
- **Acceptance:** durable-idempotency + durable-rate-limit logs; a replayed
  idempotent write returns the cached result from a _second_ instance; cascade
  subscribes.
- **Effort / risk:** **M.** Risk: half-provisioning silently leaves cascades off
  (only a log signals it).

## 7.3 C2PA Ed25519 signing key — `OSHUN_LIVING_SCENES_C2PA_*`

- **Consumer:** `resolveC2paSigner()` (`routes/living-scenes.ts:1331-1356`):
  reads `OSHUN_LIVING_SCENES_C2PA_SIGNING_KEY` (64-hex) + `_KEY_ID`; non-prod
  dev fallback, prod → null → share route 503 `c2pa_signing_not_configured`
  (`:645-652`).
- **Default without it:** non-prod dev signature; prod share returns 503.
- **Tasks:**
  - [ ] Generate an Ed25519 keypair; export the 32-byte private scalar as
        64-hex.
  - [ ] 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`
  - [ ] Confirm `NODE_ENV=production` (or `OSHUN_ENV`/`RUNTIME_ENV`) so
        fail-closed is active (`:1312-1318`).
  - [ ] Verify a real share → 201 with `provenanceSigner.publicKeyHex`
        (`:699-702`); independently verify the manifest signature;
        negative-check unset-in-prod → 503.
  - [ ] Publish/rotate the public key to verifiers. _Board tag 2026-09-18:
        follows the owner creating the production key._ `blocked:upstream`
- **Acceptance:** prod share → 201 with a manifest signature that verifies
  against the published key; unset-in-prod → 503.
- **Effort / risk:** **S–M.** Risk: key must be exactly 64-hex (else silently
  falls to dev/null); ensure `NODE_ENV` is genuinely production.

## 7.4 Editorial / CMS content source (5 domain rooms)

- **Consumer:** the room endpoints behind `/veritas,/nyx,/tara,/metis,/nisaba`
  (e.g. `/nisaba` ← `bffGet('/v1/nisaba/room')`). Curated copy currently ships
  as the BFF default content set (a real baseline, not a stub).
- **The gap (deploy):** wire a real editorial/CMS backend feeding the same room
  endpoints. **Caveat:** if no content-source adapter interface exists, "feed
  the same endpoints" needs a code adapter — verify before treating as pure
  provisioning.
- **Tasks:**
  - _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._ Choose the CMS; model room schemas to match the BFF room response
    shapes.
  - _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._ Author/import the editorial corpus.
  - [ ] 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._
  - _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._ Point the endpoints at the CMS; verify each room returns CMS content
    with the unavailable fallback still firing when the CMS is unreachable.
  - _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._ Run the room SSR unit tests (6.4/6.5) against CMS-shaped fixtures.
- **Acceptance:** each room serves CMS content; pulling the CMS offline yields
  the honest unavailable surface (no crash, no fabricated copy).
- **Effort / risk:** **L.** Risk: schema mismatch; the "same endpoints" claim
  may hide a missing adapter (verify first).

## 7.5 Reminder worker interval — `OSHUN_REMINDER_WORKER_INTERVAL_MS`

- **Consumer:** `server.ts:316-367` (`startReminderDeliveryWorker` when `>0`;
  produce hook sweeps known users → Arete streak stats + Nyx event reminders;
  idempotent on `deliveredIds`).
- **Default without it:** worker disabled (log `:364-366`); reminder routes stay
  live.
- **Tasks:**
  - [ ] Choose an interval; set `OSHUN_REMINDER_WORKER_INTERVAL_MS` on **exactly
        one** replica (or behind leader election, `:313-315`).
  - [ ] 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`
  - [ ] Verify boot log "reminders: in-process delivery worker started (every
        Nms)" (`:360-362`).
  - [ ] Verify a scheduled reminder delivers on the next tick + is idempotent
        (`deliveredIds`).
- **Acceptance:** 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.
- **Effort / risk:** **S.** Risk: setting on multiple replicas without leader
  election (wasted work, never double-send).

## 7.6 Provider credentials (consolidated activation matrix)

All of the following are deploy-bound credentials; the in-repo code is
fail-closed without them.

| Capability                          | Env vars                                                                                                                         | Item        |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Image (illustration/explainer)      | `OSHUN_STABILITY_API_KEY` (`OSHUN_STABILITY_MODEL?`)                                                                             | 1.2         |
| Music                               | `OSHUN_SUNO_API_KEY` (`OSHUN_SUNO_BASE_URL?`, `OSHUN_SUNO_MODEL_VERSION?`)                                                       | 1.2         |
| Sky-briefing 3D                     | `OSHUN_MESHY_API_KEY`                                                                                                            | 1.2         |
| Narration (TTS)                     | `OSHUN_ELEVENLABS_API_KEY` + `OSHUN_ELEVENLABS_VOICE_ID`                                                                         | 1.2         |
| caption-dub / a11y ML + asset store | dubbing/ASR/vision service + `OSHUN_ASSET_STORE_BUCKET`/`_ACCESS_KEY_ID`/`_SECRET_ACCESS_KEY` (`_REGION?`,`_ENDPOINT?`)          | 1.3         |
| AI video                            | `OSHUN_VIDEO_FAL_KEY` (`OSHUN_VIDEO_MODEL?`) — wired fal LTX-Video path; or a ComfyUI/RunPod endpoint + weights for the alt path | 1.4         |
| Sophia prose                        | `OSHUN_LLM_API_BASE` + `OSHUN_LLM_API_KEY` + `OSHUN_LLM_MODEL`                                                                   | 1.5         |
| Email                               | `OSHUN_SENDGRID_API_KEY` + `OSHUN_MESSAGING_EMAIL_FROM` (`_SUBJECT?`)                                                            | 2.1/2.2/2.5 |
| SMS                                 | `OSHUN_TWILIO_ACCOUNT_SID` + `_AUTH_TOKEN` + `_FROM`                                                                             | 2.1/2.5     |
| Push FCM                            | `OSHUN_FCM_ACCESS_TOKEN` + `_PROJECT_ID`                                                                                         | 2.3a/2.5    |
| Push APNs                           | `OSHUN_APNS_TEAM_ID` + `_KEY_ID` + `_PRIVATE_KEY`(.p8) + `_BUNDLE_ID` (`_SANDBOX?`) **+ HTTP/2 fetchImpl**                       | 2.3a/2.5    |
| Push Expo                           | `OSHUN_EXPO_ACCESS_TOKEN?` (OPTIONAL — basic-mode push needs none; channel is recipient-gated)                                   | 2.3a/2.5    |
| Push Web (VAPID)                    | `OSHUN_VAPID_PUBLIC_KEY` + `_PRIVATE_KEY` + `_SUBJECT` (all three → `config.webPush`)                                            | 2.3a/2.5    |
| WhatsApp                            | `OSHUN_WHATSAPP_ACCESS_TOKEN` + `_PHONE_NUMBER_ID` + `_TEMPLATE` + `_LANGUAGE`                                                   | 2.1/2.5     |
| Slack / Discord                     | `OSHUN_SLACK_BOT_TOKEN` / `OSHUN_DISCORD_BOT_TOKEN`                                                                              | 2.5         |
| STT                                 | (provider key — Whisper/Deepgram/Google)                                                                                         | 2.3b        |
| Two-way calendar                    | Google/CalDAV/Outlook OAuth creds                                                                                                | 2.3c        |
| LMS connectors                      | `OSHUN_LMS_CONNECTORS` (per-tenant JSON)                                                                                         | 3.3         |
| Deletion attestation                | `OSHUN_DELETION_ATTESTATION_ED25519_PRIVATE_KEY`                                                                                 | 4.1         |
| Payments                            | rate-feed keys + chain RPC/BTCPay/OpenNode/PSP + wallet/xpub + `OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY` + webhook secrets    | 4.3         |

- [x] 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.example`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    `InvoiceTargetProvisioner`port, not a BFF env) — honestly no inert rows.     So every concretely-named var the live code reads is documented:`[x]`.)\_
- [ ] After wiring 2.5's health endpoint, verify each capability activates by
      hitting its health/boot signal (never echo secret values).

---

---

# Appendix A — Reusable patterns & helpers (don't reinvent)

- **Durable snapshot stores:**
  `apps/oshun/bff/src/admin/durable-stores.ts:227-240`
  (`attachAdminWorkspaceStateStoreSnapshot`); add a key at `:96`.
- **Admin auth:** `getAuthorizedAdminContext(req,reply,'<scope>')` +
  `hasAdminScope` (`routes/admin.ts:7726-7764`); scopes
  `admin:*`/`admin:studio`/`admin:workspace:*`/`admin:<domain>`. Operator helper
  `hasOperatorAdminScope` (`admin-operator-inbox.ts:33-40`).
- **Mutation route registration:** `registerAdminMutationRoute`
  (`admin.ts:7716`); write-verb template `admin-operator-inbox.ts` +
  `operator-inbox-decision-store.ts` and `admin-metis-byom-decision.ts` +
  `metis/metis-byom-decision-store.ts`.
- **Audit hub:** pass `{auditEventsStore}` on `app.register(...)`
  (`app.ts:1005`); `adminAuditEventsStore.record(...)`; read via
  `listAcrossOperators` (`admin-audit-events-store.ts`).
- **Ed25519 signed receipts (attestation/payments):**
  `libs/oshun/payments-bridge/src/receipt-signer/receipt-signer.ts:21-78`.
- **HTTPS JWKS fetch:** `tenant-console/lms-route.ts:116-122`.
- **Server-component unit test (SSR):** `render(await Page())` +
  `vi.mock('@/lib/server/bff-fetch')` (templates:
  `app/operator/{sso,audit}/__tests__/page.test.tsx`).
- **Pure-BFF e2e with dev token:**
  `dev.${base64url(JSON({sub,scopes,tid,exp}))}`; re-issue island fetches
  server-side via
  `page.route('**/v1/<area>/**', → admin bearer → route.fulfill)`
  (`incident-triage.spec.ts:57-65`).
- **Cross-link triangle (mandatory on every spec change):** spec
  `E2E · Journey:` header ↔ journey doc `## E2E coverage` ↔
  `WALKTHROUGH/journeys/coverage.md` row+grade. Convention:
  `WALKTHROUGH/00-conventions.md` § _E2E test coverage_.

# Appendix B — Effort roll-up

| Part                       | Items | Quick wins (S)             | Medium (M)       | Large (L) |
| -------------------------- | ----- | -------------------------- | ---------------- | --------- |
| 1 Generation               | 6     | 1.1, 1.5, 1.6(doc)         | 1.2              | 1.3, 1.4  |
| 2 Messaging                | 5     | 2.5(docs)                  | 2.2, 2.3a/b, 2.4 | 2.1, 2.3c |
| 3 Persona/SSO/LMS          | 3     | 3.3(list/validate)         | 3.2              | 3.1       |
| 4 Privacy/Library/Payments | 3     | —                          | 4.2              | 4.1, 4.3  |
| 5 Operator/Audit/Abuse     | 3     | —                          | 5.2              | 5.1, 5.3  |
| 6 PWA/SSR                  | 5     | 6.1, 6.4, 6.5 (+6.2/6.3 S) | 6.3(seam)        | —         |
| 7 Deploy infra             | 6     | 7.5                        | 7.1, 7.2, 7.3    | 7.4       |

**Recommended first sprint (all S, high leverage):** 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
`ReceiptSigner` 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.

---

_Generated 2026-06-08 from six parallel read-only source audits. Every
`file:line` 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._
