# Residual Audit — Assistant Experience, Iris Memory, Psyche Realtime, Lilith Policy, Persona/Avatar/Voice Packs

Slice: `V1/features.md` lines 1807–2815. Date: 2026-06-11. Method: static source
reading + grep only; every claim below was verified against current code
(delegation chains followed; comments/tests not trusted as proof).

Context honored: C4/C5/C14/D5/E2/E4 landed as described in
`V1_SPEC_GROUND_TRUTH_AUDIT_2026-06-10.md`. The findings below are what remains
AFTER those fixes — chiefly, the fixes are real at the route/store they touched
but do not reach the surfaces the spec names, and several engines built for this
slice have zero live consumers.

Not findings (recorded deferrals / deploy): Psyche
voice/avatar/translation/conferencing realtime (honest `notBuilt` capability
surface, D5 default); the LLM composer without `OSHUN_LLM_*` creds (DEPLOY);
`/lilith` design-system showcase fixtures (spec explicitly says it is not the
canonical crisis surface); D1-covered `/domains/*` surfaces.

---

### 1. The assistant's Iris memory bridge is never bootstrapped — C4's wiring is dead at runtime

**Severity:** P0-STRUCT

**Evidence:**

- `apps/oshun/bff/src/routes/assistant.ts:208-215` — engine constructed with
  `memoryBridge` (C4), sessions created at `:395`, messages processed at `:531`.
  No call to `bootstrapMemory` or `closeMemory` anywhere in the route (or
  anywhere in `apps/oshun/bff/src` — grep returns zero hits outside the lib).
- `libs/oshun/shell-assistant/src/assistant-engine.ts:269-280` —
  `bootstrapMemory()` is "Required once per session before `processMessage` if
  the bridge is enabled"; `:384` and `:597` gate
  `recordUserTurn`/`recordAssistantTurn` on
  `this.memoryBridge?.hasSession(sessionId)`.
- `libs/oshun/shell-assistant/src/iris-memory-bridge.ts:407-409` — `hasSession`
  is only true after `bootstrapSession`, which only `bootstrapMemory` calls.

**Spec promise:** features.md 1816 (memory-state indicators reflecting real
posture), 1843–1867 (session memory, promotion), 2038–2049
(inference/promotion).

**What the code actually does:** `hasSession()` is false for every session the
BFF creates, so no member turn is ever recorded into Iris, the session's memory
indicator is never reconciled with real Iris consent, session memory is never
closed/sealed, and the promote-at-close path (`closeMemory`) never runs. The
bridge object exists; the runtime never activates it. The assistant still has no
memory.

**Fix sketch:** In the sessions POST handler,
`await assistantEngine.bootstrapMemory(session.id)` after `createSession`; in
the DELETE handler, `await assistantEngine.closeMemory(sessionId)` before
`endSession`. Surface the bootstrap's continuity indicator in the session
response.

---

### 2. Memory recall is never consulted — the recall-resolution pipeline and its sibling engines are orphans

**Severity:** P0-STRUCT

**Evidence:**

- `recallRelevantMemory`
  (`libs/oshun/shell-assistant/src/iris-memory-bridge.ts:837`) has zero callers
  outside its own file (repo-wide grep).
- `RecallPipeline` (`libs/oshun/memory-iris/src/recall/pipeline.ts:128`),
  `resolveIrisMemoryWriteConflict` (`conflict-resolution.ts:53`),
  `filterIrisPrivacyAwareRecall` / `evaluateIrisPrivacyStorageGate` /
  `cascadeIrisSensitiveConsentRevocation` (`privacy-suppression.ts:254-384`),
  `InferenceEngine` (`inference/policy.ts:66`), `InspectionStateMachine`
  (`admin-inspection/state-machine.ts:100`), `ActorHandleNamespace`
  (`multi-actor/namespace.ts:36`), `assessIrisProfileMemoryDecay`
  (`retention-compaction.ts:65`) — all have **zero** consumers in `apps/`
  (repo-wide grep per symbol).

**Spec promise:** features.md 1993–2036 (recall-resolution algorithm with scope
filter, suppression gate, sensitive-category gate, tenant boundary, relevance
scoring, per-surface budgets, surfaceRationale), 1884–1896 (conflict resolution
surfaced to the user), 1898–1913 (privacy suppression), 2137–2171 (admin
inspection state machine), 2086–2105 (multi-actor masking).

