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)):
FIXED — nowapps/oshun/bff/src/telegram/webhook.ts:67-71—transcribeTelegramVoicereturns the literal string`voice transcript from ${fileId}`instead of transcribing.resolveSttVoiceProvider({ env: process.env }).FIXED — nowapps/oshun/telegram-bot/src/index.ts:119-123— same: returns`Telegram voice note ${fileId}`.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 —
evaluateGenerationReleaseruns on every worker output (generation/jobs-route.ts:237), maps tocomplete/needs_review/blocked, fail-closes on a missing/invalid measurement (generation/release-gate.ts:140-145, testedrelease-gate.test.ts:72-92). The canonical contract isCanonicalReleaseGateMeasurement(libs/oshun/generation-control-isis/src/release-gate-model.ts:289). But no code anywhere constructs a measurement — grep forsafetyScanScore: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 unlessreviewerAssigned);reviewerAssigned,customerFacing. OptionalreleaseAdmissions[](dispatch-guard.ts:29, anyadmitted:false→block) andreleaseKind:'analysis'escape hatch for non-media outputs only (release-gate.ts:132). - External dependency: None for the helper (pure code). The signals it consumes are external (a real safety scan, a watermarker, a C2PA signer) — see 1.2.
- Granular tasks:
- Create
apps/oshun/bff/src/generation/provider-measurement.tsexportingbuildReleaseMeasurement(signals): CanonicalReleaseGateMeasurement— maps a provider's safety-scan result, watermark-coverage ratio, C2PA-present flag, quality score, and output kind into the canonical envelope. Must never default a signal to a passing constant — an absent signal yields an output that the gate (correctly) blocks. - Unit test
provider-measurement.test.ts: feed the helper's output throughevaluateGenerationReleaseand assertcompletefor a clean image, and the correctblocked/needs_reviewfor each below-floor field (one assertion per floor — would fail if a floor regressed or a field were dropped). - Doc: correct
V1_DEPLOYMENT_REQUIREMENTS.md§8 to state that a credential activates the provider call but the measurement bridge is what releases the output to the customer.
- Create
- Acceptance criteria: A single tested helper produces a valid measurement;
feeding it through the live gate yields
completefor a clean image and the exactblocked/needs_reviewdecision 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:1to "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—adaptImageProviderGeneratereturns only{id,url}and discards the provider response (which even carriesmetadata.c2pa.signed,types.ts:524). Consequence: even a perfectly-credentialed job landsblocked/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 atlibs/yemaya/living-scenes-runtime/src/provenance/c2pa/ed25519-signer.ts:30). - Granular tasks:
- Build 1.1 first. (Done in the keystone session —
provider-measurement.ts.) - (image) Extend
image-provider-env.tsadaptImageProviderGenerateto read the fullImageGenerationResponse(c2pa.signed, dimensions, moderation verdict), callbuildReleaseMeasurement, return{id,url,releaseMeasurement}. (DONE — the adapter now ALWAYS emits areleaseMeasurement(closing the keystone gap where it discarded governance signals + every image dead-ended atgovernance_measurement_absent):provenanceManifestPresentfromresult.metadata?.c2pa?.signed,outputShapeValidfrom the url,policyComplianceClearfrom the moderation flag,rightsAndLicenseCleartrue (first-party generation), watermark/quality null (deploy-bound, below). The executor already forwards it (image-executor.ts:68). 10 image-provider-env tests fed through the REAL gate.) - (image) Implement a real
safetyScanScoresource (map Stability moderation/finishReason, or add an output safety-scan pass) — no constant. (DONE —generation/image-safety-classifier.ts: a fail-closedImageSafetyClassifier+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 tosafetyScanScore = 1 − max(violation). NO CONSTANT: unconfigured → null → gate blocks; a moderation HTTP error → throws → the adapter catches → null → blocks (never released unscanned, never a fabricated passing score — verified by 6 classifier tests + 5 gate-fed adapter tests incl. the throwing-classifier path).) - (image) Add/verify a watermark step before asserting coverage; wire
C2PA signing via the ed25519 signer for
provenanceManifestPresent. _(provenance is now READ from the provider response (metadata.c2pa.signed→ Stability=false → honestly blocks on provenance). The remaining BFF-side watermark step + C2PA SIGNING are deploy-bound: both need the generated bytes fetched + processed (sharp watermark / ed25519 C2PA manifest) + RE-STORED in the asset store (OSHUN_ASSET_STORE_\*) so the signed/watermarked bytes are what's delivered — claiming coverage/provenance without persisting the processed output would be dishonest. So the measurement honestly blocks on watermark (null→image floor 1) + provenance until those deploy-bound steps land.)_ _2026-09-18: open for an agent. "Deploy-bound" meant an asset store; the dev stack has one (MinIO on :9000,docker/docker-compose.dev.yml). The residue is the step itself: fetch the generated bytes, watermark them, sign a C2PA manifest with the ed25519 signer, store the processed bytes and deliver those, with a spec against the local store._ - (narration) Same in
narration-provider-env.ts(outputKind:'audio'). _(DONE — and this is the genuinely-VALUABLE one of the three remaining adapters: for TTS the output content IS the spoken text (ElevenLabs faithfully speaks the input), so a real text-moderation scan ofrequest.textis a faithful output-content safety scan, not a proxy. Builtgeneration/text-safety-classifier.ts— a fail-closedresolveTextSafetyClassifier(env)+ OpenAI-moderations-compatible adapter (OSHUN_TEXT_MODERATION_API_BASE/_API_KEY/_MODEL, falling back to the sharedOSHUN_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-useddeliberately NOT set — narration uses a FIXED, deploy-configured, platform-licensed voice (OSHUN*ELEVENLABS_VOICE_ID), not a per-request clone of a real person; a future per-user voice-cloning surface would set it. 7 text-classifier + 10 narration tests fed through the REAL gate (no-classifier→block-on-safety (score 0, never a constant); clean→block-on-provenance+watermark NOT safety; flagged→policy+safety; cloned-voice trigger absent; moderation-outage→fail-closed; scans the actual spoken text). Audio full release still needs the deploy-bound audio watermark + C2PA + asset-store, like image.)* - (music) Same in
music-provider-env.ts(outputKind:'audio'). (DONE — emits an honest measurement. Curated music is INSTRUMENTAL (no lyrics/vocals —translateMusicRequesthard-setsinstrumental: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:falsetoo). Provenance false (Suno no C2PA), watermark null (audio floor 0.99), quality null, rights true (first-party licence). Emitting the measurement replaces the genericgovernance_measurement_absentwith the SPECIFIC missing-signal block reasons (better deploy diagnostics) + lands the scaffolding a deploy-bound audio scanner/watermark/C2PA plugs into. 7 tests fed through the REAL gate (blocks on safety+provenance+watermark; rights clear; nothing fabricated).) - (sky-briefing) Same in
sky-briefing-provider-env.ts(outputKind:'3d-model', watermark floor 0 → N/A but the envelope is still required or it blocks). (DONE — honest measurement: no in-repo 3D-geometry safety scanner →safetyScanScore:null→blocks at the safety floor (never fabricated); provenance false (Meshy no C2PA);policyComplianceClear:false(no scan ran); watermark null but the gate treats the 3d-model watermark floor 0 as N/A (so it does NOT block on watermark — the test assertsnot.toContain('watermark')); quality null; rights true. 7 tests fed through the REAL gate (blocks on safety+provenance, NOT watermark; rights clear; nothing fabricated).) - (executors) Confirm music/narration/sky-briefing executors forward
releaseMeasurement(image already does,image-executor.ts:68); add the spread where missing. (DONE — extendedNarrationProviderGenerate,MusicProviderGenerate, andSkyBriefingProviderGeneratereturn types with the optionalreleaseMeasurement, and added the conditional spread to all three executors (createNarrationGenerationExecutor/Music/SkyBriefing), mirroringimage-executor.ts:68verbatim. The worker'sevaluateGenerationReleasestrips the envelope and evaluates it. Full generation dir green (132 tests), BFF tsc 0, eslint 0.) - (unit) Per
*-provider-env.test.ts: assert the adapter emits a measurement that clears (or correctly fails) the per-kind floors; assert fail-closed (no fabricated measurement) when the safety scan is unavailable. (DONE for ALL FOUR providers — each*-provider-env.test.tsruns the emitted measurement through the REALevaluateGenerationReleaseand asserts per-floor decisions, not truthiness. IMAGE (image-provider-env.test.ts, 10) +image-safety-classifier.test.ts(6): no-classifier→blocked-on-safety (score 0, never a constant); clean→blocked on deploy-bound provenance+watermark NOT safety; flagged→policy+safety; C2PA passthrough; outage→fail-closed. NARRATION (narration-provider-env.test.ts, 10) +text-safety-classifier.test.ts(7): same matrix over the text classifier + asserts the cloned-voice trigger is absent + the actual spoken text is scanned. MUSIC (music-provider-env.test.ts, 7): blocks on safety+provenance+watermark, rights clear, nothing fabricated. SKY-BRIEFING (sky-briefing-provider-env.test.ts, 7): blocks on safety+provenance, NOT watermark (3d floor 0 N/A), rights clear. Full generation dir green (132 tests).) - (e2e) Extend
jobs-route.test.ts: enqueue→process→status with a measurement-emitting provider →complete; with a measurement-less one →blocked. (DONE — verified 2026-06-09: the described enqueue→process→ status coverage ALREADY EXISTS injobs-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 returningpassingImageMeasurement()→ assertsstatus==='complete'(measurement-emitting → complete); (2)it('BLOCKS a produced output that carries no governance evidence (fail-closed)')injects an executor returning noreleaseMeasurement→ assertsstatus==='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 REALevaluateGenerationRelease, asserting specific statuses not truthiness. Re-rannpx vitest run src/generation/jobs-route.test.ts→ 13/13 pass in 125ms.) - (deploy) Document per-provider activation set (key + safety +
watermark 2026-09-18: open for an agent as documentation: the residue
is the section 8 table of what each provider needs before its output may
be released; the audio and 3D scanners it would name do not exist and
are their own tasks.
- C2PA) in §8. _(Image + narration moderation both documented in
.env.example:OSHUN_IMAGE_MODERATION__(image vision scan) andOSHUN*TEXT_MODERATION*_(narration spoken-text scan, falling back to the image endpoint) with the fail-closed + "releases only with moderation + watermark + C2PA" notes, and a note that music (instrumental) + sky-briefing (3D) have no in-repo output-content scanner so both honestly block until deploy-bound audio/3D scanners land. The remaining audio/3D safety+watermark services are deploy-bound (no standard endpoint to document yet).)_
- C2PA) in §8. _(Image + narration moderation both documented in
- Build 1.1 first. (Done in the keystone session —
- Acceptance criteria: With a credentialed provider AND the bridge, a
curated-card submit → process →
GET /v1/generation/jobs/:idreturnscomplete+ asset +governance.permitted:true; without a real safety scan it staysblocked(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) runscontrast-analysisin-repo (decodes asset bytes → WCAG ratio,:30-44) when the asset store is configured; ML passes (alt-text/transcript) honestly returnrequires-external-provider(:54-62); analysis output releases via thereleaseKind:'analysis'exemption. The asset-store seam exists + is fail-closed:resolveAssetStore()(assets/asset-store.ts:25, wraps@oshun/storageS3, gated onOSHUN_ASSET_STORE_*). caption-dub has no provider —createCaptionDubExecutor(null);visual-dubbinglib 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 anaccessibility-passcontrast-analysiscard; confirmcompletewith a real WCAG ratio. Add a test injecting anAssetResolverreturning known PNG bytes and assertexpect(ratio).toBeCloseTo(4.5,1)(domain-correctness). _(Domain-correctness test DONE —accessibility/ contrast-analysis.test.ts(5 tests) drives the puredominantPairContrast(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, since255 & 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 remainingOSHUN_ASSET_STORE_\*activation + the end-to-end card→completeconfirm are deploy-bound, not code.)_ _2026-09-18: open for an agent. The residue is the end-to-end confirm against the dev stack's MinIO: setOSHUN*ASSET_STORE*\*through a throwaway env script, submit the card, and assertcompletewith a real ratio._ - (caption-dub) Create
generation/caption-dub-provider-env.ts(mirrornarration-provider-env.ts): resolve a dubbing client from env (fail-closed null), inject theAssetResolverfromresolveAssetStore, emit{id,url,releaseMeasurement}(outputKind:'video'|'audio'). _(DONE 2026-06-10 —caption-dub-provider-env.tsresolves the REAL ElevenLabs Dubbing API (POST /v1/dubbingmultipart → pollGET /v1/dubbing/{id}→ download/audio/{lang}+/transcript/{lang},xi-api-keyauth) fromOSHUN_DUBBING_ELEVENLABS_API_KEY(falls back to the narrationOSHUN_ELEVENLABS_API_KEY), gated fail-closed on BOTH the key ANDOSHUN_ASSET_STORE_\*(asset-store.ts grew a read-writeAssetObjectStore:storeBytes= S3 upload + signed download URL — dubbed outputs persist atcaption-dub/{dubbingId}/{lang}.{ext}). Multi-locale: BCP-47 tags collapse to primary-subtag dubbing languages (es-MX+es→ oneesdub), each gets its own dub/store/scan, and the executor seam forwards a per-localeoutputs[]. 25 new tests (caption-dub-provider-env.test.ts) assert the exact multipart fields, poll/timeout/failed paths, stored keys/bytes, min-score aggregation, and gate outcomes through the REALevaluateGenerationRelease.)_ - (caption-dub) In
server.ts:428replacecreateCaptionDubExecutor(null)withcreateCaptionDubExecutor(resolveCaptionDubProvider()). (DONE 2026-06-10 — server.ts now wirescreateCaptionDubExecutor(resolveCaptionDubProviderGenerate()); fail-closedprovider_not_configuredunless key + asset store are both present, verified by test "feeds a fail-closed executor when unconfigured".) - (a11y ML) Add an optional ML a11y provider behind its own env;
compose so contrast stays in-repo and ML passes use the service when
configured. (DONE 2026-06-10 —
accessibility/ml-accessibility-service.ts: an OpenAI-compatible ML service behindOSHUN_A11Y_ML_API_BASE+OSHUN_A11Y_ML_API_KEY(model overridesOSHUN_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/transcriptionsmultipart (filename from sniffed audio container). Unrecognised bytes / HTTP errors / empty model output all THROW — never a fabricated alt text or transcript. Composed indeterministic-accessibility-provider.ts: contrast stays in-repo, alt-text/transcript run the ML service when configured and reportrequires-external-provider(with the error detail) when absent or failing; caption-verification stays honestly external (needs the caption track).AccessibilityPassResultgrewaltText/transcript. 12 new ML-service tests + 6 new/extended provider-composition tests, BFF suite green + tsc clean.) - (measurement) Build the media
releaseMeasurementvia 1.1; setcloned-voice-usedif voice cloning is used. (DONE 2026-06-10 — the adapter builds the media measurement via 1.1'sbuildReleaseMeasurement: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; provenancefalse+ watermarknull+ qualitynull(honestly absent, deploy-bound); andhumanReviewTriggers:['cloned-voice-used']on EVERY dub — ElevenLabs dubbing clones the source speakers' voices — so a dub releases only with an assigned reviewer. Fed through the real gate in tests: clean scan → blocked on provenance+watermark+human-review, NOT safety-scan.) - (unit) caption-dub: locale normalization
(
translateCaptionDubRequest)- measurement forwarding + fail-closed without provider. a11y: ML passes
return
requires-external-providerwithout the service. (Done — verified 2026-06-09, 9 tests green:caption-dub-executor.test.ts(4) coverstranslateCaptionDubRequestde-dup + lower-case of BCP-47 target locales + keeps the source asset, fail-closedprovider_not_configuredwith 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-urlreleaseKind:analysisexemption, 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.)
- measurement forwarding + fail-closed without provider. a11y: ML passes
return
- (e2e) Curated-card submit → enqueue → process → status for both
kinds with injected providers. (DONE 2026-06-10 —
jobs-route.test.tsgrew 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 releasescomplete; accessibility-pass runs the real deterministic provider + ML double over an encoded PNG →completeanalysis report with the REAL WCAG ratiotoBeCloseTo(19.77,1)+ AA + the ML alt text, and the stripped releaseKind envelope asserted.) - (cross-link + deploy) Link the curated-card journey; document the
dubbing/ASR/a11y env +
OSHUN_ASSET_STORE_*in §8/§9. (DONE 2026-06-10 —.env.exampledocuments the newOSHUN_DUBBING_ELEVENLABS_API_KEY/OSHUN_DUBBING_API_BASE(falls back to the narration key) +OSHUN_A11Y_ML_API_BASE/_API_KEY/_VISION_MODEL/ _TRANSCRIPTION_MODELblocks 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.)
- (a11y, lowest-hanging) Set
- Acceptance criteria: With
OSHUN_ASSET_STORE_*, acontrast-analysiscard 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/generatevariant 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—generateVideot2v +imageToVideoi2v — fail-closed null withoutOSHUN_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 (aVideoCardInputscontract); 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-generationtsconfig 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.exampledocuments 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-doorPOST /v1/isis/generateexists but is wired withnotConfiguredProviderExecutor()(server.ts:248) → 503. - The gap: No ComfyUI executor is injected (neither
/v1/isis/generatenor avideoworker 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. NoreleaseMeasurementfrom the video client. - External dependency: A running ComfyUI/RunPod endpoint + model weights +
creds; measurement signals (1.1);
OSHUN_LIVING_SCENES_C2PA_*. - Granular tasks:
- Create
generation/video-provider-env.ts(resolve the real fal LTX-Video provider from env; adapter over t2v/i2v →{id,url,releaseMeasurement}; fail-closed null). DONEd9e4d8ca9c(LTX-Video, not the provider-manager). - Create
generation/video-executor.ts(mirrorimage-executor.ts;outputKind:'video'). DONE — two executors (t2v + i2v), fail-closed, forward governance evidence verbatim. - (preferred) Add
video/video-from-imageexecutors to the worker registry (server.ts) so they flow through the wired gate. DONE. - Not built — rejected alternative: (optional) Replace
notConfiguredProviderExecutor()for/v1/isis/generate— NOT done (curated-card front-door used instead). - Not built — rejected alternative: (render-route, if chosen) living-scenes render-route variant — NOT chosen (curated-card path is the cleaner same-as-image front-door).
- Build the video
releaseMeasurement(1.1). DONE — honest fail-closed (safety/provenance/watermark all absent → blocks; never fabricated). - (unit) Inject a fake provider: unconfigured → fail-closed; configured → measurement fed through the REAL gate asserts blocked-on-safety/provenance/ watermark; t2v vs i2v routing; out-of-enum aspect/resolution defaults. DONE (8 provider-env + 7 executor tests). NB an in-repo "measurement-clear → complete" case is impossible without a safety scanner, so the tests assert the honest always-blocked path, not a fabricated pass.
- (deploy) Document the endpoint + weights + creds in §2/§8 (the
.env.exampleOSHUN_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 thevideo/video-from-imageprovider-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).)
- Create
- Acceptance criteria: With a ComfyUI/RunPod endpoint, a
videojob 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:98drops un-cited claims), resolved fail-closed byresolveSophiaAnswerSynthesizer(answer-synthesizer-env.ts:16, needsOSHUN_LLM_API_BASE+_KEY+_MODEL), wiredapp.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:20503 comment; (3) optionally add an e2e proving the LLM path- fallback.
- External dependency: An OpenAI-compatible LLM endpoint + key (any vendor/self-host).
- Granular tasks:
- Add
OSHUN_LLM_API_BASE/_API_KEY/_MODELto deploy config; add a §8 row. - Fix the stale comment at
routes/sophia.ts:20; correct §9 to "seam built + wired; activate via env." - (e2e) Inject a
fetchImpl(the synthesizer accepts one,:129) returning a canned completion; assert the abstractive answer cites only valid Nisaba ids; assert a 500/timeout falls back to extractive (no 500 surfaced, grounding intact). - (unit) Confirm
sanitize(:98) drops a claim citing an unknown id and coercesmodel-only→synthesized(extend tests if not already covered).
- Add
- Acceptance criteria: With
OSHUN_LLM_*set,POST /v1/sophia/answerreturns 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, returnsunknown_modelfor unregistered,:195); there is no in-BFF 3D-model upload/registration flow to produce themodelId+ licenseadmit()needs. - The gap: Bridging them requires inventing an upload contract that doesn't exist.
- External dependency: Not creds/infra — a missing product contract (an
upload that captures license + IP + registers into the registry + calls
admit()). - Granular tasks:
- (decision, blocking) Confirm with product whether
upload→registry→
admit()is in V1 scope. If NO → correct §9: "covered by the output gate (1.1); upload-admission deferred — no upload contract." If YES, then: (DECIDED 2026-06-09 — user confirmed upload IS in V1 scope; built below.) - Define the upload contract (artifact + license category + IP declaration
→
ThreeDModelGovernanceLicenseRegistryEntry,model-governance-store.ts:5). (The route validates a fullThreeDModelGovernanceLicenseRegistryEntryInput: modelId/displayName/licenseName + a validlicenseCategory(incl. theoperator-suppliedcategory) + the COMPLETE per-right matrix (all six rights, each status+commercialStatus from the real enums) — a partial or malformed license is rejected, never silently defaulted.) - Make the 3D license registry durable/writable (today static
DEFAULT_3D_MODEL_LICENSE_REGISTRY,:162). (ModelGovernanceStore.register()rebuilds the registry with the new entry viacreate3DModelLicenseRegistry— validates + de-dupes (additive, never overwrites a curated id, never half-writes). Snapshot-durable:wireDurableModelGovernance(dbHandle.store)(server.ts) persists the operator uploads + re-registers them at boot, so an uploaded model survives a restart; a now-invalid/colliding persisted entry is skipped defensively.) - Add
POST /v1/admin/isis/model-governance/upload(admin-scoped, mirroradmin-model-governance.ts) that registers thenadmit()s at the boundary. (Built — register → admit at the boundary (server-stamped evaluationId): 201 { registered, assessment } / 409 duplicate_model / 400 invalid_payload / 403 non-admin. 25 tests: 12 store (admit + register + durability) + 4 upload route (201 register+admit+GET-resident, 409 duplicate, 400 malformed, 403 scope). tsc + eslint clean.) - (tests) Domain-correctness: non-commercial license in a
commercial-SaaS topology →
deny/requiresReview; prohibited-IP prompt → blocked. (Done — verified 2026-06-09: newapps/oshun/bff/src/isis/model-governance-store.test.ts(6 tests) drives the liveModelGovernanceStore.admit()over known-correct @isis/model-governance-3d verdicts:flexicubes(non-commercial) in a serverless-burst commercial-saas deployment → commercial DENY →admitted: false; permissivetrellis-2+ a "Pikachu/Pokemon" prompt → IP DENY → blocked (proves the stricter-of-two); permissive + no IP → admitted;unknown_model+duplicate_evaluationrejected (never fabricates a posture); the record carries BOTH sub-verdicts. Tests the EXISTING registered-model admission — the upload→registry contract above stays product-blocked.)
- (decision, blocking) Confirm with product whether
upload→registry→
- 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-1433has noonClick. 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.
MessageRecipientalready models all addresses (delivery.ts:25-34). Device tokens persist (routes/device-tokens.ts); no email/SMS/WhatsApp recipient store exists.
- admin routes are for editorial channel publishing — different concern,
don't conflate.
- 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 intoMessageRecipientat 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 returnsmissing-configwithout creds). - Granular tasks:
- (domain lib)
libs/oshun/messaging-channels/src/channel-bindings.ts:BindableChannel='email'|'sms'|'whatsapp',ChannelBinding{userId,channel,address, status,codeHash,codeExpiresAt,verifiedAt,attempts},generateVerificationCode()(crypto 6-digit), constant-timehashCode/verifyCode, purerequestBinding/confirmBinding/unbindreducers; export from index. (Built + exported. Type namedBindableChannelBinding(notChannelBinding) to avoid a barrel collision withpreferences.ts's existing verified-opt-inChannelBinding. Crypto uses@noble/hashesSHA-256 + the Web Crypto CSPRNG (getRandomValues, rejection-sampled, no modulo bias) — NOTnode:crypto, which the lint rule forbids in this client-transpiled lib (webpack noops it). Constant-time hex compare; salt = the binding identity so a code can't be replayed across addresses; status machine pending→verified/locked/unbound with a bounded attempt budget + TTL expiry. The BFF store / route / recipient-resolution / UI are the deferred wrappers over this foundation.) - (domain tests)
channel-bindings.test.ts: code expiry, max-attempts lockout, wrong-code rejection, idempotent re-request, unbind, constant-time path. (15 domain-correctness tests: code format + crypto variety (200 draws → >50 distinct), issued-code verifies, custom TTL, wrong-code rejection, cross-address non-replay (salt), malformed-hash → false (no throw), verify clears the secret, 5-attempt lockout (then even the correct code is refused), expiry-beats-correct-code, not-pending, re-request rotates the code + resets attempts, unbind clears. All green; lint + tsc clean.) - (BFF store)
apps/oshun/bff/src/messaging-channels/channel-bindings-store.ts(mirrortelegram-bindings-store.tsMap+reset); snapshot-durable like reminders (reminders-route.ts:65-75). (Per-(user,channel) nested Map wrapping the domain reducers;request/confirm/unbind/list/get+isBindableChannelguard; 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 privatepersist()on a new singlesetBinding()write-chokepoint (covers request/confirm/unbind);wireDurableChannelBindings(store)hydrates on boot then binds the write-through sink, wired inserver.tson the SAME deploy-boundDurableSnapshotStoreas the persona-lifecycle / operator-incident / device-tokens stores. So a member's VERIFIED channels — and a PENDING verification's salted codeHash — survive a restart instead of forcing every member to re-verify after a deploy. 13 store tests: issued-code-verifies, persisted verify/wrong-attempt/lockout/expiry, per-user isolation, re-request rotation, reset, + 3 durability (a verified binding survives a restart; a pending code still verifies post-restart [codeHash persisted]; per-user isolation across re-hydrate).) - (BFF route)
routes/channel-bindings.ts:GET /v1/profile/channels,POST /v1/profile/channels/:channel/bind(→ send code ormissing-config),POST .../verify,DELETE .../:channel.preHandler:[authProtection, abuseProtection], user-scoped viarequest.authContext.userId(copyroutes/profile.ts:148-174+device-tokens.ts:71-78); register nearregisterDeviceTokenRoutes(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-configin 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/channelscollision.) - (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 sendcontent-type: application/json(Fastify 400s on empty-body parse); and a user token needs ≥1 scope (emptyscopes→ 401 atauthz.ts:266/366).) - (recipient resolution) Resolve verified bindings into
MessageRecipientin the reminder produce/deliver path (server.ts:330-344; today reminders are in-app-only,reminders/streak-reminder-producer.ts:79). (PureverifiedBindingsToRecipient(bindings)maps VERIFIED channels only ontoMessageRecipient(email→email, sms→phoneE164, whatsapp→whatsappTo; pending/locked/unbound contribute nothing — never deliver to an unverified address).produceStreakRemindersForUsersgains an optionalresolveRecipient?(userId)(default{}= in-app only), wired inserver.tstoverifiedBindingsToRecipient(channelBindingsStore.list(userId))so a verified channel rides alongside in-app delivery. 4 tests (2 helper + 2 producer); tsc/lint clean. The actual SEND still needs the per-channel transport creds (deploy-bound).) - (web data layer)
apps/oshun/web/src/profile/channel-bindings-client.ts(mirrorprofile-sync.ts+notifications/push-registration.ts). (Thin typedchannelBindingsClient(list/bind/verify/unbind) over the sharedapiclient (/v1/profile/channels…), mirroringdata-export-client.ts/consent-client.ts. Typed views are secret-free (no code hash). 4 unit tests asserting paths + bodies. TheChannelBindingsSectionUI that replacesDEMO_SERVICESis the remaining layer.) - (UI) Replace
DEMO_SERVICES+ the static section with a realChannelBindingsSection: live status, address input, send-code → code-entry → verify, unbind; keep/migratedata-profile-service*hooks. DONE — resolved the product-design flag by ADDING (not replacing). Verified against source thatDEMO_SERVICES(ProfileSettingsPanel.tsx) is connected ACCOUNTS (Google / Apple Health / Spotify / Notion OAuth integrations), a DIFFERENT feature — replacing it would destroy that surface. So builtapps/oshun/web/src/components/profile/ChannelBindingsSection.tsx('use client', consumingchannelBindingsClient) and ADDED it as a new "Notification channels"CollapsibleSectionat the END of ProfileSettingsPanel (so no positional section-index shift), with the siblingvi.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 — drivingchannelBindingsClient.{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. Thedata-profile-service*connected-accounts hooks are untouched (correct — a different surface). NB:ProfileSettingsPanel.test.tsxcarries 8 PRE-EXISTING failures on branch HEAD (verified via git stash; stale positionalsections[N]/links.lengthassertions from the earlier unconditional "Operator access" section/shortcut addition) — UNRELATED to 2.1 and not introduced here. Browser visual pass deferred (RAM-bound).) - (UI unit test) Extend
ProfileSettingsPanel.test.tsx: pending→verified, verify-error, unbind. (Done — verified 2026-06-09: the channel-binding UI states are covered by the DEDICATEDcomponents/profile/ChannelBindingsSection.test.tsx(5 tests, green): bind → dev-preview-code → verify (pending→verified); a wrong code → honest error + NOT marked verified (verify-error); unbind; mount affordances; list-load error. That's the correct home — ProfileSettingsPanel mocks the section, and the panel test carries pre-existing positional-index failures unrelated to this work, so extending it would risk green tests.) - (e2e)
e2e/profile-channel-bindings.spec.ts(pattern fromprofile-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 (initialunbound→pending+ dev code →verified+ address →unbound); (2) a WRONG code → "incorrect" error + stayspending(no fabricated verify). Drives the shippedChannelBindingsSectioninProfileSettingsPanel. This e2e caught + I fixed FOUR real bugs source-reading missed: (a) the address/code<input>s had no accessible name → axelabelcritical → addedaria-labels; (b) the row re-list()-ed after each mutation, which the PWA service worker serves stale (it caches/v1/*GETs by URL, ignoringcache-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) + addedno-storeon the route; (c) the verify route returned a 4xx for a wrong code, so the api-client threw and DISCARDED theoutcomethe 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 listprofile-channel-bindings(lifecycle + the 4 bugs). Env vars ALREADY present:.env.exampledocumentsOSHUN_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 reportsmissing-configin prod /previewin non-prod — never claims a send) → a dedicated §4 note left for when that transport dispatch is wired.)_ _2026-09-18: open for an agent. The residue is the per-channel verification-code send: wire the route to the messaging transports, prove email against Mailpit (:8025), and keep SMS and WhatsApp failing closed until vendor keys exist. This is the same defect as P1 ofdocs/audits/V1*RESIDUAL_BACKLOG_2026-06-11.md; close them together.*
- (domain lib)
- 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 populatesMessageRecipient; noDEMO_SERVICESremains; 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:
signUpsetsemailVerified:falseunlessOSHUN_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-380has 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 indocker/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 thefetchImplseam (transports.ts:73). - Granular tasks:
- (domain/token) Add a verification-token mint+verify with TTL (reuse
the recovery-code generator
customer-auth-store.ts:804-813), bound to userId+email. (NewcreateEmailVerificationToken— 32-byte hex, 24h TTL viaOSHUN_AUTH_EMAIL_VERIFICATION_TTL_MS.) - (store) Extend
CustomerAuthStateStorewithissueEmailVerification/confirmEmailVerification+ averificationTokensByTokenMap (mirrorrecoveryCodesByCode,:108). (Single-use, TTL-bound; flips emailVerified; re-request invalidates prior. Prod delivery is 'missing-config' — it never claims an 'email' send it didn't perform.) - (send) On
signUpsuccess, send the verification link viasendEmailViaSendgrid(fail-soft, returnverificationDelivery:'email'| 'missing-config'|'preview'like recovery:273). (Done — and wired on BOTH signup AND the explicitPOST /v1/auth/verify-email/requestvia one code path. Newauth/verification-email-sender.ts:resolveVerificationEmailSender(env)is fail-closed null unlessOSHUN_SENDGRID_API_KEY+OSHUN_MESSAGING_EMAIL_FROM+OSHUN_WEB_BASE_URLare all set;buildVerificationEmailContentbuilds the{base}/auth/verify-email?token=…link/subject/body; the sender POSTs viasendEmailViaSendgridbounded by an 8s timeout (a hung provider → not-delivered, never stalls signup).issueEmailVerificationis nowasync (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 averificationblock; a send failure never fails the signup.registerAuthRoutesresolves 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→emailwith an injected sender). Updated the 7 existing store/route callers toawait. tsc + eslint clean; 20/20 + 18 auth-route + 5 profile-route green.) - (routes)
POST /v1/auth/verify-email/request(auth-gated) +POST /v1/auth/verify-email/confirm(public, abuse-protected); register on both/auth/*and/v1/auth/*. - (decision) SMTP transport — add
sendEmailViaSmtpso e2e can pointOSHUN_SMTP_HOST/PORTat Mailpit and assert viahttp://localhost:8025/api/v1/messages; gate selection inbuildMessageProviderConfigFromEnv(prefer SMTP when set). OR (alt) write the round-trip e2e against an injectedfetchImplcapturing the SendGrid POST and extracting the token; document Mailpit isn't used (HTTPS). (DONE — chose the real SMTP transport (not the fetchImpl-capture alt). Newlibs/oshun/messaging-channels/src/smtp-email-transport.tssendEmailViaSmtpis 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 (resolveVerificationEmailSenderprefers SMTP viaOSHUN_SMTP_HOST, else SendGrid; fail-closed) AND the messaging router (MessageProviderConfig.email.smtp+buildMessageProviderConfigFromEnvgateOSHUN_SMTP_HOST/_PORT/_SECURE/_STARTTLS/_USERNAME/_PASSWORD, precedence over SendGrid;delivery.tsemail-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.exampledocuments the OSHUN_SMTP* block + the Mailpit round-trip note. The Playwrightemail-verify-roundtrip.spec.tsagainst a live BFF + Mailpit stays the deferred e2e box below — RAM-bound on this box — but is now de-risked: the transport + both consumers are real and in-process-verified.)_ - (unit/route tests) token mint/expiry, confirm flips
emailVerified, expired/replayed rejected, re-request invalidates prior;/mereflects verified. (9 tests: 6 store + 3 route, via a bare-Fastify registerAuthRoutes with NODE_ENV overridden off auto-verify.) - (e2e)
e2e/email-verify-roundtrip.spec.ts: signup (auto-verify off) → poll Mailpit or read captured token → open link → assert verified. (DONE 2026-06-09 — GREEN (2/2 incl. auto-axe). Uses the dev preview-token path (read the captured token), NOT Mailpit, so it needs no SMTP creds: a fresh signup (NODE_ENV=development,OSHUN_DEV_AUTO_VERIFY_EMAILunset → emailVerified false) returnsverification.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). Addedapp/auth/verify-email/page.tsx(server: reads the token from searchParams) +components/auth/VerifyEmailConfirm.tsx('use client': confirms the token on mount against the PUBLICPOST /v1/auth/verify-email/confirm— StrictMode-safeuseRefguard 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-emailto BOTHproxy.tsPUBLIC_PATHS(server 307 gate) andauth-session.tsisPublicShellPath(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; noteOSHUN_DEV_AUTO_VERIFY_EMAILmust be unset for the test. _(.env.exampleDONE —OSHUN_SENDGRID_API_KEY+OSHUN_MESSAGING_EMAIL_FROMwere already present (2.5); addedOSHUN_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_EMAILtest 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.
- (domain/token) Add a verification-token mint+verify with TTL (reuse
the recovery-code generator
- Acceptance criteria: A fresh signup (auto-verify off) creates
emailVerified:false, triggers a real email (captured by Mailpit/HTTP fake), and the link flipsemailVerified:true(via/v1/auth/me); replayed/expired tokens rejected; no creds → honestmissing-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 correctlydelivery.ts:132-165). Device-token registration live (routes/device-tokens.ts:65-205, web clientnotifications/push-registration.ts). Config gateprovider-config-env.ts:76-104. - The gap: (i) APNs needs an HTTP/2
fetchImplinjected (Node fetch is HTTP/1.1) — nothing injects it (V1_DEPLOYMENT_REQUIREMENTS.md:67); (ii) device tokens are in-memory + not read intoMessageRecipient.deviceToken; (iii) no e2e (can't drive a real device). - External dependency: FCM/APNs creds + web VAPID + an HTTP/2 fetch lib.
- Granular tasks:
- Inject an HTTP/2
fetchImpl(Nodehttp2/undiciH2) at the BFF root into the delivery path sopush-apnscan 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 adaptercreateHttp2TransportFetch(http2-transport-fetch.ts, real node:http2, 4 h2c-server tests) was built; now WIRED. CRITICAL: the delivery path uses a SINGLE sharedfetchImplacross 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. SodeliverDispatchedMessagegained a SEPARATE optionalapnsFetchImplused ONLY by thepush-apnscase (apnsFetchImpl ?? fetchImpl); threaded throughdeliverWithEnvProviders+ReminderCycleInput; the BFFrunScheduledReminderCycle(reminders/reminders-route.ts) injectscreateHttp2TransportFetch()so a scheduled Apple-device reminder actually delivers over H2 instead of failing on H1. Reachable TODAY viaPOST /v1/reminders/{schedule,run-cycle}(a client scheduleschannel:'push',pushProvider:'apns',recipient:{deviceToken}). Boot signal:server.tslogs the H2 wiring when APNs is configured; the pure tested guardapnsHttp2BootWarning(provider-config-env.ts) flags the misconfig (APNs creds set, no H2 wired). 7 new tests: 3 indelivery.test.ts(APNs uses apnsFetchImpl NOT the shared fetch; FCM stays on the shared fetch; fallback to shared when unwired) + 4 inprovider-config-env.test.ts(boot-warn warn/silent/silent +deliverWithEnvProvidersroutes APNs through apnsFetchImpl). lib tsc 0, BFF tsc 0, eslint 0, stub-scan clean. - Persist
device-tokens.tstokensByUservia the snapshot store. (DONE 2026-06-09 —device-tokens.tsis now snapshot-durable, mirroring the reminder / personalization / conversation stores: a flatDeviceTokenSnapshot(each token carries itsuserId, so it reconstructs the per-user map),captureSnapshot/restoreSnapshot, a write-throughsink, andwireDurableDeviceTokens(store)(hydrate at boot + fire-and-forget save) wired inserver.tsbeside the other durable stores (conditional ondbHandle.store; in-memory without a DB).register/unregisterpersist; hydrate uses a non-persistingputToken. 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 returnedtokenType/deviceId/platformmetadata). 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 accessorgetTokensForUserexists module-private indevice-tokens.ts.) - Read device tokens into
recipient.deviceTokenat reminder time (FCM vs APNs by storedtokenType). (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.tsexportsresolveDevicePushBinding(userId)— picks the user's most-recently-ACTIVE registered token and maps storedtokenType→transport:fcm/apns→recipient.deviceToken,expo→expoPushToken,web-push→parses the storedJSON.stringify(subscription.toJSON())intowebPushSubscription(malformed → skipped honestly, falls back to the next-newest deliverable token; none → null → reminder stays in-app only). The lib'sReminderRecipientBindinggrewpushProvider?(+ thePushRecipientBindingtype) forwarded bytoReminder, so the planner routeschannel:'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) acceptresolvePushBindingand merge it with the verified-binding recipient;server.ts's worker produce hook passesresolveDevicePushBindingto 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.tsfor 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.tsalready drives the H2 adapter (incl. apns-id passthrough) against a real h2c server, andtransports.test.ts:146-259already testssendPushViaApnsexhaustively (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 indelivery.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 inhttp2-transport-fetch.test.ts,transports.test.tsanddelivery.test.ts. - Add
e2e/push-registration.spec.ts(web push reg against a mocked/v1/device-tokens). _(DONE 2026-06-10 — and fixed the deeper product gap behind it:registerForPushNotificationshad ZERO callers (the web app never offered push enablement).NotificationPreferencesgrew 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'sresolveDevicePushBindingparses back); unregister → browser unsubscribe + DELETE. VAPID public key fromNEXT_PUBLIC_VAPID_PUBLIC_KEY(documented in.env.examplebesideOSHUN_VAPID_\*) with a**OSHUN_WEB_PUSH_VAPID_PUBLIC_KEY**runtime override seam (host page / e2e harness — no rebuild). The 4-test spec stubs ONLY the browser Push API boundary (fake SW registration + granted permission — headless chromium has no push service) and asserts the REAL wire shape both ways (endpoint + p256dh/auth keys round-trip), the 503-BFF→error honesty path, and the no-VAPID→unavailablefail-closed state. 4/4 green at 1 worker; the adjacentprofile-notification-preferences.spec.tsre-run green (5/5, no regression); web tsc clean; coverage.md row updated.)_ - Add FCM/APNs/VAPID vars to
.env.example; document the H2 requirement. _FCM (OSHUN_FCM_ACCESS_TOKEN/_PROJECT_ID) + APNs (the fourOSHUN_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, andbuildMessageProviderConfigFromEnv(provider-config-env.ts:136,143-145) DOES readOSHUN_VAPID_PUBLIC_KEY/\_PRIVATE_KEY/\_SUBJECT(all three required →config.webPush) andOSHUN_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.examplebeside 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 viaEXPO_PUBLIC_VAPID_PUBLIC_KEY; the BFF needs the full keypair to sign.)_
- Inject an HTTP/2
- Acceptance criteria: With creds + H2 fetch,
push-apnsPOSTs a signed request toapi.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-71returns`voice transcript from ${fileId}`;apps/oshun/telegram-bot/src/index.ts:119-123returns`Telegram voice note ${fileId}`. No server-side STT provider exists. - The gap: A real
VoiceProvider.transcribeTelegramVoice(libs/oshun/messaging-channels/src/telegram/bot.ts:70-72) replacing the stubs, fail-closed when unconfigured. - External dependency: An STT provider (Whisper/Deepgram/Google Speech) + key.
- Granular tasks:
- Add
libs/oshun/messaging-channels/src/stt-provider.tsimplementingVoiceProvideragainst the chosen API (injectablefetchImpl), fail-closed (stt_not_configured) without a key — never a fabricated transcript. (Placed atsrc/telegram/stt-provider.tsbesidebot.ts; real 3-step path: Telegram getFile → download → OpenAI-compatible /audio/transcriptions.) - Replace the two stub bodies (
webhook.ts:67-71,telegram-bot/src/index.ts:119-123) to call the provider, or surface "voice transcription unavailable" when unconfigured. (Both callresolveSttVoiceProvider; bot handler now replies honestly on throw/empty — also updated the e2e that pinned the old stub.) - Add the STT key to
buildMessageProviderConfigFromEnv(all-vars-present gate). (Gate implemented inresolveSttVoiceProviderinstead — STT is INBOUND transcription, not an outbound delivery channel, so it must not enter the delivery config / configured-channels list. Same fail-closed all-vars-present semantics, tested.) -
stt-provider.test.ts: real transcript extraction, error path, andmissing-config(assert it NEVER returns a fake transcript — stub-elimination check). - Add the STT key to
.env.example.
- Add
- 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:CalendarTransportwith upsert/delete:146-152,exportEventthrows when no transport:402-414, providers google/apple/outlook:13). Mobile composes it (apps/oshun/mobile/src/calendar/mobileCalendarSync.ts). One-way.icsexport exists for Nyx. No provider transport implementsCalendarTransport, and the framework is not wired into any BFF route. - The gap: A provider transport + a BFF route/store; the domain is done.
- External dependency: Google Calendar API / CalDAV / Microsoft Graph creds + OAuth.
- Granular tasks:
- Implement
CalendarTransportfor ≥1 provider (Google or CalDAV): fetchEvents, upsertEvent, deleteEvent (injectable fetchImpl). (Done —libs/shared/inbound-integrations/src/calendar-google-transport.tscreateGoogleCalendarTransport({resolveAccessToken, fetchImpl?})implements all three. The framework'sCalendarDateTime/CalendarAttendee/CalendarEventStatusare modeled on the Google Calendar API v3 event shape, somapGoogleEventToProviderEventis 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/throwingresolveAccessTokenrejects BEFORE any network call (never proceeds tokenless, never fabricates events); a non-ok HTTP throws. The OAuth2 token exchange/refresh behindresolveAccessToken+ 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 realcreateCalendarConnectorFrameworkreconciliation engine +MemoryCalendarSyncStoredriving 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 —createGoogleOAuthAccessTokenResolverexchanges a stored refresh token for an access token at Google's token endpoint (grant_type=refresh_token), composing directly into the transport'sresolveAccessToken. Fail-LOUD: an absent refresh token (never exchanges), a non-2xx exchange, or a response with noaccess_tokenall 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 BFFroutes/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 aCalendarSyncStore(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.tscreateCalendarRuntimeFromEnvreadsOSHUN_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'srefreshTokenRef→ env) +MemoryCalendarSyncStore. google-only — the only transport implemented, so a non-google connector is surfaced asunsupported_provider, not dropped.routes/calendar-sync.ts:GET /v1/admin/calendar/connectors(secret-free summary + invalid w/issues, norefreshTokenRefleaked) +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/exportwriteback route is now done too —POST /v1/admin/calendar/connectors/:id/exportrunsframework.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 withaccess_type=offline+prompt=consentso 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 CSRFstatetokens 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_URIdocumented. The per-user connection store is now SNAPSHOT-DURABLE 2026-06-09 —CalendarOAuthConnectionsSnapshot(established connections ONLY; the ephemeral single-use/TTL CSRFstatetokens are deliberately NOT persisted) +bindSnapshotSink/restoreSnapshot+persist()on saveConnection/removeConnection;wireDurableCalendarOAuthConnections(store)wired inserver.tson the SAME deploy-boundDurableSnapshotStoreas 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-levelMemoryCalendarSyncStoresync-state durability (deploy-bound).) 2026-09-18: open for an agent. The residue the note names is one thing: make the library'sMemoryCalendarSyncStoresync state durable (Postgres, per the repository's persistence policy), with a restart test. - Map domain items (Tara rituals, Arete reminders, Nyx events,
mobileCalendarSync.ts:13-21) →CalendarEvent. (DONE 2026-06-10 — BFFcalendar/domain-calendar-events.tsmaps 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 explicitchannelsselection — only 'external-calendar'-eligible reminders export); arete_reminder ← the REAL at-risk daily streak (sameisStreakAtRisksignal 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).domainCalendarItemToCalendarEventmirrors the mobilebuildCalendarExportEventconventions exactly (stableoshun-<kind>-<slug>externalIds + theraw.oshunmetadata 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) andPOST /v1/calendar/export(writes it to the caller's OWN connected Google calendar via a per-user transport built on their stored refresh token; 503 unconfigured / 409 no connection / per-item honest failure outcomes), registered in app.ts. 14 new tests (7 mapper + 7 route incl. per-user transport identity + cross-user isolation + fail-soft domain outage); tsc + stub-scan clean.) - Transport-double tests: import normalization
(
normalizeCalendarEvent:657), upsert/delete, fail-loud when transport missing (:402-414). (DONE 2026-06-10 — import normalization + upsert/delete were ALREADY covered bycalendar.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) →exportEventrejectscalendar_export_unavailableanddeleteExportedEventrejectscalendar_delete_unavailable(never a silent no-op write-back). Lib spec 13/13 green.) -
e2e/calendar-two-way.spec.tsagainst a mocked transport: connect → import → export → assert upsert called. (DONE 2026-06-10 — built as the BFF full-chain flow testroutes/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 → runappliedwith creates:1 from the double's provider event; POST /v1/calendar/export → the caller's REAL seeded session upserts through THEIR connection —upserts[0].event.externalId === 'oshun-tara_ritual-session-sunrise-sit'asserted. Full calendar suite 44/44 green, tsc clean.) - (web UI — gap spotted 2026-06-10) The per-user calendar connect
surface does not exist in the web app: nothing calls
POST /v1/calendar/connect, and the BFF callback redirects to/account/integrations?calendar=connectedwhich has NO page (404). Build the integrations surface (connect/disconnect Google calendar via the real per-user routes, list connections, triggerPOST /v1/calendar/export, handle the?calendar=connectedreturn) + a Playwright spec; then point the fixture-drivenConsumerShellCalendarSyncPanel(/activity) atGET /v1/calendar/eventsinstead 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 → realPOST /v1/calendar/connect→ browser sent to the consent URL; the BFF callback redirect retargeted from the 404/account/integrationsto/profile?path=calendar&calendar=connected(connect-route test updated); connections listed secret-free with disconnect; the REAL exportable schedule previewed fromGET /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); honestunavailablecopy on 503. 4-test Playwright specprofile-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 everysections[N]with a title-basedsectionByTitlehelper + the shortcut-count assert with an explicit id-list — 62/62 green (was 54/62). REMAINING (second half, tracked below): repoint the fixture-drivenConsumerShellCalendarSyncPanelon /activity at the realGET /v1/calendar/events.) - (web UI — /activity panel) Point the fixture-driven
ConsumerShellCalendarSyncPanel(/activity, hardcodedREVIEW_PROVIDER_EVENTS+ fixed 2026-05-07 clock + shell-core fixture items) at the real per-user data: items fromGET /v1/calendar/events, connection state fromGET /v1/calendar/connections, and the sync/export action throughPOST /v1/calendar/export; updatecalendar-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 honestlydisabled— "Not available yet", never claimed connected), and "Sync now" performs a REAL pull through the NEWGET /v1/calendar/provider-events(per-user transportfetchEventsover 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.tsmappers incl.raw.oshunround-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.tsrewritten (3 e2e: live sync review, unconnected guidance, mobile) — all green; ActivityDashboard suite 83/83; web tsc clean.) - Add OAuth vars to
.env.example; document in a new §. (Done — a "Two-way calendar sync (Google)" block documentsOSHUN_CALENDAR_CONNECTORS(the per-tenant descriptor JSON, mirroringOSHUN_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.)
- Implement
- Acceptance criteria: With a configured provider, connect → imported events
appear → a local change writes back; unconfigured export fails loud; the
.icsNyx 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 — projectslms.assignment.{upserted,cleared}events the LMS publishes),reminders/assignment-reminder-producer.ts(feeds the lib'sproduceAssignmentReminders). Wired into the BFF reminder worker'sproducehook with the same verified-binding recipients as the streak producer; the stableassignment:courseId:assignmentId:userIdsessionId 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, absolutestartsAtUnixSeconds),reminders/session-event-consumer.ts(the real WRITER — projectssession.{upserted,cancelled}on the shared Redis bus, keyPrefixoshun:session),reminders/session-reminder-producer.ts(feeds the lib'sproduceV3SessionReminders). Wired into the BFF reminder worker'sproducehook (return [...streaks, ...nyx, ...assignments, ...sessions]) with the same verified-binding recipients; the session-nativesessionIddedups; 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 mapperproduceContentDropRemindersnow 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, absolutepublishAtUnixSeconds),reminders/content-drop-event-consumer.ts(the real WRITER — projectscontent.drop.{scheduled,cancelled}on the shared Redis bus, keyPrefixoshun:content),reminders/content-drop-reminder-producer.ts(feeds the lib'sproduceContentDropReminders→ "goes live", 48h window). Wired into the worker producehook (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 ONEcontent.drop.scheduledper recipient, exactly as the LMS publishes onelms.assignment.upsertedper (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-91wiredserver.ts:330-344; Arete all-usersproduceStreakLossRemindersoverlistHabitsAtRiskOfStreakLoss(apps/arete/api/src/repositories/habits.ts:148,545-573), adminPOST /v1/reminders/streak-due). The producer mappers for the other two kinds already exist:produceAssignmentReminders(messaging-channels/src/reminder-producers.ts:124-135) andproduceV3SessionReminders(:76-87) — pure mappers, unfed. -
The gap: The domain bulk-enumeration queries that feed them — no LMS
listAssignmentsDueBetween(window)and no sessionlistUpcomingSessions(window)analogous tolistHabitsAtRiskOfStreakLoss. (Documented atstreak-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 bylms.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 — mirrorhabits.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.tsmapping rowsUserRepository(recipient/tz) →produceAssignmentReminders.
- Not built — rejected alternative (2026-06-10 note above): Admin
POST /v1/reminders/assignments-due(copyapps/arete/api/src/routes/reminders.ts:50-106). - Not built — rejected alternative (2026-06-10 note above): Sweep + route tests.
- Not built — rejected alternative (2026-06-10 note above): Add
- Upcoming-session:
- Not built — rejected alternative (2026-06-10 note above): Add
listUpcomingSessions(fromIso,toIso)to the owning service (Nyx/Metis), returningUpcomingV3Session-shaped rows for opted-in users. - Not built — rejected alternative (2026-06-10 note above): Production
sweep →
produceV3SessionReminders; recipient viaUserRepository. - 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.
- Not built — rejected alternative (2026-06-10 note above): Add
- Shared:
- Not built — rejected alternative (2026-06-10 note above): Point a daily
cron (or the BFF worker
producehook) at the two sweeps →runReminderCycle→ §4 transports. - Not built — rejected alternative (2026-06-10 note above): Verify dedup
via stable
sessionIdkeys (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-87becomes wired).
- Not built — rejected alternative (2026-06-10 note above): Point a daily
cron (or the BFF worker
- Due-assignment:
-
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.exampledocuments 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:
- Add a "Messaging / Notifications" block to
.env.exampleenumerating every var above (+ optionals, the APNs.p8multi-line note,OSHUN_REMINDER_WORKER_INTERVAL_MS). - Add
GET /v1/admin/messaging/configured-channels(admin-gated, patternadmin-messaging-channels.ts:97-112) returningconfiguredChannelsFromEnv()— channel names only, never secret values. - Health-endpoint test: lists exactly the configured channels, leaks no secret.
- Inject the APNs H2
fetchImpl(shared with 2.3a); boot-warn on H1. DONE — per-channelapnsFetchImplwired through the delivery path; the BFF reminder worker injectscreateHttp2TransportFetch(); boot-readiness guardapnsHttp2BootWarning+ a confirmingserver.tsboot 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). - Slack/Discord: if user-facing, extend 2.1 to capture
slackChannel/discordChannelId; else document as ops-only. - 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).
- Add a "Messaging / Notifications" block to
- Acceptance criteria:
.env.exampledocuments 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). ButtransitionPersonaLifecyclehas ZERO non-test callers — no store, no audit log; the admin store uses an unrelated 3-valueAdminPersonaStatus(apps/oshun/bff/src/admin/state.ts:2023) andpublishPersonahard-sets'published'(:16667) without the validator. No oshun consumer session-picker (the onlyPersonaSelector.tsxis 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_URLset, like all admin stores). - Granular tasks:
- (store)
apps/oshun/bff/src/admin/persona-lifecycle-store.ts:Map<personaId,{status,auditLog[]}>,getLifecycle,listLegalEvents(→listEventsFromStatus),applyTransition(request)calling the validator, advancing status + appendingauditEventonly on ok. (In-memory module singleton mirroringoperator-incident-store.ts; durable snapshot deploy-bound like the sibling new admin stores. Adds optimistic concurrency: a caller-supplied stalecurrentStatus→ aconflictoutcome, never a lost write. Imported via the@oshun/persona-registry/lifecyclesubpath to dodge the barrel'sPersonaLifecycleEvent as PersonaLifecycleAuditEventalias.) - (durability) Add a
persona_lifecyclesnapshot 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 gainedbindSnapshotSink/restoreSnapshot+ a privatepersist()that write-throughs on everyapplyTransitionANDseedPersona;wireDurablePersonaLifecycle(store)hydrates on boot (sink.load()→restoreSnapshotOVERLAYS persisted state onto the seeded catalog so a newly-added catalog persona keeps its seed) then binds the write-through sink. Wired inserver.tsright afterwireDurableModelGovernance, backed by the SAME deploy-boundDurableSnapshotStore(PostgresadminStoreSnapshotin 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 legacypublishPersonapath so'published'↔ canonicalreleased(canonicalizePersonaApprovalStatus,index.ts:121) — single source of truth. (seedPersonainitialises new personas todrafted; the store seeds from the same governance catalog asadmin/state.ts(persona-{zen,stoic,sufi}-guide), mapping the coarse 3-value status onto the canonical lifecycle status. ThepublishPersona↔released reconciliation is deferred — the lifecycle store is the new canonical 8-state authority, kept separate from the coarseAdminPersonaStatusto 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 legacypublishPersonapath write the canonical lifecycle status, sopublishedandreleasedcannot disagree, with a test that drives both paths. - (route)
transitionPersonaLifecycleHandlerinroutes/admin.ts(nearpublishPersonaHandler: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 viaregisterAdminMutationRoute, GET viaapp.getwith[abuseProtection,authProtection]), scopegetAuthorizedAdminContext(...,'persona'). (Built as a separate fileroutes/admin-personas-lifecycle.tsmirroring the freshestadmin-operator-incidents.tstemplate rather than editing the giantadmin.ts; same effect. GET list + GET:id+ POST transition, dual-prefix/admin+/v1/admin,admin:workspace:personascope. The server stampsemittedAtUnixSecondsso a client cannot backdate emission below a signoff. A POST emits apersona.lifecycle.<event>audit event (registered with{auditEventsStore}so 5.2 sees it). Confirmed no route collision; the fullcreateApp()boots with it registered.) - (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 inapp.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), andDELETE /v1/personas/active(clear) — I used DELETE-clear rather than a redundantPOST /activesince/selectIS 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.tsresolves thePersonaBrowseMemberContextSERVER-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), sohasVoiceAvatarOptInis resolved from the REAL consent store —customerConsentStateStore.getSnapshot(userId)→ a grantedvoice|avatarflow — 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.tsis 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 inserver.tsbesidewireDurablePersonaLifecycleon the SAME deploy-boundDurableSnapshotStore, 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/bffPostfor/v1/personas,/select,/active. (Client half DONE —apps/oshun/web/src/personas/personasClient.ts, a thin auth-bearing wrapper over the sharedapiclient (browse(surface?,locale?)→ query-serialized GET,select(request)→ POST body,getActive()→ GET,clearActive()→ DELETE), mirroringsavedItemsClient/channel-bindings-clientwith typed DTOs for the browse/select/active shapes. 5 unit tests (path + body + query serialization). tsc + eslint clean. The server-sidebffGet('/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. - (UI consumer)
apps/oshun/web/src/app/personas/page.tsx: browse cards grouped by family (PersonaBrowseGroupByFamily:121) + disclosure/eligibility; a client island posts/selectand reflects active selection (reuseLWebShell/LMasthead). (DONE —app/personas/page.tsx(thin server shell:LWebShell+LCustomerNav+LMasthead) hostingcomponents/personas/PersonaPicker.tsx('use client'). The picker browses/v1/personas(BFF resolves tier/consent/surface from the session — no client tier claim), renders thebyFamilygroups with per-card eligibility disclosure: eligible → "Choose this guide"; blocked → itseligibilityBlockers(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).) - (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/personasserver page +PersonaLifecycleConsoleclient component (selection-reactive, mirroringAdminInboxConsole+AdminInboxIncidentActions) rather than retrofitting the dossier editor — same effect, cleaner host. Reads the live lifecycle (status + legal events + audit history) fromGET /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).) - (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) returnsok:false+ no mutation — enumerate all 13 events × from-states fromlifecycle.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.) - (unit route)
admin-persona-lifecycle-route.test.ts(mirroradmin-persona-governance-route.test.ts): 401/403/404/409-with-errors/200. (10 tests: auth+scope gate, list, GET view withlegalEvents, 400 malformed, 409lifecycle_transition_invalidwith errors, 409lifecycle_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.) - (unit consumer)
GET /v1/personasreturns only eligible/disclosed cards;POST /selectreturns eachPersonaSelectionReasonfailure + 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.) - (e2e) Extend
persona-governance.spec.ts(or newpersona-lifecycle-bff.spec.ts) for a fulldrafted→…→releasedhappy path + an illegal 409; newpersona-session-picker.spec.tsfor the picker round-trip. (DONE 2026-06-10 — both built as pure-API specs over the REAL BFF (Playwrightrequest, no page.route):persona-lifecycle-bff.spec.tsdrives 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-registryis not web-resolvable from e2e).persona-session-picker.spec.tsdoes 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.) - (cross-link) Update
ADMIN_WALKTHROUGH/journeys/persona-release-cycle.md- matrix; add a consumer journey doc +
coverage.mdrow; 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.)
- matrix; add a consumer journey doc +
- (store)
- 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/personasreturns eligible cards;/selectreturns 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
AdminPersonaStatuswith the 8-state model (single source of truth); confirmLAUNCH_PERSONA_CONFIGSis populated so the picker isn't empty.
3.2 Tenant-admin SSO editor#
- Status today: Read path is a non-prod fixture —
/v1/ssoviaguardedFixtureRoute(domain-stubs.ts:1645, fixture:514-544, blocked in prod).OperatorSsoPageis 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-consoleSsoConnection/SsoClaimMapping/processSsoLogin(identity/sso.ts:9-125) and the full@yemaya/organizationsSSOService(~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/IOidcTokenExchangerimpl (sso-service.ts:196,223), a live IdP metadata/JWKS, network egress for test-connection. - Granular tasks:
- (decision) Use the oshun-native
@oshun/tenant-consoleSsoConnectionmodel + host the editor inapps/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.) - (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. requiredemail, positive lifetimes); durable (sso-connectionssnapshot key). (SNAPSHOT-DURABLE 2026-06-09:SsoConnectionSnapshot(every connection flattened) +bindSnapshotSink/restoreSnapshot+ a privatepersist()on every mutation (create/update/remove);wireDurableSsoConnections(store)hydrates on boot then binds the write-through sink, wired inserver.tson the SAME deploy-boundDurableSnapshotStoreas 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.) - (routes)
apps/oshun/bff/src/tenant-console/sso-route.ts(mirroradmin-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. - (test-connection)
POST /v1/admin/sso/:id/test: OIDC → fetch discovery + JWKS over HTTPS (reuselms-route.ts:116-122); SAML →parseSamlMetadata(sso-service.ts:1763) +inspectX509Pem(:1799). Fail-closed, structured result — never fake success. (Builttenant-console/sso-connection-test.ts— a pure, injectable-fetchtestSsoConnection(connection)returning a structured{ok, protocol, checks[], testedAt}verdict, plus thePOST /v1/admin/sso/:id/testroute (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, readjwks_uri, GET the JWKS, require ≥1 key — any throw/non-200/missing-key → a failed check, never a green light. SAML deviates fromparseSamlMetadata/inspectX509Pemby design: those parse IdP metadata XML, but the tenant-consoleSsoConnectionmodel 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/organizationsmodel (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-clocktestedAt) — every failure path assertsok:falseso a thrown fetch can NEVER yield success — + 4 route tests (401/403/404/200-ok-true/200-ok-false fail-closed). tsc + eslint clean.) - (wire) Register in
server.tsnear LMS/tenant-console (:282-294). (Registered inapp.tswith the other admin routes — createApp-testable; same effect.) - (retire fixture in prod) Replace
/v1/sso's fixture body (domain-stubs.ts:1645) with a read from the store (map → the operator card shapeoperator/sso/page.tsx:28-36). (Done —/v1/ssonow reads the realssoConnectionStore(the same singleton the/v1/admin/ssoCRUD routes write to) and projects eachSsoConnectionthroughmapSsoConnectionToOperatorCardonto the non-secret operator card shape; theSSO_FIXTUREconstant 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/ssocontract — there's an explicit e2e tripwire (tenant-sso-config.spec.ts) doing an UNAUTHENTICATEDGET /v1/ssoasserting 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=activeiff the connection permits idp- OR sp-initiated login elsedisabled;idpMetadataUrl= the RFC-8414 discovery doc for OIDC / the entity id for SAML;protocolsaml2→saml;tenantLabel= tenantId (model has no label);lastSyncIso= null (not tracked). Tests reconciled: removed/v1/ssofrom the prod-503it.each, kept the envelope-keysit.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.) - (web data layer)
bffPost/bffPatch/bffDeletefor the SSO endpoints. (Done —apps/oshun/web/src/operator/ssoClient.ts, a thin auth-bearing wrapper over the sharedapiclient for the full CRUD + test-connection:list(tenantId?)/get/create/update(PATCH)/remove(DELETE)/test(POST/:id/test), with typed DTOs mirroringSsoConnection+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. TheOperatorSsoEditorUI that consumes it needs the in-browser visual pass (CLAUDE.md) — the deferred piece.) - (UI) Extend
app/operator/sso/page.tsxwith "New connection" + per-card edit/delete viaOperatorSsoEditor.tsx: protocol, idpEntityId, endpoints, JWKS URL, signing cert/thumbprint, idp/sp-initiated toggles, JIT toggle, session/refresh lifetimes, a claim-mapping table (externalClaim →internalAttributeunionidentity/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 usesssoClient→/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 → theinternalAttributeunion + required + transform, add/remove), idp/sp-initiated + JIT toggles, session/refresh lifetimes. Create / Edit (populate→update) / Delete / Test (renders the real per-check verdict) viassoClient. Client pre-flight mirrors the validator (a required→email mapping is mandatory, ≥1 endpoint, positive lifetimes) but the BFF stays authoritative — a 400 surfaces itsissues[], 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).) - (unit store/route/claim) create/update/delete + validation rejects;
401/403/400/404/201/200 + structured test-connection;
processSsoLoginconsumes a saved required-claim mapping (right attribute ormissing-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.) - (e2e) Extend
tenant-sso-config.spec.tsbeyond 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 deletedSSO_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 (axeselect-namecritical): theOperatorSsoEditorendpoint-use / claim-internal / claim-transform<select>s had no accessible name — added aria-labels (commitf0073d8d8a). 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 spectenant-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.) - (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 byprocessSsoLoginat 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).)
- (decision) Use the oshun-native
- 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 throughprocessSsoLogin; 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-129overOSHUN_LMS_CONNECTORS, validates each vialms.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_CONNECTORSvalues + the platform's live JWKS) are tenant-provided config. - Granular tasks:
- Retain per-connector validation results in
createTenantLmsRuntimeFromEnv(the catch at:108-110currently discards them). (Now structuredinvalidConnectors[]; malformed JSON surfaced too.) - Add
listConnectors(tenantId?)toLmsConnectorFramework(lms.ts:346-382). (Already present atlms.ts:397— the GET route consumes it.) - (GET list)
GET /v1/admin/lms/connectors→{connectors[], invalid:[{id, issues}]}, admin-gated. (Highest-value, lowest-risk addition.) (Summary omits secrets.) - (POST validate)
POST /v1/admin/lms/connectors/validate→ runcreateLmsConnectorDescriptor+validateLmsConnectorDescriptor, return theConnectorValidationResult(no persistence). - (decision-gated CRUD) Only if redeploy-free changes are needed: a
durable
lms-connector-store.ts(tenant_lms_connectorssnapshot) merged with env at bootPOST/PATCH/DELETEthat validate-then-register; document precedence. Recommend env + list/validate first. (Followed the recommendation — env + list/validate only; no CRUD store built.)
- (web)
bffGet/bffPosthelpers;app/operator/lms/page.tsxlisting connectors + a prominent invalid-connector panel + a dry-run validate form. (app/operator/lms/page.tsxbuilt — a read-only server component over the existingbffGet: 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 toPOST /v1/admin/lms/connectors/validateand renders the{valid, issues[]}verdict; malformed-JSON caught client-side (no POST), honest 400/403 errors. 4 unit tests.) - (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.tsdrives the REAL routes over HTTP via the Playwrightrequestfixture (pattern fromadmin-auth-policy-bff.spec.ts,dev.<base64url>bearer): GET/v1/admin/lms/connectors→ 401 no-bearer / 403admin_scope_missing(customer scope) / 200 admin → the{generatedAt, connectors[], invalid[]}envelope (empty, noOSHUN_LMS_CONNECTORSseeded); POST…/validate→ 401/403, 400invalid_request(missing ids), and a well-formed-but-no-protocol descriptor → 200valid:falsewith a real issue (lms_protocol_required/descriptor_malformed— the validator REJECTS it, never fabricatesvalid: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 seededOSHUN_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 seededOSHUN_LMS_CONNECTORSroster and an admin-scoped operator session. - (cross-link + deploy) Add an LMS-connectors journey + operator
walkthrough
- coverage; expand §3 with a copy-pasteable per-provider
OSHUN_LMS_CONNECTORSJSON example. (DONE 2026-06-10 — NEWWALKTHROUGH/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-viewWALKTHROUGH/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 verifiedvalid:true, issues:[]against the REALvalidateLmsConnectorDescriptorvia tsx before committing (a copy-pasteable example that doesn't validate would be a doc stub).)
- coverage; expand §3 with a copy-pasteable per-provider
- Retain per-connector validation results in
- Acceptance criteria:
GET .../connectorsreturns live connectors + skipped ones with issues;POST .../validatereturns 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.tsimports 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-closedDsarDeletionReceipt), reachable only via operatorPOST /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, likeOSHUN_LIVING_SCENES_C2PA_*). A service with no programmatic delete is the irreducible blocker for that service. - Granular tasks:
- (contract) Extend
CustomerDataDeletionTombstoneSchema(:128-134) withattestation:{service,status:'erased'|'not_found'|'failed',rowsRemoved, receiptSignatureHex,auditKeyId,attestedAt}|null; add aDeletionServiceIdenum; addservicetoCustomerDataDeletionCascadeJobSchema(:120-126). (Done —DeletionServiceIdSchema(the 6 ids, matching@oshun/privacyDELETION_SERVICE_IDS1:1),DeletionAttestationStatusSchema('erased'|'not_found'|'failed'), andCustomerDataDeletionTombstoneAttestationSchemaadded + exported via the existing barrel. The attestation schema is field-for-field the engine'sDeletionAttestation— incl.detail: string|nullandattestedAtUnixSeconds:number(NOT the checklist'sattestedAttimestamp), 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 BFFstate.tsbuildTombstones/buildDerivedArtifactJobscompile 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-domaindeleteForSubjectAPIs.) - (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 (reusereceipt-signer), fail-closed (completeonly if nofailed, mirrordsar-deletion-cascade.ts:149-158). Built —DELETION_SERVICE_IDS(the 6),DeletionServiceEraser,executeDeletionFanout(per-service erase → sign → fail-closedcompleteiff nonefailed; a throwing eraser is captured asfailed, never dropped), plus a deletion-specificcreateDeletionAttestationSigner/canonicaliseDeletionAttestation/verifyDeletionAttestationover@noble/curves/ed25519(NOT the paymentsReceiptSigner— see below). Exported from the lib index; tsc + lint clean. The BFF erasers + thestate.tsrewire (replacing the fabricatedmarkCompleted) remain — the L wiring blocked on per-domaindeleteForSubjectAPIs. Correction (verified against source): "reusereceipt-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 primitiveReceiptSignerwraps internally) with its own canonical payload — NOT the paymentsReceiptSigner. This + the orphaned wiring (the BFFstate.tsstill fabricates; real wiring is blocked on per-domaindeleteForSubjectAPIs) makes this an L build, not a quick reuse.) - (domain test)
deletion-fanout.test.ts: 6 mock erasers — all invoked with correct kind; each tombstone signature verifies; one failure →complete:false+ that tombstonefailed;not_foundcounts 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.) - (BFF erasers)
apps/oshun/bff/src/data-deletion/service-erasers.tsmapping each kind to a real delete. (DONE — 2026-06-09, user-directed EVENT-DRIVEN re-architecture.createSubjectRowEraser(serviceId, deleteRows)over an injectedSubjectRowDeleterport (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.createNoSubjectDataEraserfor voice_profile/avatar_pack — confirmed by product to hold NO per-user rows (shared catalogs; the user's choice is personalization data), so honestnot_found, NOT a fabricated erase.buildBffDeletionErasersomits an unwired real category so the orchestrator reports itmissing→ incomplete (fail-closed, never a silent pass). 10 tests incl. a realexecuteDeletionFanoutpass. PLUS the event-driven core the sync-fanout design didn't have:deletion-event-orchestrator.ts(publishes via aDeletionEventTransportport, 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.) - (BFF wire) In
state.ts, inject the fanout runner + signer; replace syntheticmarkCompleted/applyTransitions. (DONE — THE STUB IS DEAD (85c6a17104). The user chose EVENT-DRIVEN, so this isrunDeletion(userId, deletionId, runner): runs the real orchestrator→bus→consumers fan-out, records each verified attestation onto its tombstone, setscompletedONLY when every requested service produced a verified non-failed receipt, elsefailed. Removed the applyTransitions lazy auto-complete (it fabricatedcompletedwith zero erasure) + deleted the syntheticmarkCompleted. The route runs it inline for immediate requests via an injectabledeletionRunner(createApp/registerDataDeletionRoutes); no runner → staysscheduled, never fabricated. grace-window cancel kept. 18 route+state tests; whole-BFF tsc 0. Built on: end-to-end consumersab4a0b77d7, honest completion3ffad97c2c.) - (BFF worker) A grace-expiry worker tick,
OSHUN_DELETION_WORKER_INTERVAL_MS, fail-closed off. (DONE5f4e9a6833—runScheduledDeletionCyclefinds 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).) - (key) Read
OSHUN_DELETION_ATTESTATION_ED25519_PRIVATE_KEYat boot; dev fallback non-prod, fail-closed prod. (DONE3d01c7ad24—resolveDeletionAttestationSigner: 32-byte hex key + KEY_ID; deterministic dev signer non-prod; FAIL-CLOSED null in prod when unset; malformed→throws. 4 tests;.env.exampledocuments it + the worker interval. PLUS the composition rootbuildDeletionRunner4f592e534e(assembles consumers+transport+orchestrator → the runner the route/worker consume).) - (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 —CustomerDataDeletionSectionnow 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+ truncatedreceiptSignatureHex) — 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 declaredattestation?+ a structurally-matchingOshunCustomerDataDeletionTombstoneAttestationon the@oshun/auth-clienttombstone 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 jobcompleted, every tombstone signed; a forced failure → requestfailed. New A-to-Z: seed real data across the 6 domains, POSTaccount_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, postaccount_full, run the worker past the grace period, re-query each store and assert erasure, and verify every attestation. - (e2e) Customer schedules deletion, sees per-service attestation rows
resolve; cross-link to the journey. (DONE 2026-06-10 —
profile-customer-data-deletion.spec.tsgained the "per-service erasure receipts" leg (4/4 green): a completed request renders exactly onedata-profile-deletion-attestationrow per ATTESTED tombstone (statuserased+ "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 exactstate.ts:393wire 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.)
- (contract) Extend
- 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/databaseimport behind the module boundary). Loader bug fixed first — the Prisma-7 generator emitsclient.ts, notindex.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.deleteAllForUserin iris/api (sweeps every tier viauserMemories+ 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 runsstartMemoryDeletionConsumer(apps/iris/api/src/deletion/) which subscribes tosubject.deletion.requestedon 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).
- wired them live in
- Acceptance criteria: An
account_fulldeletion makes a real delete call to each of the 6 services, produces one verifiable Ed25519 attestation per tombstone, setscompletediff all succeeded/absent elsefailedwith 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 asfailed, never silentcompleted).
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/libraryis read-only aggregation, no POST/DELETE to save arbitrary items (routes/library.ts:189-311).library-sharing.tsgoverns 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/resumeCursorwith a defined conflict policy,bookmarks.ts:33-158). Paywall UI already built (OshunPaywallCard.tsx,ShellFeatureGate.tsx).GET /v1/entitlements/aaaexists (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:
- (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 thecommonbarrel. The BFF store now imports the canonical types from@oshun/contracts(re-exports them so its route+test consumers are untouched); the BFF route'sPUT /syncnow validates the body withLibrarySyncRequestSchema.safeParse(replacing the hand-rolledisSavedItemArray— the malformed-body 400 is now contract-driven and deep-validates every item in the batch); the websavedItemsClientaliases its*Dtonames 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 webwebLibraryStorerefactor + inline paywall remain the frontend (browser-gated) pieces.) - (BFF store)
apps/oshun/bff/src/library/saved-items-store.ts: per-user Map + revision, snapshot-durable; bind reconciliation to@oshun/customer-curation/bookmarksupdateCursor/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:SavedLibraryItemsSnapshotpersists every user's item set TOMBSTONES INCLUDED (the deleted markers must persist — they're how an unsave propagates cross-device viaapplySync; dropping them would resurrect a deleted item on the next sync) +bindSnapshotSink/restoreSnapshot+persist()on themergechokepoint (save/unsave) ANDapplySync;wireDurableSavedLibraryItems(store)wired inserver.tson the SAME deploy-boundDurableSnapshotStoreas the sibling stores. So a member's saved library survives a restart instead of every member's library being wiped on deploy.) - (store test) last-write-wins by
lastSeenUnixSeconds, regressive rejected, two devices converge tofarthestCursor, 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.) - (routes)
GET /v1/library/saved-items,PUT /v1/library/saved-items/sync,POST/DELETE /v1/library/saved-items/:domain/:itemId; reuselibrary.ts:90-91preHandlers +resolveAuthorizedShellDomains; register nearregisterLibraryRoutes. (user-scoped via authContext.userId; registered in app.ts beside registerLibraryRoutes.) - (route test) 401; sync round-trips;
conflictsResolvedsurfaced; schema-validated. (5 tests incl. user isolation + malformed-body 400.) - (web data layer)
apps/oshun/web/src/lib/library/savedItemsClient.ts(mirrorprofile/data-deletion-client.ts). (getSnapshot/sync/save/unsave over the sharedapiclient; 5 tests. The webLibraryStore refactor that consumes it + the inline paywall remain.) - (web store) Refactor
webLibraryStore.ts: localStorage as offline cache;hydrate()pullsGET, save/unsave optimistic thenPUT .../syncwith a stabledeviceId; reconcile server response. (Store refactor DONE 2026-06-09 —webLibraryStoreis now backed by the per-user BFF store for a SIGNED-IN member, cross-device. Newlib/library/librarySync.ts(pure, tested) maps the RICH web item ↔ the THIN@oshun/contractsSavedLibraryItem(display fields ride inmetadata), plusmergeServerSnapshot(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 viaGETfor an authed member;save/unsavestay SYNCHRONOUS + optimistic (the ~15 UI call sites + the existing test are untouched) and fire a fail-soft background push — per-itemPOST/DELETE(immediate propagation; DELETE is the verb072ad5eabaCORS-unblocked) rather than the checklist'sPUT /sync+deviceIdbatch. 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 purelibrarySync+ 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: thePUT /sync+deviceIdoffline-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 …/syncwith a stabledeviceId) and a two-context browser e2e that saves on one device and reads on the other. - (web paywall) Wrap the save affordance (callers of
saveOshunWebLibraryItem, e.g.LibraryDashboard.tsx/ShellSavedQueuePanel.tsx) withShellFeatureGateso exceeding the cap rendersOshunPaywallCardinline; read the limit fromresolveProfileEntitlements; 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_itemsadded toOshunShellFeatureKey(new 'library' category, anchorDomain nisaba, minimumTier free, limit 25, period lifetime) + a NEWunlimitedAtTierrule 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'srequiredTierbecomes the LIFT tier (the actionable upgrade target). Web:components/billing/LibrarySaveGate.tsx(useLibrarySaveCapEvaluationover the LIVE real-save count — bundled showcase items never consume the cap — +LibrarySaveCapNoticecomposing ShellFeatureGate→OshunPaywallCard); rendered standing onLibraryDashboardand attempt-triggered above the results onSearchResultsView(whose save handler blocks the save direction at cap; unsave always works). PLUS the store chokepoint:webLibraryStore.save()itself refuses a 26th real save (canSaveAnotherRealItemviaevaluateShellFeatureAccessoveroshunWebProfileStore) 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.) - (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 withdata-oshun-paywall-action="upgrade"andaction-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).) - (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-contextsyncProfileStore, 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 standingLibrarySaveCapNoticerenders the inlinedata-oshun-paywall-cardwithdata-oshun-paywall-action="upgrade"targetingpro(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 /syncis last-write-wins (a stale clock loses + is counted inconflictsResolved, the fresher copy's title/clock win), a far-future tombstone in/syncpropagates 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-BFFrequest+devTokenstyle. GOTCHA (re-hit): a body-less DELETE must NOT carrycontent-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 — newe2e/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/libraryin a fresh browser (SW blocked, no local copy) and asserts the dashboard hydratesuseOshunWebLibraryStorefromGET /v1/library/saved-itemsand RENDERS the reconstructed item's title — the user-visible "device B sees it from the server". (uid-match confirmed: seeding withsession.tokens.accessTokentargets 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).) - (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 underOSHUN_V1_DATABASE_URL, no new creds.)
- (contract)
- 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:resolvePaymentsReceiptSignerenv-resolver [fail-closed null / fail-loud on > a bad key],isPaymentsConfigured, theInvoiceTargetProvisionerPORT, the runtime + bind),payments/quote-builder.ts(validate →gateInvoiceCreationdisclosure → provision → assemble the webPaymentsBridgeInvoiceDto→ persist), and the three routes (methods = REALlistSupportedAssets; 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 singleInvoiceTargetProvisionerPORT owns rate + amount + per-charge address (subsuming the separateprice-feed-fetcher/address-provisioneritems). 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 aggregatororacle-aggregator/price-feed.ts:55-89, Ed25519ReceiptSigner, paywall builderscustomer-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 contractPaymentsBridgeInvoiceDto(billing/crypto/invoice-loader.ts:47-104) whose BFF endpoint doesn't exist (activeFetcherdefaults null → "issuance pending"). (@aphrodite/crypto-paymentshas 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
PriceFeedFetcherover Kraken + CoinGecko + a Uniswap v3 TWAP RPC; math done, HTTP/RPC deploy-boundprice-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:
- (decision) Wire
@oshun/payments-bridge; update §7 to point at it + flag the crypto-payments mocks. - (composition root)
apps/oshun/bff/src/payments/payments-composition.ts: from env build aPriceFeedAggregator(+ real fetcher below), aReceiptSigner(OSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY+auditKeyId), an address provisioner over@aje/*/BTCPay; exportisPaymentsConfigured()for fail-closed handlers. - (price fetcher)
payments/price-feed-fetcher.ts: realfetchto 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 buildapps/oshun/bff/src/payments/price-feed-fetcher.tsbehind 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: perV1PaymentAsset(state-mapper.ts:14-49) issue a real per-charge target — BTC/LN via BTCPay/OpenNode (reuse the realBitcoinPaymentProcessor), EVM/Solana/TON/Monero via@aje/walletsor 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.tsalready declares theInvoiceTargetProvisionerport. 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. - (invoice store)
payments/invoice-store.ts: persist issued invoices (id, asset, amount, fiat, target, expiry, status, signed receipt); snapshot-durable. - (quote route) Replace
/v1/payments/crypto/quote(move toroutes/payments.ts): when configured → validate body →gateInvoiceCreation→PriceFeedAggregator.aggregate(spread-reject) → provision address → buildPaymentsBridgeInvoiceDtoviamake*Paywall(paywall-spec.ts:47-138) → sign → persist → return DTO; keeporiginGuard+csrfGuard; unconfigured → keep 503. - (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 atrequiredConfirmations(:125); verify signatures (fail-closed). 2026-09-18: open for an agent. The receiving half exists asPOST /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. - (web)
setInvoiceFetcher(invoice-loader.ts:85) at web boot → the new quote endpoint, soCryptoPaywallrenders 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 (StoredCryptoInvoiceWebDtoonCryptoInvoiceRecord; pre-retention records default null and honestly 404 — never reconstructed from summaries) andGET /v1/payments/invoices/:invoiceIdre-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 viabffGet; only a still-pendinginvoice 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.) - (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). - (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 callsGET /v1/payments/invoices/:idon the harness BFF) andbilling-crypto-paywall.spec.tsre-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.) - (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-paymentspath + "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.)
- (decision) Wire
- Acceptance criteria: With creds,
quotereturns a real per-charge target + live spread-checked rate + verifiable Ed25519 receipt asPaymentsBridgeInvoiceDto;methodslists only provisionable assets;invoicesreflects stored state; a webhook advances status + grants entitlement at confirmation depth; without creds all three stay fail-closed (no fabricated address) in every env;CryptoPaywallrenders 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 newapp.register(...)(asadmin-operator-inbox.tsdoes atapp.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/admintriage 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, storeoperator-inbox-decision-store.ts). The incident ledger/operator/incidentsis 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:
- (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 asoperator-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); typedOperatorIncidentErrormirroringoperator-inbox-decision-store.ts:45-53. (SNAPSHOT-DURABLE 2026-06-09:OperatorIncidentSnapshot+bindSnapshotSink/restoreSnapshot+ a privatepersist()on the singlecommit()write-chokepoint (one hook covers all 5 verbs);wireDurableOperatorIncidents(store)hydrates on boot then binds the write-through sink, wired inserver.tsbeside the persona-lifecycle / model-governance / device-tokens stores on the SAME deploy-boundDurableSnapshotStore. restoreSnapshot OVERLAYS the seed. An operator's triage now survives a restart instead of resetting to seededopen.) - (store test) each transition mutates status; concurrent-ack rejects
the 2nd; escalate enforces monotonic severity + rationale; snooze caps;
resolve requires class;
historyaccumulates 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.) - (route)
routes/admin-operator-incidents.ts(model onadmin-operator-inbox.ts): reusehasOperatorAdminScope+ auth + abuse preHandlers; zod-validate; each verb writes anadminAuditEventsStore.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.) - (register)
app.register(registerAdminOperatorIncidentsRoutes,{auditEventsStore})nearapp.ts:1005(shared store so 5.2 sees the events). - (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.) - (UI)
components/lilith/AdminInboxIncidentActions.tsx('use client', plain buttons + constants — keep the client-boundary discipline fromAdminInboxConsole.tsx:16-17): Acknowledge / Assign / Escalate (severity + rationale≥50) / Snooze (duration) / Resolve (class + rationale≥50), each POSTing + surfacing honest errors;data-incident-actionhooks. Render in the console detail card keyed onselectedId. (Panel built + tested viaapi.postto the verb routes; honest 403/409/400 errors. NOW RENDERED inAdminInboxConsole's detail card (keyed onselectedId, severity fromselected.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.) - (UI test) rationale-gate on escalate/resolve; severity monotonicity; 403 error surface.
- (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:AdminInboxIncidentActionsis rendered unconditionally inAdminInboxConsole's detail card (AdminInboxConsole.tsx:146-150,incidentId={selectedId}severity={selected.sev}), which/operator/adminrenders viaAdminInbox→ 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 (ownskipAxedescribe — 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-seedsopenat boot — no HTTP reset, so run on a clean process). GOTCHA: a pure-requesttest still triggers the auto-axe onabout:blank→document-titleviolation; isolate it in atest.use({ skipAxe: true })describe (the tenant-sso-config tripwire pattern).) - (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 headerCovers:now lists the acknowledge→escalate→resolve + bad-actor coverage;incident-triage.md## E2E coveragegained 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 stayspartial(INC creation / on-call paging / deep-link routing still uncovered) ·✅ verified.)
- (store)
- 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
eventTypenaming must match theincident.*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/auditis a read-only static table over/v1/auditfixture (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/ eventswith actorIds/workspaceIds/eventTypePrefix/needle/time/limit:62-85, saved-investigation CRUD:87-183, share-token:185-208, export json|markdown:210-257; scopeadmin:*|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:studioscope — 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.tsforwarding to BFF/v1/admin/audit-log/*with the caller's session (mirror the admin app'sadmin-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 sharedapiclient +proxy.ts— exactly how 5.1'sAdminInboxIncidentActionshits/v1/admin/operator-incidents/*. The explorer usesapi.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 ofAdminAuditLogExplorerPanel.tsxrestyled withL/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-testidhooks paralleling the admin panel. (Built — needle, eventTypePrefix, actor/workspace (comma inputs),datetime-localtime 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/investigationsvia theapiclient; "Export JSON" per row →api.get(.../export?format=json)→ a client Blob download — 7 unit tests total). Markdown export NOW DONE too — added an additiveRequestConfig.responseType:'text'+api.getText(path)to the shared web api-client (the markdown endpoint returns rawtext/markdown, which the JSON-parsingapi.getcould not consume;getTextsends anAccept: text/markdownheader + returnsresponse.text()). The explorer'sexportInvestigation(inv, 'json'|'markdown')now offers an "Export Markdown" button →api.getText(.../export?format=markdown)→ a.mdtext/markdownBlob 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. - (page) In
app/operator/audit/page.tsxkeep 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.) - (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 ghostLBtn href="/operator/audit"to theAdminInboxheader (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-INCdata-operator-inbox-counte2e is unaffected. Playwright run itself deferred — RAM-bound on this box.) - (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.) - (e2e) Extend
tenant-audit-log-investigation.spec.tsweb 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 REALGET /v1/admin/audit-log/eventswire (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.) - (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.)
- (web proxy)
- Acceptance criteria:
/operator/auditshows 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/adminlinks 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 anAbuseReport(withroutedQueue: '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:
- (store) In
personal-artifacts.ts, add anAbuseReportStore(record/list/get) alongsiderecordAbuseReport— keeprecordAbuseReportpure (don't make it persist). Export from the lib index. (yemaya lib — in scope, not euterpe. Idempotent by reportId.) - (store test) record→list newest-first;
routedQueuestamped. - (BFF persist) In
living-scenes.ts:1146-1153, afterrecordAbuseReport, write to a BFF store (living-scenes/abuse-report-store.ts, module singleton likeoperatorInboxDecisionStore); optionally snapshot-durable. Record aliving_scene.public_report_createdaudit event at creation. (Persist + theliving_scene.public_report_createdaudit event both DONE — the route records via theadminAuditEventsStoresingleton directly. End-to-end firing is exercised by the deferred public-report→operator e2e.) - (route)
routes/admin-abuse-reports.ts(model onadmin-operator-inbox.ts):GET {prefix}/abuse-reports(admin-scoped, dual-prefix), mapping eachAbuseReport→ inbox item (severity from reason; deterministicINC-...from reportId; deep link to/scene/:shortCode;category:'auto'). Register with{auditEventsStore}nearapp.ts:1005. (GET + dual-prefix + mapping + operator-scope DONE; auditEventsStore registration deferred with the audit event.) - (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.)
- (bridge) In
getAdminInbox()(operator-depth.ts:198-200) fetch/v1/admin/abuse-reportsand union abuse-derived INCs with the static fixture (fixture stays the dev/test fallback; prepend live items,category:'auto'); keep theAdminInboxData['items']shape. (Done via the serverbffGet(fail-soft → fixture-only on null/403/unreachable, so the six-INC e2e count holds). Maps reason→severity,autolane,INC-<reportId>, anon label + synthetic subtitle fallbacks, HH:MM-UTCwhen; drops malformed rows. 6 unit tests inoperator-depth.test.ts.) - (detail deep link) In
AdminInboxConsole.tsxdetail card, for an abuse INC render "Open offering ↗" →/scene/:shortCode(carryshortCodeon the item). (Added optionalshortCodetoAdminInboxData['items']; the console renders the deep link (data-operator-incident-deeplink) only when present. 2 unit tests inAdminInboxConsole.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 realPOST /v1/living-scenes/shares/:shareId/revoke. _(RESOLVE/DISMISS + AUDIT DONE — operators could see but not action abuse reports. Extended the yemayaAbuseReportStorewithactionReport(reportId,{action,actionedByUserId, actionedAtUnixSeconds,note?})(re-freezes the report with statusresolved/dismissed+ actor/when/note;recordAbuseReportstays pure — the store is the mutable triage owner; unknown id → null, never a fabricated action) + astatus:'open'default + theAbuseReportStatustype (5 lib tests). AddedPOST /v1/admin/abuse-reports/:id/{resolve,dismiss}(dual-prefix, admin/operator-scoped) → persists via the store → emits a discreteadminAuditEventsStore.recordeventliving_scene.public_report_resolved/_dismissed(chose thepublic_report_prefix to match the existing_createdevent for explorer faceting, rather than the checklist's awkwardactioned_\*literal) → returns the updated inbox item with its newstatus; 404 on unknown id. 4 route tests (401/403, resolve→status+persist+audit-event asserted viaadminAuditEventsStore.list, dismiss, 404). lib tsc 0, BFF tsc 0, eslint 0, stub-scan clean. **CORRECTS the prior "revoke needs an unverified shortCode→shareId map" blocker:AbuseReportALREADY carriesshareId(personal-artifacts.ts:188), so revoke is NOT map-blocked.** Revoke-share wiring itself deferred — it's a separate cross-route concern (the revoke route has its own owner-auth model; an admin-initiated revoke should call the underlying share service, a distinct slice). The web inbox surfacing of the new status + the operator-leg e2e remain frontend/RAM-bound.)_ _2026-09-18: open for an agent. The residue is the revoke wiring (an admin-initiated revoke that calls the share service, sinceAbuseReportcarries theshareId), the inbox showing the new status, and the operator-leg e2e._ - (e2e) Extend
public-scene-abuse-report.spec.tswith 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 RSCbffGet, not a browser fetch), and investigating revealedbff-fetch.tsforwarded NO auth at all in any environment — it called Next 16's asynccookies()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:bffGetnow awaits cookies() and forwards theoshun-accesstoken as the bearer, with authenticated fetches forcedcache:'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 liveINC-<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.) - (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.mdupdated: documents the wired bridge + deep link with unit coverage, and resolves the "operator inbox filter-chip mapping" open question. Thecoverage.mdgrade +incident-triage.mdE2E-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-reportsreturns it; it surfaces on/operator/adminas an INC with severity mapped from reason, under the Auto filter, with a working/scene/:shortCodedeep link; aliving_scene.public_report_createdaudit event is visible in 5.2; e2e drives reporter→operator end-to-end.
- (store) In
- 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, pinsdata-operator-inbox-count=6) breaks; keeprecordAbuseReportpure; 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) doesfetch(BFF_HEALTH_URL,{cache:'no-store'})and treats any non-throwing response as "online" — it never checksresponse.ok/status/content-type (OfflineBanner.tsx:57-62); only a thrown error sets offline./healthzreturns JSON{status:'ok',service:'oshun-bff'}(routes/health.ts:5-11). - The gap: A captive portal returning
200 text/htmlmakesfetchresolve → 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:
- Edit
OfflineBanner.tsx:57-62: requireresponse.okAND a JSON content-type (and optionally parse{status:'ok',service:'oshun-bff'}); elsesetOffline(true). Keep theforceOffline/disableAnimationtest hatches. -
OfflineBanner.test.tsx: a200 text/htmlresolves → banner shows; valid JSON 200 → dismisses;navigator.onLine===falsestill forces offline. - 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]. - (Optional hardening) BFF sets an explicit
x-oshun-health: oksentinel header on/healthzso the client can assert origin authenticity.
- Edit
- Acceptance criteria:
/healthzas200 text/htmlshows the banner; valid JSON 200 keeps it hidden;navigator.onLine===falseunchanged. - 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 && jsonOR the sentinel header.
6.2 Manifest-404 affordance#
- Status today:
PwaInstallPrompt.tsxdrivesavailablepurely frombeforeinstallprompt/appinstalled(:58-91); it never fetches/validates the manifest (declaredlayout.tsx:49, filepublic/manifest.json). Mounted only on/welcome/downloadHomeWorkspace. 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
(
beforeinstallpromptis 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:
- (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 —
beforeinstallpromptonly fires when the manifest is valid + installability criteria are met, so a broken manifest already yields no prompt; (2)PwaInstallPromptrendersnullin idle/dismissed (lines 466-467), so there is NO false affordance to correct — a manual trigger without adeferredPromptis a correct no-op; (3) adding a/manifest.jsonfetch + a newunavailablerender 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 aHEAD/GET /manifest.jsoncheck that gates the manual trigger handler (:113-125) to a no-op +data-pwa-install-unavailablestate 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-unavailableattribute; 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.tsxunit 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.
- (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 —
- Acceptance criteria: With
/manifest.json→404, the manual trigger showsdata-pwa-install-unavailableinstead 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) postsOSHUN_SKIP_WAITING, setsapplyingUpdate, and arms a 4s fallback timer that silentlywindow.location.reload()s (:256-261); normallycontrollerchangereloads first (:122-128).PwaUpdatePromptonly 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:
- (optional seam) Add a
data-pwa-update-manual-reloadhint toPwaUpdatePrompt.tsxafter 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 afterapplyingpersists 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. NewPwaUpdatePrompt.test.tsx(3 fake-timer tests: 1999ms→no hint / 2000ms→hint, click→exactly one reload, not-applying/reset behavior). Web tsc clean.) - (unit)
PwaBootstrap.test.tsxwithvi.useFakeTimers(): Apply → no controllerchange → advance 4000ms → assertreloadcalled once; controllerchange before 4s → immediate reload- timer cleared (
:122-128,257-261). (Mockwindow.location.reloadviaObject.defineProperty.) (Added astuck-apply fallback reload timerdescribe 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 dispatchescontrollerchange→ immediate single reload + advancing 10s does NOT reload again (timer cleared). Mockswindow.location.reloadviaObject.definePropertyonwindow, restored in afterEach. All 7 tests green.)
- timer cleared (
- (e2e, if seam)
pwa-failure-modes.spec.tsusingmockWaitingServiceWorkerUpdate: click Apply, never fire controllerchange, assert[data-pwa-update-manual-reload]; update the "infeasible" note (:160-167). (DONE 2026-06-10 —mockWaitingServiceWorkerUpdategained arespondToSkipWaiting:falseoption (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.)
- (optional seam) Add a
- 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.reloadin jsdom.
6.4 Nisaba SSR deep-read unit test#
- Status today:
/nisaba/page.tsx:13-16is a server component:getNisaba()→bffGet('/v1/nisaba/room')(lib/lilith-data/nisaba.ts:14-20), falling back tonisabaUnavailable()when!room?.passageBody.NisabaRoom(components/lilith/rooms.tsx:1114) is a pure server component. In e2e,bffGetis server-side andpage.routecan'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:
- Create
app/nisaba/__tests__/page.test.tsx(model onoperator/sso/__tests__/page.test.tsx):vi.mock('@/lib/server/bff-fetch',()=>({bffGet:vi.fn()})). - Test A (ready): mock a real room payload →
render(await NisabaPage())→ assert the passage title + apassageBodyparagraph + a section label. - Test B (unavailable):
mockResolvedValue(null)→ assert "Today's passage is not available right now." + no fabricated body. - Test C (malformed → fallback):
passageBody: undefined→ unavailable branch. - Build typed fixtures from the
NisabaDatatype (lib/lilith-data/types.ts).
- Create
- Acceptance criteria: A mocked real payload renders the real
passage/body/sections/related;
null/missingpassageBodyrenders 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-17server component:getVeritasLead()→bffGet('/v1/veritas/briefing/home')→mapHomeBriefingToVeritasData(veritas-briefing.ts:168) orveritasLeadUnavailable().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.tsxis a'use client'component — NOT the SSR path; the SSR path isVeritasRoom.) - The gap: Same root cause as 6.4 —
bffGetis server-side, unreachable frompage.route. - Classification: TESTABLE-NOW via server-component unit test.
- External dependency: None — pure test.
- Granular tasks:
- Create
app/veritas/__tests__/page.test.tsx;vi.mock('@/lib/server/bff-fetch'). - Reuse the
briefing()fixture builder fromveritas-briefing.test.ts:10-40. - Test A (ready): mock
briefing()→ assert the real headline + claim count + a source name. - Test B (null): assert
veritasLeadUnavailable()copy + zero claims. - Test C (degraded): missing
briefing/grounding→ unavailable branch.
- Create
- 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 thebff-fetch.tstransport 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.ymlreplaced the ECS workflow), and that stack already haspostgres,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 URLlibs/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
postgresservice ofinfra/hetzner/docker-compose.ymlis 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_URLif isolating) in BFF secrets. Board tag 2026-09-18: production secrets are set by the owner in the Hetzner environment; an agent checks thatinfra/hetzner/docker-compose.ymlpasses both variables to thebffservice 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.
- Withdrawn 2026-09-18: the
- 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 difffirst).
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
redisservice 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(orOSHUN_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).
- Withdrawn 2026-09-18: the
- 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): readsOSHUN_LIVING_SCENES_C2PA_SIGNING_KEY(64-hex) +_KEY_ID; non-prod dev fallback, prod → null → share route 503c2pa_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(orOSHUN_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_ENVis 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(startReminderDeliveryWorkerwhen>0; produce hook sweeps known users → Arete streak stats + Nyx event reminders; idempotent ondeliveredIds). - Default without it: worker disabled (log
:364-366); reminder routes stay live. - Tasks:
- Choose an interval; set
OSHUN_REMINDER_WORKER_INTERVAL_MSon 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).
- Choose an interval; set
- 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 |
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 |
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 |
- Add every var above to
.env.examplewith 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_KEYrequired,_REGION/_ENDPOINToptional), each with the fail-closed + release-gate note — plusOSHUN_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) andOSHUN_PAYMENTS_RECEIPT_ED25519_PRIVATE_KEY(4.3) — are now BOTH wired (4.1's full fan-out +resolveDeletionAttestationSigner, and 4.3's fail-closedresolvePaymentsReceiptSignerboth 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) + theOSHUN_VAPID_\*Web-Push triple — and added their rows to the matrix above. The only abstract matrix entries WITHOUT a concrete.env.examplevar are deploy-constructed providers with no in-repo var by design (caption-dub/a11y ML execution service, the payments chain-settlement RPC/BTCPay/wallet — owned by theInvoiceTargetProvisionerport, 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); scopesadmin:*/admin:studio/admin:workspace:*/admin:<domain>. Operator helperhasOperatorAdminScope(admin-operator-inbox.ts:33-40). - Mutation route registration:
registerAdminMutationRoute(admin.ts:7716); write-verb templateadmin-operator-inbox.ts+operator-inbox-decision-store.tsandadmin-metis-byom-decision.ts+metis/metis-byom-decision-store.ts. - Audit hub: pass
{auditEventsStore}onapp.register(...)(app.ts:1005);adminAuditEventsStore.record(...); read vialistAcrossOperators(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 viapage.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.mdrow+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.