**What the code actually does:** memory (when it exists at all — see #1/#3) is
stored and reviewable, but never recalled into a turn. No response is ever
shaped by remembered context; no rationale ("you mentioned this last Sunday")
can ever surface; conflict detection, decay, suppression-at-recall, inference
thresholds, and admin inspection are tested library code wired to nothing.

**Fix sketch:** In `processMessage`, call `memoryBridge.recallRelevantMemory`
(≤12-entry budget) and feed results into the response formatter and the C14
composer's grounding facts. Route `/v1/iris/adapter/search` through
`RecallPipeline` + `filterIrisPrivacyAwareRecall`. Mount
`InspectionStateMachine` behind the admin iris reads.

---

### 3. No client can grant memory consent — the C4 consent route has zero consumers, so durable memory is unreachable for every member

**Severity:** P0-STRUCT

**Evidence:**

- `POST /v1/iris/adapter/consents` (`apps/oshun/bff/src/routes/iris.ts:437` —
  "the only path to granted") — repo-wide grep over `apps/oshun/web/src` and
  `apps/oshun/mobile/src` finds **no** caller. The only iris calls any client
  makes are two GETs:
  `apps/oshun/web/src/app/profile/memory/memory-state.ts:144-147` (`review`,
  `consents`).
- Consents start empty (`iris-memory-adapters.ts:467-508` honest empty seed) and
  the canonical engine suppresses durable writes until granted
  (`iris-memory-bridge.ts` planWrite/remember contract).

**Spec promise:** features.md 1846–1849 (profile memory "opt-in… stored with
consent record"), 2057–2085 (customer memory UX flows).

**What the code actually does:** the consent flip exists only as an HTTP route
and its tests. The `/profile/memory` page renders the six category consents as
forever-"pending" and offers no control that POSTs the decision. Combined with
#1 (no writes) and #2 (no recall), the consent-gated durable memory feature is a
closed loop no member can enter: nothing can consent → nothing writes → nothing
recalls.

**Fix sketch:** Add grant/deny controls on `/profile/memory` (and the assistant
dock consent prompt) that POST `/v1/iris/adapter/consents?role=settings` and
re-hydrate. Mirror in mobile.

---

### 4. /profile/memory mutations are localStorage theater — "permanently deleted (step-up enforced)" facts resurrect from the server

**Severity:** P0-HONESTY

**Evidence:**

- `apps/oshun/web/src/app/profile/memory/ProfileMemoryControls.tsx:369-498` —
  forget-fact, full-clear, category-clear, scope-clear, pause, and export all
  mutate the client view-model via lib functions
  (`hardDeleteIrisUserMemoryFact`, `clearIrisUserMemoryCategory`, …) and
  `commit()` → localStorage (`:173`). Success toasts:
  `"Memory fact permanently deleted (step-up enforced)"` (`:378`).
- The ONLY network calls on the surface are the two hydration GETs
  (`memory-state.ts:144-147`). `DELETE /v1/iris/adapter/memory/:id`
  (`routes/iris.ts:629`) has zero web/mobile callers.
- Hydration overwrite: `ProfileMemoryControls.tsx:156` re-fetches
  `fetchRealProfileMemoryState` on mount, replacing local state with the server
  record.

**Spec promise:** features.md 1875–1877 ("deletions are tombstoned and
propagated; no silent re-creation"), 1911–1918
(edit/clear/opt-out/export/delete/pause), 2068–2076 (forget flow with step-up,
full clear with 7-day grace).

**What the code actually does:** a member who "permanently deletes" a remembered
fact deletes a localStorage copy; the server's Iris store is untouched and the
fact reappears on the next visit. Pause ("off-the-record") likewise never
reaches the BFF — the server would keep writing memory while the user believes
writes are paused (features.md 1918). The step-up gate is also theater: the
challenge answer (`iris-step-up`) is printed next to the input (`:1082`) and
checked client-side (`:264`).

**Fix sketch:** Wire forget → `DELETE /v1/iris/adapter/memory/:id`,
category/scope clear → bulk-delete criteria, pause → a server-side per-user
write-suppression flag the iris adapter honors, then re-hydrate. Replace
string-challenge step-up with the real auth step-up flow.

---

### 5. /profile/safety shows every member a fabricated crisis record for a fictional user ("oshun-member-renata")

**Severity:** P0-HONESTY

**Evidence:**

- `apps/oshun/web/src/app/profile/safety/ProfileSafetyJourney.tsx:42-85` —
  `buildProfileSafetySeedState` hardcodes a 2026-05-08 `acute-grief` crisis
  frame for `subjectUserId: 'oshun-member-renata'`, with a fabricated redacted
  excerpt ("[redacted: grief disclosure during Tara session]") and resources
  (`us-988`, …). Rendered under the header "This page reflects what the audit
  platform sees about your most recent crisis frame" (`:206-208`). State
  persists to localStorage; the page never fetches anything.
- `apps/oshun/mobile/src/safety/MobileProfileSafetyJourney.tsx:50-67` —
  identical Renata seed on mobile.
- The REAL crisis-frame store exists server-side
  (`apps/oshun/bff/src/safety/crisis-frame-runtime.ts:41-64`, durable snapshot)
  and is never read by either surface.

**Spec promise:** features.md 2656–2659 ("Visible audit: a 'what happened'
surface in profile/safety shows the user the audit-platform-visible record of
the frame… the user owns the record of what occurred").

**What the code actually does:** every member — including those who have never
had a crisis frame — sees the same invented incident presented as their own
audit record; their stillness/re-entry/check-in interactions manipulate a
fiction in localStorage. This is the exact "Renata" fixture pattern C4 purged
from `/profile/memory`, surviving on the safety surface, where honesty matters
most.

**Fix sketch:** Add `GET /v1/safety/crisis-frames/mine` over the crisis-frame
store (+ incident records); render honest empty state ("no crisis frames on your
account") when none; drive the recovery-journey state machines from the real
frame, server-persisted.

---

### 6. The mobile assistant never reaches the BFF — no Lilith crisis policy, no memory, no server persona, no composer

**Severity:** P0-SEC

**Evidence:**

- `apps/oshun/mobile/src/components/MobileAssistantSheet.tsx:777,788,844` —
  every reply comes from
  `buildMobileAssistantReply(context, prompt, personaId)`.
- `apps/oshun/mobile/src/assistant/mobile-assistant-model.ts:372-440` — that
  function is a client-side keyword matcher over canned navigation strings. Grep
  for `fetch(`/api calls across `apps/oshun/mobile/src/assistant` + the sheet:
  zero. Grep for `crisis|lilith|safety` in the sheet: zero.

**Spec promise:** features.md 1813–1815 ("Universal invocation points across
customer web, customer mobile, and admin web"), 2342–2366 (crisis-aware behavior
on every surface, no-bypass), C5's "EVERY member turn".

**What the code actually does:** a member who types a suicidal-ideation message
into the mobile assistant gets a canned reply like "The durable answer is
Library…". The 13-rule crisis catalog, operator crisis frames, Iris memory, the
E4 server persona, and the C14 composer are all bypassed because mobile turns
never leave the device. C5's guarantee holds only on customer web.

**Fix sketch:** Point the mobile sheet at
`POST /v1/assistant/sessions(/…/message)` via `oshun-bff-client` (the network
layer already exists for handoff), keeping the local templates only as the
offline/error notice path — never as the reply for a successfully-sent turn.

---

### 7. AssistantPanel fabricates tool execution — fake progress bars report "complete" for actions that never ran

**Severity:** P0-HONESTY

**Evidence:**
`apps/oshun/web/src/components/assistant/AssistantPanel.tsx:1539-1576` — any
message containing "start"/"action"/"session"/"check-in" spawns a ToolAction
card ("Starting Tara session...", "Running daily check-in...") whose progress is
`progress += webRandomUnitInterval() * 30 + 10` on a 400 ms timer
(`// Simulate progress`, `:1561`) until `status: 'complete'`. No request is
made; nothing is started or run.

**Spec promise:** features.md 1813–1827 (assistant experience; safe fallback —
not fabricated activity), Quality bar: no result-faking on live paths.

**What the code actually does:** the member sees a tool run to 100% and report
complete when no check-in, session, or action occurred anywhere.

**Fix sketch:** Delete the keyword-spawned ToolAction simulation; render tool
cards only from real response cards/`navigateTo` outcomes returned by the BFF.

---

### 8. Failed session-history fetch silently substitutes four fabricated sessions

**Severity:** P0-HONESTY

**Evidence:**
`apps/oshun/web/src/components/assistant/AssistantPanel.tsx:651-663`
(`SIMULATED_SESSION_HISTORY`, `SESSION_TOPICS` "Evening meditation routine", …)
served by the catch at `:1885-1887`
(`// Fallback to simulated session history`).

**Spec promise:** features.md 1816 (turn history, transcripts are real member
data); the panel's own send-path comment (`:1654-1657`) states the correct rule
— fabricating is forbidden — then the history path violates it.

**What the code actually does:** when `GET /v1/assistant/sessions` fails, the
member is shown four invented past conversations with invented topics as their
own history.

**Fix sketch:** Replace the fallback with an honest "history unavailable right
now" state (the send path already has the right pattern).

---

### 9. Assistant-route Nisaba/Metis adapters are inline fixtures that fabricate per-member progress — while real adapters sit unused on `app.domainAdapters`

**Severity:** P0-HONESTY

**Evidence:**

- `apps/oshun/bff/src/routes/assistant.ts:115-170` — `nisaba.getContinueReading`
  returns hardcoded "Tablet Fragment 12 … progressPercent: 62" for every user;
  `metis.getContinueLearning` returns "Calculus I … progressPercent: 34,
  nextStep: 'Lesson 4 queued…'" for every user; search/recommendations come from
  `buildNisabaSearchHits`/`METIS_SEARCH_SEEDS` keyword fixtures.
- `apps/oshun/bff/src/adapters/domain-service-adapters.ts:827-897` — the REAL
  `NisabaApiAdapter`/`MetisApiAdapter` (with `getDailyPassage`,
  `getContinueLearning`, `searchCatalog`, circuit breakers, fail-closed
  unavailability) exist on `app.domainAdapters` (`:897-898`) and are used at
  `:59-113` for the other four domains — Nisaba and Metis alone are bypassed.

**Spec promise:** features.md 1816–1822 (context handoff, cross-domain
carry-over over real domain data; safe fallback when a subsystem is unavailable
— not fabricated data).

**What the code actually does:** the assistant tells every member they are 34%
through Calculus I and mid-way through "Tablet Fragment 12" regardless of
reality, on the live reply path. Two sources of truth: the assistant's Metis
answers can contradict the real `/v1/metis` surfaces.

**Fix sketch:** Replace the inline objects with `adapters.nisaba.*` /
`adapters.metis.*` delegation exactly as the other four domains do (the
interfaces already align); let the engine's safe-fallback path handle
unavailability.

---

### 10. Persona governance is five disjoint ID universes — policy, lifecycle, selection, and rendering never meet

**Severity:** P0-STRUCT

**Evidence:**

1. Consumer selection (E4): launch roster IDs — `lotus-mindful`, `rhea-steady`,
   `veritas-compass`, … (`libs/oshun/persona-registry/src/launch-roster.ts:64+`;
   served by `routes/personas-consumer.ts`).
2. Web assistant panel's own switcher: `oshun-navigator`, `tara-guide`,
   `stoic-scholar`, `zen-guide`, `sufi_heart`, …
   (`apps/oshun/web/src/design-system/assistant-persona-switching.ts:81-145`),
   persisted client-side and sent in a `personaHandoff` body field the BFF route
   never reads (`AssistantPanel.tsx:1444-1451,1596-1601` vs
   `routes/assistant.ts:444-449` body type). The panel never calls
   `/v1/personas/select` (grep: `personasClient` used only by
   `components/personas/PersonaPicker.tsx`).
3. Admin lifecycle store:
   `persona-zen-guide`/`persona-stoic-guide`/`persona-sufi-guide`
   (`apps/oshun/bff/src/admin/persona-lifecycle-store.ts:90-92`) — disjoint from
   the roster, so an admin retire/deprecate can never affect what members can
   select.
4. Lilith policy packs: `tara-contemplative-guide`/`veritas-stoic-scholar` only
   (`adapters/lilith-persona-policy-adapters.ts:39,66-69`) —
   `checkPersonaTopic('lotus-mindful', …)` returns "No Lilith persona policy
   pack was found" (`:526-533`); no roster persona has a policy pack.
5. Psyche runtime default persona: `Guide One`
   (`adapters/psyche-embodiment-adapters.ts:285+`).

**Spec promise:** features.md 2459–2519 (single canonical seven-role taxonomy
"referenced verbatim" by the registry, the assistant handoff surface, and every
Lilith suite), 2599–2629 (one lifecycle governing every persona), 2788–2791
(customer selection from the governed registry).

**What the code actually does:** the persona a member selects (roster) has no
Lilith policy, no lifecycle governance, and is not the persona the assistant
panel displays/switches; the persona the panel switches is never told to the
server; the personas admins govern don't exist for members. E4 made the
_selection_ server-side, but the selection floats free of policy, lifecycle, and
the rendering surface.

**Fix sketch:** Make `LAUNCH_ROSTER` persona IDs the single ID space: key Lilith
policy packs and the admin lifecycle store by roster IDs; replace the panel's
`assistant-persona-switching` catalog with `/v1/personas` browse +
`/v1/personas/select`; drop the ignored `personaHandoff` body field.

---

### 11. The Lilith tone-band catalog and its enforcement functions have zero consumers

**Severity:** P0-STRUCT

**Evidence:**
`libs/oshun/persona-policy-lilith/src/tone-bands/enforcement.ts:79-160` exports
`resolveEffectiveToneBand`, `enforceIsisGenerationDispatchTone`,
`enforceLivingScenesConductorTone`, `enforceShareControlTone`; repo-wide grep
finds no caller outside the lib and its tests. Same for the band-transition
graph (`transitions.ts`) and `checkPublishLineage`/`emitLineageBinding`
(`sacred-symbols/lineage-binding.ts`).

**Spec promise:** features.md 2520–2570 (tone band catalog, band transitions,
per-band capability caps: "Bands cap the ceiling"), 2552 table (max motion /
audacity / share / generation tier), tests at 2680-2683 ("every band's caps
enforced in Isis, Living Scenes, share controls"), 2571–2597 (lineage policy
enforced at composition time).

**What the code actually does:** generation dispatch, share controls, and scene
conduction run without any tone-band ceiling; the catalog is a versioned
artifact enforcing nothing. The "contemplative-strict" promises (audacity 0,
reduced-motion, redacted-intent share) are not applied anywhere a member's
request passes through.

**Fix sketch:** Call `enforceIsisGenerationDispatchTone` in the Isis generation
admit path, `enforceShareControlTone` in the share routes, and
`enforceLivingScenesConductorTone` in the living-scenes conductor; derive the
active band from surface + persona role.

---

### 12. Psyche dialogue surface (WS + HTTP) has zero clients, and records member turns with no Lilith policy, no crisis-frame check, and client-assignable "assistant" role

**Severity:** P1

**Evidence:**

- Zero consumers: grep for `psyche` across `apps/oshun/web/src` and
  `apps/oshun/mobile/src` returns nothing (no page, no client, no WS connect).
- No policy: `apps/oshun/bff/src/psyche/realtime-route.ts:83-99` and
  `routes/psyche.ts` turn POST record content straight into the durable store —
  no `analyzeMessageSafety`, no `isCrisisFrameActiveForUser`, no consent gate;
  turns persist via durable snapshot (`dialogue-session-store.ts:184`).
- Role spoofing: `realtime-route.ts:85-88` accepts
  `role: 'assistant' | 'system'` from the client, writing assistant-attributed
  turns into the member's transcript.
- `PsycheSessionBridge`
  (`libs/oshun/shell-assistant/src/psyche-session-bridge.ts:179`) — the piece
  that would connect the assistant engine to Psyche sessions — has zero app
  consumers.

**Spec promise:** features.md 2191–2196 (session envelope binds persona, Lilith
policy version, Iris memory scope), 2284–2289 (crisis-frame entry mid-session →
instant policy enforcement, memory-write suspension), 1854–1856 (no memory
writes during crisis frame).

**What the code actually does:** D5's transport transports for nobody (a
member-facing realtime leg with no member surface), and if anything ever
connects, it is a policy-free durable recorder of member utterances. The
assistant engine and the psyche session store remain two unlinked conversation
systems.

**Fix sketch:** Run every inbound `turn` through `analyzeMessageSafety` + the
crisis-frame check (mirror the assistant route's interception); reject
client-supplied non-`user` roles on the WS; wire a web surface (assistant panel
session transport or `/psyche` page) — or explicitly record the no-client status
as a deferral.

---

### 13. Psyche adapter session creation fabricates a live "connected" runtime — example.com join URLs, invented latencies and confidence scores

**Severity:** P1

**Evidence:** `apps/oshun/bff/src/adapters/psyche-embodiment-adapters.ts` —
`buildConference` returns `connectionState: 'connected'`,
`joinUrl: 'https://example.com/psyche/…'`, `latencyMs: 130` (`:605-617`);
participants get `connectionQuality: 'excellent'`, `latencyMs: 48/62`,
`isSpeaking: true` (`:504-528`); `buildTranslation` reports a `'streaming'`
websocket with `avgConfidence: 0.91` (`:620-646`); disclosure metadata claims
`watermarkApplied` (`:660`); persona seeds carry invented
`evaluationScore: 0.94` / `validationScore: 0.93` (`:297,323`). All reachable
via `POST /v1/psyche/adapter/sessions` (`routes/psyche.ts:432`).

**Spec promise:** features.md 2236–2256 (latency budgets and quality thresholds
are _measured_), 2270–2281 (diagnostics: per-event timing, provider
attribution). D5's own bar: "honest health… instead of fabricating transports".

**What the code actually does:** the capability/health routes honestly say the
realtime transports are not built (`:155-169`), but the session-creation route
on the same adapter answers with a fully fabricated connected conference +
streaming translation + speaking AI participant. The two routes contradict each
other; the session route fails the "did it really produce this?" test.

**Fix sketch:** Make `plan-session`/`sessions` fail-closed for modalities whose
transports are `notBuilt` (mirror the capability surface); return only the
text-session envelope that is real; delete the invented metrics.

---

### 14. Crisis-frame suppression of Iris memory is client-asserted, and crisis frames don't suppress generation/Tara surfaces

**Severity:** P1

**Evidence:**

- `apps/oshun/bff/src/routes/iris.ts:545-578` — `lilithCrisisFrame` /
  `safetyCriticalContext` on `/v1/iris/adapter/search` are **query params the
  caller supplies**; `:740-741,781-782` — same on `plan-write`/`remember`
  bodies. The server never injects `isCrisisFrameActiveForUser(userId)` into
  these calls; the canonical adapter's `matchesCrisisRecallPolicy` filter
  therefore only fires if the client chooses to say so.
- Server-side frame checks exist in exactly four places
  (`grep isCrisisFrameActiveForUser`): assistant route, telegram, reminders,
  payments (domain-stubs). No Isis generation route, Tara route, or
  living-scenes route consults the frame.

**Spec promise:** features.md 1854–1856 ("during a Lilith crisis frame, no new
memory writes; existing memory suppressed from recall"), 2004–2005
("Crisis-frame suppression overrides all other markers"), 2351–2354 (halt
synthesis — no ritual continuation), 2648–2650 (Living Scenes/briefings/Isis
generation remain disabled during stillness).

**What the code actually does:** during an operator-armed crisis frame, memory
writes and recalls through `/v1/iris/adapter/*` proceed normally unless the
client volunteers the flag, and generation/ritual surfaces continue serving.
Enforcement-by-honor-system.

**Fix sketch:** In the iris search/plan-write/remember handlers, compute
`lilithCrisisFrame` server-side via `isCrisisFrameActiveForUser` and OR it with
the client hint; add the same check to the Isis generation admit path and Tara
session-start.

---

### 15. /profile/persona voice page is built against a contract the E2 route doesn't serve — dead previews, dead "switch", undefined provider

**Severity:** P1

**Evidence:**

- Page expects `{ id, providerId, previewUrl, approvalState }` per voice
  (`apps/oshun/web/src/app/profile/persona/page.tsx:31-43`) and renders
  `Provider · {voice.providerId} · {voice.approvalState}` (`:132`),
  `<audio src={voice.previewUrl}>` (`:173-178`), and
  `voice.id === data.activeVoiceId` (`:101`).
- The E2 route returns
  `{ voiceId, label, description, familyLabel, premiumCast }`
  (`apps/oshun/bff/src/routes/personas-consumer.ts:117-125`).
- The page's doc comment promises "Voice switching is a POST that lives in the
  BFF voice module" (`:5-6`) — no such POST exists; the "○ switch" affordance
  (`:157`) is a non-interactive `<span>`.

**Spec promise:** features.md 2738–2752 (voice profile schema: vendor, consent
record, naturalness baseline; per-profile catalog), 2788–2791 (customer persona
selection with per-persona disclosure copy).

**What the code actually does:** every row shows "Provider · undefined ·
undefined", an audio element with `src={undefined}`, an active marker that can
never match (`voice.id` is undefined), and a switch control that does nothing.
E2 unified the data source but the page was never migrated to it.

**Fix sketch:** Render the route's actual DTO
(`voiceId`/`familyLabel`/`premiumCast`), drop the fabricated provider/preview
fields (or add real ones to the registry), and make "switch" POST
`/v1/personas/select` with the persona behind the voice.

---

### 16. Voice and avatar persona packs never affect the actual voice or embodiment — stored config, default browser voice

**Severity:** P1

**Evidence:**

- `AssistantPanel.tsx:1829-1840` — `speakText` uses a bare
  `SpeechSynthesisUtterance` (rate 1.0, pitch 1.0, no `voice` assignment). The
  member's `activeVoiceId` (from `/v1/persona/voice` / active persona) is read
  nowhere in the speech path.
- The assistant identity pins `voicePackId: null, avatarPackId: null`
  (`routes/assistant.ts:188-189`); avatar mode in the panel is entitlement-gated
  CSS embodiment with no pack binding (`:1103-1136`).

**Spec promise:** features.md 2690–2697 (packs are governed assets gating
premium voiced/avatar experiences), 2738–2752 (voice profile contracts),
1828–1830 (avatar assistant mode with entitlement gating + synchronized
disclosure).

**What the code actually does:** selecting a voice/persona changes a stored
record and a list highlight; every spoken reply uses the OS default voice and
the avatar surface is identical for all personas. Persona packs are
config-without-effect on every surface that exists today.

**Fix sketch:** Map roster personas to `speechSynthesis.getVoices()` profiles
(locale + gender + rate/pitch per pack) as the V1 voice binding; thread
`activePersona` into the panel's tone/avatar theme; record the real TTS-provider
binding as deploy-bound.

---

### 17. ContinuationToken protocol: real route, zero emitters, zero readers

**Severity:** P1

**Evidence:** `routes/iris.ts:307-416` implements write/read/resume/notices over
`ContinuityProtocol` (`@oshun/memory-iris/continuity/protocol`). Grep for
`continuation-token` across `apps/oshun/web/src` + `apps/oshun/mobile/src`: zero
hits. No surface (Tara, Nisaba, Metis, home shell) writes checkpoints or renders
continuation cards from it. (The separate C16 `/v1/iris/mobile-handoff` blob
channel IS consumed — it is not the spec's per-surface checkpoint protocol.)

**Spec promise:** features.md 2105–2135 ("every surface emits ContinuationToken
updates at natural checkpoints… the home surface renders continuation cards from
them").

**What the code actually does:** dead route; cross-device continuity for
reading/study/ritual posture does not function.

**Fix sketch:** Emit tokens from the web Tara player / Nisaba reader / Metis
lesson at checkpoint events; render `GET /v1/iris/continuation-tokens` as home
continuation cards.

---

### 18. Iris export fabricates a bundle that is never written; DSAR data-export omits memory entirely

**Severity:** P1

**Evidence:**

- `adapters/iris-memory-adapters.ts:377-402` — `exportMemories` returns
  `success: true`, `filePath: '/exports/…'` (no file is ever written),
  `sizeBytes: memoryCount * 384`, `checksum: '${exportId}-checksum-v1'` — all
  invented. Served live by `POST /v1/iris/adapter/export`
  (`routes/iris.ts:665`).
- The web export flow doesn't use it: `ProfileMemoryControls.tsx:485` exports
  the **client** view-model as a data URI (`buildProfileMemoryDataUri`) and
  labels DSAR exports "signed and ready to download" (`:497`) with no signature.
- `apps/oshun/bff/src/data-export/bundle-builder.ts` — grep for
  iris/memory/assistant: zero; the account data-export bundle contains no
  memory.

**Spec promise:** features.md 1915 ("Export: full memory bundle export (JSON),
with audit chain"), 2080–2082 ("signed JSON with audit chain; downloadable from
profile/data-export; available to DSAR fulfillment").

**What the code actually does:** the server endpoint manufactures an export
manifest pointing at a file that doesn't exist; the UI exports a different
(client-side) object and calls it signed; DSAR fulfillment never sees memory.

**Fix sketch:** Make `exportMemories` build the real in-memory JSON bundle
(entries + consents + audit refs) and return it inline (or via the existing
data-export bundle store); add an iris section to `bundle-builder.ts`; drop the
"signed" copy until a signature exists.

---

### 19. Lilith enforcement reaches exactly one surface — the spec's other wiring points are unwired

**Severity:** P1

**Evidence:** `analyzeMessageSafety` is called from exactly one live path:
`routes/assistant.ts:485,609` (grep). Sophia's answer composer/synthesizer
(`apps/oshun/bff/src/sophia/*`) contains no lilith/crisis reference; Metis tutor
routes, Tara generation paths, and Telegram inbound message handling run no
claim/tone/crisis analysis (telegram checks only the operator frame flag,
`routes/telegram.ts:19,263`). `validateDraftResponse` (output-side validation)
is exposed on `/v1/lilith/adapter/evaluate-interaction` but invoked by no
generation pipeline.

**Spec promise:** features.md 2444–2449 ("Wiring points: Tara teacher personas…
ritual script generation, meditation script narration, voice rendering, avatar
rendering, Veritas explainer generation, Sophia grounded answers, Metis tutor
responses, support copilot"), 2296–2302 ("the policy fires inline across
whichever surface the user is on").

**What the code actually does:** C5 put the catalog in the assistant's line of
fire and nowhere else. A crisis disclosure typed into a Sophia question, a Metis
tutor exchange, or a Telegram message gets no per-signal response; generated
contemplative content is never tone-checked before serving.

**Fix sketch:** Run `analyzeMessageSafety` on Sophia question intake and
Telegram inbound text; run `validateDraftResponse` (plus the tone rubric) on
Sophia/Metis composed outputs before send; reuse the assistant route's
interception envelope.

---

### 20. Memory consent has three disconnected sources of truth; the assistant's memory indicator reflects none of the real ones

**Severity:** P1

**Evidence:**

- (a) Profile preferences: `AssistantControlsSection.tsx` writes
  `preferences.assistant.memory.*`; the BFF session create derives
  `memoryConsentGranted` from that (`routes/assistant.ts:417-419` via
  `buildOshunAssistantBehaviorProfile`), and session continuity's memory
  indicator comes from it (`assistant-metis-handoff.ts:53-56`).
- (b) Iris consent records: `setBffIrisConsent` store
  (`iris-memory-adapters.ts:412`) — the thing that actually gates durable
  writes.
- (c) `/profile/memory` category consents: localStorage projection (#3/#4).
- The reconciler (bridge `bootstrapSession` continuity indicator) is never
  invoked (#1).

**Spec promise:** features.md 1816 (memory-state indicator), 1846–1849 (consent
record governs profile memory); E2's own one-source-of-truth bar.

**What the code actually does:** a member can toggle "Approved memory" in
assistant controls and see the shell indicator say memory is on, while Iris
consent is ungranted and every durable write would be suppressed — and vice
versa.

**Fix sketch:** Make the Iris consent store authoritative: assistant-controls
toggle POSTs `/v1/iris/adapter/consents`; session create reads consent from the
iris adapter (or via `bootstrapMemory`'s continuity) instead of preferences.

---

### 21. Mobile has no memory-controls flow at all

**Severity:** P1

**Evidence:** `find apps/oshun/mobile/src -iname "*memory*"` → only
`components/AssistantMemoryState.tsx` (an indicator with no `/v1/iris` binding).
No mobile screen lists/edits/forgets memory; no consent surface.

**Spec promise:** features.md 2049–2052 ("The customer-facing memory surface
lives at `apps/oshun/web/src/app/profile/memory/` **and the equivalent mobile
flow**").

**Fix sketch:** Mobile memory screen over the same `/v1/iris/adapter/review` +
`/consents` reads and the (to-be-wired, #4) mutation routes.

---

### 22. C14's retrieval/synthesized disclosure labels and the E4 persona envelope are rendered by no client

**Severity:** P2

**Evidence:** the route emits `responseMode`, `retrievalText`
(`routes/assistant.ts:611-621`) and `activePersona` (`:580-587`); grep across
web + mobile finds no consumer of any of the three (the only `responseMode` hits
are an unrelated drone-cinematics module). The panel's parse type
(`AssistantPanel.tsx:1577-1590`) doesn't even include the fields.

**Spec promise:** features.md 1816-1818 (disclosure state, persona identity
indicators).

**Fix sketch:** Render a small "synthesized/grounded" register chip + the server
persona label in the panel from the existing fields.

---

### 23. Lilith adapter still runs on the frozen clock and fabricates voice-quality verdicts

**Severity:** P2

**Evidence:** `adapters/lilith-persona-policy-adapters.ts:38` —
`DEFAULT_NOW = '2026-03-22T15:00:00.000Z'` stamps health `lastChecked` forever
(the C4 fix removed only the iris twin). `createVoiceSafetyAssessment`
(`:449-488`) hardcodes `clarity: 0.94, naturalness: 0.92, consistency: 0.9`,
`watermark: { verified: true }` unconditionally, `certified: !abuseSignal` —
quality scores and watermark verification for audio it never analyzed, served
via `/v1/lilith/adapter/*` voice-safety reads.

**Spec promise:** features.md 2368–2384 (voice-quality policy: naturalness floor
measured per profile, audio integrity: required watermark _validated_).

**Fix sketch:** Live clock like the iris adapter; return
`{ assessed: false, reason: 'no-audio-analysis-provider' }` fail-loud instead of
invented scores until a real scorer exists.

---

### 24. Assistant sessions and transcripts are process-memory only

**Severity:** P2

**Evidence:** `routes/assistant.ts:51` module-singleton engine;
`assistant-engine.ts` sessionStore is an in-memory map with no snapshot sink
(unlike psyche's `wireDurableDialogueSessions` and iris's
`wireDurableIrisMemory`).

**Spec promise:** features.md 1816 (persistent context, transcripts), 2200-2204
(reconnect with durable session ID).

**Fix sketch:** Add a durable-snapshot sink for assistant sessions mirroring
`dialogue-session-store.ts`.

---

### 25. Iris/Lilith/Psyche health endpoints fabricate per-check latencies

**Severity:** P2

**Evidence:** `iris-memory-adapters.ts:255-281` (`latencyMs: 9/7/12/5/6/11`
constants); psyche adapter at least measures its stores
(`psyche-embodiment-adapters.ts:132` "D5: honest health… measure"), lilith
health is constant-frozen (#23).

**Spec promise:** honest health surfaces (D5's own bar).

**Fix sketch:** Measure the store call (psyche pattern) or drop the latency
field.

---

### 26. `/welcome/test-assistant` test harness ships as an app route

**Severity:** P2

**Evidence:** `apps/oshun/web/src/app/welcome/test-assistant/page.tsx` — a
"TestSection" playground for the panel, reachable in production routing.

**Fix sketch:** Move under a dev-only flag or delete (the panel has real entry
points everywhere).

---

### 27. UX cohesion: three persona surfaces, two memory surfaces, and a safety surface that don't tell one story

**Severity:** UX

**Evidence/observations:**

- Persona: `/personas` (PersonaPicker, roster IDs, server-backed) vs the
  assistant panel's persona switcher (different persona set, client-only) vs
  `/profile/persona` ("voices", broken DTO #15). A member can "activate" three
  different personas in three places and no surface reflects the others.
- Memory: the assistant dock/panel memory indicator (preferences-driven) vs
  `/profile/memory` (Iris-hydrated + localStorage mutations). The spec's pause
  toggle "visible in the assistant dock and the profile/memory header"
  (features.md 2076-2078) exists only on profile/memory, only locally — pausing
  there changes nothing in the dock or the engine.
- Safety: `/profile/safety` narrates a crisis journey (stillness, re-entry,
  check-ins) that has no connection to the real crisis frame the assistant route
  enforces — a member who actually trips the crisis catalog sees the assistant's
  plain-language response, then finds a profile/safety page describing a
  _different, fictional_ incident.
- Psyche: a whole runtime with no page; nothing in the product ever says
  "realtime session".

**Fix sketch:** One persona entry point (panel switcher = `/v1/personas` data);
one memory-consent authority surfaced identically in dock + profile;
profile/safety driven by the real frame store; remove or surface Psyche.

---

## Severity counts

| Severity   | Count  | Findings                                         |
| ---------- | ------ | ------------------------------------------------ |
| P0-SEC     | 1      | #6                                               |
| P0-HONESTY | 5      | #4, #5, #7, #8, #9                               |
| P0-STRUCT  | 5      | #1, #2, #3, #10, #11                             |
| P1         | 10     | #12, #13, #14, #15, #16, #17, #18, #19, #20, #21 |
| P2         | 5      | #22, #23, #24, #25, #26                          |
| UX         | 1      | #27                                              |
| **Total**  | **27** |                                                  |

**Cross-cutting summary:** the 06-10 headline fixes are real but each stops one
hop short of the spec's surface. C4 built consent-gated durable memory that
nothing can write to, consent to, or recall from (#1–#3); C5 put crisis policy
on one route while mobile, Sophia, Metis, Telegram, and Psyche turns bypass it
(#6, #12, #19); D5 built a transport no client connects to (#12); E2/E4 made
persona selection server-side inside a five-way persona ID schism (#10, #15,
#16). The Renata-fixture pattern C4 purged from /profile/memory survives
verbatim at /profile/safety (#5), and the tone-band/recall/inference/inspection
engines remain fully built, fully tested, and fully unwired (#2, #11).
