EVA Services · Epic Completion Audit

Epic Completion Audit

121 stories · 19 epics · audited against EVA_Sprints.xlsx · refreshed 2026-07-16 after a post-epic security-remediation stream (gateway auth, provider-native webhook verification, event-bus reliability, credential lifecycle, MCP dispatch input-validation, and a full-audit security-hardening pass) — findings #45–60 — plus an Integrity Gate v2 hardening pass and two independently-discovered production-path bugs — findings #61–63 — plus a full MCP tool E2E sweep and its findings — findings #64–67. Story verdicts unchanged: every stream hardened how the DONE features are authenticated / transported / persisted / gate-calibrated / erasable, not what they do.
118 DONE (98%)  ·  1 PARTIAL (1%)  ·  2 NOT STARTED (2%)
■ Open Priority · Action Required · owner to add embedding model
Graphiti has no working embedding model — semantic graph search is degraded (index-only fallback).

Graphiti requires an LLM (working: qwen3-5-397b via nabh) and an embedding model. The embedder is wired to nomic-embed-text through the nabh proxy (EVA_Shared/eva-persistence/src/eva_persistence/stores/graphiti_store.py:81-85), but nabh returns 404 Route Not Found for POST /v1/embeddings — it advertises embedding models in /v1/models (nomic-embed-text, bge-m3, qwen3-embed-8b) yet does not serve the OpenAI-compatible embeddings endpoint. Effect: every graph fact-search logs graphiti search failed, falling back to index and runs keyword/BM25 only — no vector similarity. Fact recall still works via the local index; semantic / hybrid retrieval does not. Surfaced by the 2026-07-15 Bureau E2E. Fix (deferred): point the embedder at a working embeddings endpoint — nabh's real embeddings path if one exists, or a decoupled local embedder (e.g. Ollama bge-m3 / nomic-embed-text) set only on OpenAIEmbedder.base_url while the LLM and reranker stay on nabh. Writes still build graph nodes; they simply carry no embeddings until this lands.

EVA Services · Epic Index

EVA Services · Audit Synthesis

Cross-Cutting Findings

Read this before the per-epic tables. Every verdict is grounded in a direct read of the cited source (backend and/or Flutter frontend) — not inferred from module names or docstrings. Refreshed 2026-07-08 against the post-restructuring codebase. Frontend evidence cites files in the separate EVA-Flutter-App-Frontend repo — this repo (EVA-Services) itself contains no Dart/Flutter code, by design.

1
PolicyGate zone-check deleted this session — a real, documented regression spanning ~10 stories.

eva-core/isolation/policy.py:15-27,63-74 _decide() now only compares caller.principal_id != target.principal_id; the caller.zone != target.zone branch and AuditAction.CROSS_ZONE_ATTEMPT were removed outright (DEV-NOTES.md correction M), a new test (test_isolation.py:84-102) asserts a GENERAL caller can now read MNPI data for the same principal, and the red-team corpus had its cross-zone case deleted with the comment “this is no longer a real attack” (security/corpus.py:40-42). This single change downgraded verdicts across E11-S3/S4, E13-S5, E15-S2/S5, E16-S7, E17-S6, E18-S5/S6, and E19-S7 from “unreachable in practice” to “the enforcement primitive no longer exists to wire anything into.”

2
A graveyard of features instantiated into app.state with zero production callers.

At least seven independent subsystems are wired at boot and never invoked by any route/MCP tool: PlaybackProgressTracker (wiring.py:548-549), BatchUndoStore (wiring.py:551-552, despite passing unit tests), DeviceContextStore (wiring.py:545-546, contradicting its own docstring), RendererPriorityQueue (wiring.py:542-543), CalibrationPushService (wiring.py:557-560), IdentityResolutionEngine (wiring.py:297-299), and HashChainedAuditLog (wiring.py:197-199). This is the dominant failure mode in the codebase — correct, tested logic that the application never actually calls.

3
The same bug now exists in two independently-maintained implementations.

RelationalGraphService.evaluate_drift (arbiter/relationships.py:113, max(cadence_score, tone_drift)) and the production-wired SupabaseRelationshipStore.evaluate_drift (relationship_store.py:169, identical max()) both violate “cadence primary, tone corroborator” (E7-S2). Separately, HashChainedAuditLog (real hash chain, zero callers) and SupabaseAuditSink (live, durable, but its docstring's claimed audit_log_set_hash DB trigger doesn't exist per migrations/010_audit_log.sql) leave the append-only audit log split into a chained-but-dead half and a live-but-unchained half (E9-S1). And two colliding ScenarioAnalysis dataclasses exist — the correct advisory one in relevance.py:10-16 and a directive one in evaluator.py:47-53 that emits recommendation text like “Activate contingency plan” (E6-S12).

4
Flutter UI now exists for several stories but is wired to the wrong JSON contract, rendering blank/broken data.

memory_result.dart expects id/content/entity_type/timestamp/citations:List<String> but the backend sends fact_id/subject/predicate/object_value/citations:List<Citation> (E9-S2). draft_item.dart expects subject/content/pillar/draft_type but GET /keeper/drafts returns BriefDraft shaped as draft_id/principal_id/zone/brief_type/state/sections (E2-S3, E12-S4). Both screens are reachable and call the correct endpoint, but would render empty/default values against real data.

5
MCP tool layer silently strips or over-promises relative to the REST layer.

eva_recall_facts (registry/keeper.py:122-143) drops citations that the equivalent REST routes (keeper_routes.py:40-58,284-293) serialize correctly (E3-S2, E9-S2). Separately, the eva_forget tool description (catalogue.py:424-430) claims a “24-hour propagation SLA,” but the wired handler (webhooks.py:138-157, registry/compliance.py:9-33) only ever calls request_forget()ForgetEngine.propagate() (erasure.py:84-102) has zero production callers outside tests (E9-S4, E15-S3/S4). Agent-facing tool descriptions are actively misleading about what the system does.

6
A new runtime bug introduced by the restructure silently breaks the existing intelligence-crawl job.

scheduler.py:161 calls relevance_engine.score(...), but the object wired at that name (wiring.py:444-445) is a RelevanceScenarioEngine whose only method is score_event(...) (relevance.py:24) — every call raises AttributeError, caught by a bare except at scheduler.py:166-168, with no test covering _cartographer_crawl (E6-S1). This means the pre-existing 4-hour crawl pipeline is currently non-functional in production, a regression beyond the original “interval too slow” finding.

7
Two unrelated “draft” systems are conflated by the Flutter UI, and the real one has no frontend or REST exposure at all.

draft_review_screen.dart / keeper_providers.dart call GET /keeper/drafts, which is backed by LivingDraftManager's BriefDraft (morning-brief queue) — not Correspondent's queue_for_principal() (correspondent.py:665), the actual E5 email-draft/escalation queue, which is referenced only by its own unit test and exposed via no route or MCP tool anywhere (E5 epic_note, E8-S1, E12-S4). This also means E8-S1's “memo variant” premise fails doubly: there's no shared brief-variant abstraction to extend, and the story's target draft system is invisible to the app.

8
Genuine new frontend capability this session, but scoped generically rather than to the target feature.

morning_brief_widget.dart implements real play/pause audio control with no seek/scrub/replay (E1-S4); pillar_chat_page.dart implements a real tap-and-hold record-transcribe flow, but it's generic per-pillar chat input with no 5-minute cap and no debrief/meeting scoping (E4-S1, E4 epic_note); and memory_search_screen.dart plus the per-pillar chat box are two disconnected, non-prominent query surfaces rather than the single global query bar the spec calls for (E3-S4). Each upgraded a story from NOT_STARTED to PARTIAL without closing the actual gap.

9
“Publish without wiring the consumer” is a repeated defect shape across two independent epics, not a one-off.

E19: `SubscriptionService._transition` publishes `SUBSCRIPTION_DOWNGRADED` (`eva_domain/subscription/service.py:165`) but `EVA_Bureau/src/eva_bureau/consumers.py` has zero subscription/downgrade references — no consumer exists anywhere. E15: `ComplianceConsumer` is fully coded and unit-tested (`EVA_Bureau/tests/test_consumers.py:94-120`), but `consumers.py:311-335`'s `start_consumers()` never instantiates it — so `propagate()`/`FORGET_COMPLETED`/`FORGET_SLA_VIOLATED` never fire in production, identical net behavior to the pre-session baseline despite all supporting logic existing. Same root cause twice: engine + tests shipped, the one-line production-startup wiring step was skipped both times.

10
“Entry point built, zero production callers” is the load-bearing gap behind several PARTIAL upgrades.

E10-S2: persistence, reviewer actions, audit trail, and MCP tools are all genuinely wired end-to-end — but `resolve()` (`identity.py:43-76`) itself has no production caller anywhere, so the review queue the new tools expose can never populate from real data. E19-S1: `require_active_subscription` is real and tested but called from exactly one route (`brief_routes.py:114-115`), not any Editor/Cartographer/Arbiter surface — “lights up pillars” as a concept isn't built, only its primitive. Same pattern found independently in E13 (all 5 stories): correct, unit-tested domain logic with no route/MCP tool ever calling it.

11
Where wiring was completed end-to-end, it's genuinely solid and follows the codebase's own established patterns.

E16's calendar rebuild (599 lines vs. ~120-line prototype) mirrors the pre-existing `draft_manager`/`commitment_tracker` Supabase-switch pattern exactly (`wiring.py:376-393`), landing 6/10 stories at full DONE with 34 passing tests. E17's `CommitmentChaseEngine` explicitly models itself on `SentinelAlertManager`'s cooldown pattern (`commitment_chase.py:12-17`), with 21 passing tests. This is the strongest evidence the pass is disciplined engineering, not verdict-chasing.

12
A new cross-surface inconsistency was introduced this session, not just an old one narrowed.

E17-S1: `due_date` is accepted by the MCP handler (`registry/keeper.py:91-115`) but `CreateCommitmentRequest` on the REST route (`keeper_routes.py:174-198`) has no `due_date` field at all — an HTTP caller cannot set a deadline at creation, only MCP callers can. This divergence didn't exist before `due_date` was added; it's a net-new risk from this build pass, not a pre-existing gap.

13
This session's “done” bar for delivery/notification stories is self-acknowledged internal-only, which inflates some verdicts relative to what a principal would actually observe.

`commitment_chase.py:12` explicitly docstrings “no real push/email” for chase reminders (E17-S3, graded PARTIAL). E15-S2's privileged-channel access control (`audit_query.py` `CounselRegistry`) is an env-var allowlist stub, not per-caller DB roles — also self-documented as such. Graded correctly against the codebase's existing bar, but Phase 2 planning should not read “DONE”/“PARTIAL” here as “a human receives a notification.”

14
Flutter frontend absence is now a fixed, cross-epic constraint, confirmed independently across this session's audits, not a stale finding being carried forward.

Every UI-facing story (E16-S1 re-auth UX, E10-S1 confirmation-prompt UI, etc.) is blocked on the frontend repo not existing on disk at all — not on backend incompleteness. Any Phase 2 sequencing that treats these as “blocked on backend, unblock next” is mis-scoped.

15
A follow-up Phase 1.5 wiring pass closed the exact “publish without wiring the consumer” gaps finding #1 flagged, confirming the diagnosis was correct.

ComplianceConsumer and a new SubscriptionConsumer are now both instantiated in `consumers.py`'s `start_consumers()` (previously missing entirely) — E15-S3/S4 (forget auto-propagate) and E19-S2 (credential revocation on downgrade) moved from PARTIAL/NOT_STARTED to DONE as a direct result. Three real endpoints (`POST /keeper/memory/search`, `GET /keeper/memory/facts/{subject}`, `eva_recall_facts`) that previously bypassed E13-S5's refusal check entirely now route through it — that story moved to DONE. `eva_resolve_identity` and `eva_open_hr_thread`/`eva_close_hr_thread` give resolve() and HR-thread state their first real callers outside tests (E10-S2 now DONE, E13-S1 evidence improved though still blocked on the scheduler hardcoding zone=GENERAL). Net: 5 verdict upgrades to DONE, zero epics fully closed — remaining gaps in E13/E19 are real feature work (scheduler zone-awareness, production-path Supabase store parity), not more wiring.

16
The Phase 2 wiring pass moved 8 stories out of NOT_STARTED and 1 from PARTIAL to DONE across all 5 targeted epics, but every remaining story that spans both a backend and a Flutter surface stayed capped at PARTIAL because the frontend half is genuinely untouched.

Pattern repeats across E1 (persona/zone-prosody/hedging/EOD-budget all real+tested now, but E1-S2/S4/S5 stay PARTIAL because the fallback-path-only or frontend-absent caveats survive), E2 (device-context and batch-undo went from zero backend callers to fully wired+tested REST routes — E2-S4/S8 evidence rewritten, verdict held at PARTIAL solely because no Flutter UI drives them), E3 (the scheduler now genuinely auto-fires 30 minutes before a meeting and populates a real draft — E3-S1 stays PARTIAL because "there is nowhere for the user to see it" is still literally true), and E5/E18 (whistleblower non-demotability and Scribe's edit lifecycle are the two cleanest fixes — a documented bug closed exactly as described, and a documented missing method now exists — landing at DONE and PARTIAL->DONE respectively). One correction along the way: this pass's own indentation mistake silently turned half of CalendarOrchestrator's methods into nested functions inside a newly-added module function, caught immediately by the resulting test failures rather than by review.

17
The Phase 3 wiring pass closed the exact "dead code, zero production call sites" gaps E6-S5/E14-S1/E14-S3 all independently flagged for the same underlying mechanism (CalibrationPushService), because they were the same bug described from three epics' worth of story angles.

CalibrationConsumer now subscribes to CALIBRATION_UPDATED and calls schedule_push() (previously nothing subscribed to that event at all); a new _calibration_fast_track callback is registered via register_callback() in EvaScheduler.configure() (previously zero production call sites, confirmed independently by three separate prior-audit grep passes under three different epics) and re-scores relevance/drift for just the triggering principal within the debounce window — proven end-to-end with the debounce shrunk to 0.01s rather than trusting the pieces in isolation. Net: E6-S5 and E14-S3 both moved NOT_STARTED→DONE, E14-S1 and E4-S2/E4-S3 moved toward PARTIAL with substantially rewritten evidence. Separately, E7's SupabaseRelationshipStore — the store actually used in production — was rewritten to delegate to the real RelationalGraphService instead of independently reimplementing it, which didn't flip any E7 verdict but collapsed two independently-drifting buggy implementations (the cadence/tone max() bug, the missing 4-layer Confidence Architecture, the never_flag reload bug) into one, fixing the never_flag bug outright as a byproduct and leaving the remaining known bugs fixable in exactly one place going forward. Zero epics fully closed — E4/E6/E7/E14 all still have real, unaddressed frontend and product-decision gaps the evidence spells out story by story.

18
The Phase 4 pass moved all 3 E8 stories from NOT_STARTED to PARTIAL by building the literal backend mechanisms each story names, while correctly not claiming the harder prerequisite (a real shared brief-variant abstraction) that was never in scope for a P3 pass.

BriefType.MEMO now exists and is accepted end-to-end (E8-S1); a new eva_memo_inline_query MCP tool answers free-text questions against a memo draft via a shared eva_domain.keeper.memo_query.answer_query(), the same function E3's chat query-bar now calls too — one implementation instead of two independently-drifting copies (E8-S2); VoiceDeliveryBridge.to_ssml() intercepts PRIVILEGED/MNPI/HR memo sections outright and a new memo_citation_sections() helper exposes exactly what to render as a citation cue card (E8-S3). None reached DONE: E8-S1's actual ask — one shared composition path across voice/cue-card/memo instead of the duplicated paths E2-S1/E12-S5 already documented — was correctly left untouched rather than faked with a bigger refactor out of proportion for this pass; and all 3 stories are backend-only, since no memo-reading UI exists in Flutter to make any of this reachable by a principal.

19
E9 and E12 were both P0 epics no phase in ROADMAP.md had ever assigned — a gap in the roadmap's own sequencing, not a stale audit — and closing it surfaced one row (E9-S4) that was already fixed by a fix landed for a different epic weeks earlier.

E9-S4's "propagate() never invoked in production" verdict was never re-checked against the Phase-1.5 ComplianceConsumer fix (built for E15-S3) because E9 was never in scope for any prior pass — reading consumers.py directly confirmed the exact same epic-agnostic consumer already made this true, so the fix was free: a documentation correction, not new code. Where real gaps existed, they closed cleanly: E9-S1's hash-chaining was unified into one shared, tested algorithm (eva_contracts.contracts.audit.hash_audit_event/verify_hash_chain) used by both InMemoryAuditSink and the production SupabaseAuditSink, which now actually populates the prev_hash/this_hash columns migrations/010 always had; E9-S3's correct_fact/retract_fact is a genuinely new mechanism built into both the in-memory and production (Neo4j) graph stores; E12-S6's "implemented backwards" bug (interrupt_locked silently re-locking with no signal anything changed) is fixed in both LivingDraftManager and SupabaseDraftStore, the latter gaining its first-ever test file in the process. E12-S3 shows the limits of a bounded pass honestly: the queue's producer side (push) is now real and wired end-to-end, but its consumer side (pop_next) still has zero callers, so nothing yet turns a queued P0 into an actual real-time re-delivery — evidence was rewritten to say exactly that rather than rounding up to DONE.

20
Asked to close out E1 entirely, checking feasibility first surfaced that 4 of 6 remaining stories are hard-blocked by dependencies outside this pass's control — the Flutter frontend repo and the deliberately-deferred Integrity Gate v2 — not just unstarted work.

S4/S6 need a Flutter UI (scrub controls, onboarding tone-capture) and the Flutter frontend repo was confirmed to no longer exist anywhere on this machine at all (both previously-referenced paths checked directly) — a stronger, more definitive absence than earlier sessions' per-epic "not found at this path" notes. S2/S7 need the primary, most-used brief delivery path (OpenClaw-authored free-form prose) to carry structured per-fact confidence to hedge against or restructure — exactly the gap the Integrity Gate v2 design (proposed, not built, explicitly deferred by the user this session) exists to close; building it now to unblock two E1 stories would be solving the wrong problem in the wrong order. Only S1 and S5 had a real, bounded gap: S1 gained a genuine T-30 pre-warm for pre-meeting briefs (populate_pre_meeting_draft already builds that draft's content deterministically ahead of time, so there was finally something real to warm) plus a content-keyed cache so the warm-up isn't wasted work, while the primary morning-brief path is untouched since OpenClaw builds and delivers its draft in one T-0 job with nothing to pre-warm from; S5 gained the specific "adaptive last-meeting-anchor" logic its own gap text named, word-budgeted like every other line. Neither reaches DONE — both were narrowed exactly as far as a bounded, dependency-free pass allows, and no more.

21
Both of finding #20's hard blockers were lifted in the very next pass — the user re-added the Flutter frontend and approved building Integrity Gate v2's Phase 2 "flip" — and four of E1's five remaining PARTIAL stories closed to DONE as a direct result, the fastest verdict movement of any epic this session.

S4/S5/S6 needed a Flutter surface that didn't exist a few turns earlier; once it was back on disk, each closed with real, tested, wired code (scrub/replay via drag-to-seek plus a word-count-estimated section-replay button for S4; a full AudioPlayer in EodBriefScreen for S5; a new onboarding route + screen for S6, the first real caller anywhere of VoiceProfileManager.update_from_correction). S7 closed because the Phase 2 flip's biggest structural change — eva_submit_brief_plan actually rendering an ACCEPTED plan via BriefComposer into the real delivered draft, instead of computing a shadow-mode verdict nobody acted on — gave the hedging logic real per-fact confidence to work with on the path that actually reaches a principal. S2 is the one honest holdout: the flip gave it a real, enforced ~90s word budget on the primary path (closing that half outright), but BriefComposer structures briefs by the older fixed THE_DAY/WORLD/RELATIONS/HOLDING categories, not the "setup counts → top-3 ranked signals" narrative shape this story's evidence has named since Phase 2 — that specific structure is still confined to the rarely-hit fallback text builder, and this pass didn't touch it. Building the flip also surfaced and fixed a real, independent, pre-existing bug unrelated to E1 itself: EditorService.auto_lock_pending() read BriefDraft fields (last_updated_at/created_at) that don't exist on the real contract, so the T-30 auto-lock sweep never actually fired in production — only a test's hand-written fake draft (which happened to define those nonexistent fields) made it look like it worked.

22
Asked to complete E2 entirely, a single shared-ranking refactor (E2-S1) turned out to be the load-bearing fix six other stories could build on, and the pass honestly stopped short of 9/9 on the two stories with genuine external dependencies rather than padding them to DONE.

Extracting rank_top_signals() out of _build_speech_text into eva_domain so build_cue_cards() could call the identical function wasn't just S1's fix — it's also what made S2's already-heard demotion and S6's world_signal/drift card types possible, since both needed a card that traceably corresponds to something the voice brief actually said. Every one of E2's 9 stories was touched this pass (unusual breadth for a single epic pass this session); 7 reached DONE. The 2 that stayed PARTIAL were stopped for named, external reasons rather than scope-narrowed silently: S6's pattern card type needs EvaluatorService to expose an enumerable "current scenarios" store, which is E6-scoped work, not E2's, and legacy-surface consolidation (MorningBriefWidget/AlertBanner/DraftReviewScreen unifying into the new grammar) was correctly judged too large a redesign to absorb un-asked; S9's WCAG/contrast claim was left open specifically because no accessibility-audit tooling was run — adding Semantics labels is real progress but isn't the same thing as verifying compliance, and the audit says so rather than rounding up. A real side effect: wiring CorrespondentAgent.list_draft_replies_for_principal() for S6's draft card type gave E5's queue_for_principal() its first real external caller, a gap finding #7 named explicitly (“exposed via no route or MCP tool anywhere”) — though the queue is now reachable via cue-cards only, not via a dedicated E5 route, so E5's own verdict is unaffected.

23
E3 is the first epic this session to close all of its stories to DONE in a single pass — and the pattern behind it is that every remaining gap was independently, concretely fixable without a cross-epic dependency.

Unlike E2 (blocked 2/9 on E6/accessibility-audit dependencies) or E1 (blocked on the Flutter repo's existence and Integrity Gate v2), none of E3's 5 gaps needed anything outside this pass's control: S1 needed one new read-only route over data that already existed (LivingDraftManager); S2 needed one Citation object built from fields the event envelope already carried, in the one automatic write path, plus not dropping a field in one response dict; S3 needed one shared helper called from three already-existing consumers; S4 needed one route that skips a keyword check plus one floating button; S5 needed one early-return guard checked in two places instead of one place with a spoofable parameter. None of these required new subsystems, new external services, or product decisions — which is also why this pass could close all 5 without narrowing any of them, unlike every prior "complete this epic" pass this session. One genuine design tension surfaced and was resolved conservatively: S5's fix could have generalized memo.py's per-section PRIVILEGED/MNPI voice interception to every brief type, but an existing, deliberately-scoped, currently-passing test (test_non_memo_brief_does_not_intercept_privileged_section) explicitly documents that as E8-S3's memo-specific behavior, not a general rule — so S5 was closed via a separate, additive whole-draft-scope check instead of touching that test's protected behavior.

24
E4 is the second consecutive epic this session to close all of its stories in one pass, and the reason is structural: all 4 gaps were facets of one missing state machine, not 4 independent problems.

PostMeetingDebriefService.create_debrief() (Phase 3) was real but fired synchronously end-to-end with nothing in between "audio arrives" and "commitments exist" — no capture surface (S1's gap), no way to hear or correct what was extracted before it became permanent (S2's gap), no gate requiring explicit confirmation before it committed (S3's gap), and no way to back out (S4's gap). Introducing exactly one new concept — a PENDING/CONFIRMED/CANCELLED DebriefCapture sitting between transcription and create_debrief() — closed all 4 simultaneously, the same shape finding #23 described for E3 (S1's rank_top_signals extraction) and finding #22 for E2 (S1's shared-ranking refactor): asking to complete an epic entirely surfaces these load-bearing single fixes more reliably than asking for one story at a time, because the dependency between stories is invisible until you're forced to look at all of them together. One honest limit disclosed rather than papered over: S4's "mandatory read-back" is enforced structurally (confirm requires a capture that only exists after the capture step) rather than by literally proving a client played the read-back audio — the latter is not something a backend can verify, and the audit says so instead of overclaiming enforcement that doesn't exist.

25
E5 is the third consecutive epic closed entirely in one pass and the largest (8 stories) — but unlike E2/E3/E4, its stories weren't all facets of one missing state machine; each needed a genuinely different real fix, and two decisions (S1's scorer redesign, S6's provider-trust architecture) were reached by explicitly rejecting a more obvious approach that turned out to be hollow or risky.

S1's first design used sender_tier as the CoVe verifier's independent signal — tracing it through select_action()'s existing branches showed every sender_tier this verifier would flag (board/regulatory/legal/exec) already triggers ESCALATE directly, making the check permanently unreachable dead code before it shipped. Caught by writing the test first and watching it fail to distinguish behavior, not by review. Rebuilt around deal-vocabulary-in-subject instead — genuinely independent of the existing branches, verified by tracing the same reachability question through to a passing, meaningful test. S6 could have reimplemented DKIM/SPF crypto+DNS verification directly (a security-sensitive judgment call this pass made deliberately conservatively, consistent with the prior audit's own "no unreviewed crypto dependency" caution) — instead it trusts the receiving mail provider's own Authentication-Results header, the same architecture virtually every production email security system uses, closing the story without adding an unreviewed dependency. S7's fix (principal_initiated) only became reachable in practice because S4's dictation flow exists to set the flag unconditionally — a cross-story dependency discovered by building S4 after S7's design was already drafted, requiring no rework only because S7 was designed as an additive permission (a flag that widens what's allowed) rather than a replacement of the existing draftable-actions check.

26
E6 is the fourth consecutive epic closed entirely in one pass, and the largest by story count (12) — but its true shape was 10 independent bugs across 8 different files, not a repeated pattern, and two of them were latent bugs discovered only while fixing something else.

S1's confirmed-fixed 4h-crawl regression (finding #6) meant this story's actual remaining work was building a genuinely separate fast path, not a bug fix. S4's real finding wasn't "the queue needs a consumer" — it was that no concurrent/cancellable task concept existed anywhere in the voice-synthesis layer at all, so a P0 arriving mid-render of a lower-priority brief had no way to actually jump ahead of it; fixed with a real `asyncio.Task` + `.cancel()`, proven with a slow fake adapter genuinely blocked and cancelled, not a timing assumption. Two bugs were found only as a side effect of implementing something else: `SupabaseSentinelStore` was missing `suppression_counts()` entirely (would 500 in production, found while wiring S9's principal-facing rollup) and `AlertStatus.ACKNOWLEDGED` had existed as a dead enum member since before this session (found while designing S10's demotion sweep, which needed a real ack concept to exclude acknowledged alerts from demotion). S12 closed the exact class-collision finding #3 named in the very first audit pass of this session — evaluator.py's directive `ScenarioAnalysis` colliding with relevance.py's correct advisory-only one — by renaming and rewriting rather than deleting either, since both serve genuinely different real call sites (per-event scoring vs. free-text scenario description).

27
E7 is the fifth consecutive epic closed entirely in one pass, and the only one where the central defect was codified by a passing test — fixing the bug correctly required rewriting the test that proved it worked, not just adding new assertions around it.

`test_relational_graph_detects_tone_driven_drift` existed specifically to prove tone alone (cadence_score=0, perfectly on cadence) could fire drift with `tone_drift=0.85` — exactly the "implemented backwards" behavior the spec forbids, and exactly what finding #3's original audit pass flagged. There was no way to fix S2 without that specific test starting to fail; the correct response was recognizing the test encoded the bug, not treating a red test as a regression to avoid. It was replaced with a test asserting the opposite (tone alone never drifts on normal cadence) plus a new test proving tone still genuinely corroborates an already-elevated signal — the fix is more restrictive than the old behavior, not merely different. S3 and S5 both compose directly on top of S2's `tone_corroborates` boolean (S3 feeds it a real signal instead of a caller-supplied scalar; S5 forces it permanently off for counsel relationships) — a design that would not have been available had S2 stayed a `max()` calculation with no clean corroboration/no-corroboration branch to hook into. S4 deliberately kept a new corroboration method separate from the existing `pinned_ghosts()` rather than modifying it in place, the same non-breaking-additive-surface pattern E6-S9's rollup and E6-S6's health report both used — proving out as a repeatable convention across epics, not a one-off choice.

28
E8 is the sixth consecutive epic closed entirely in one pass, and the first where re-reading the codebase directly overturned a prior audit's own pessimistic note rather than confirming it — the "harder prerequisite" the Phase 4 pass declined to build as out of scope turned out to already exist by the time this pass looked again.

Phase 4's note said E8-S1's real ask — a shared brief-variant composition path — "still doesn't exist" and framed closing it as a re-architecture out of proportion for a P3 pass. Direct re-reading (not trusting the note) found that by Phase 15, a memo already flowed through the exact same generic open/add_section/deliver/to_ssml machinery every other non-tier-1 brief type uses — the prerequisite had been satisfied as a side effect of unrelated work in later phases, never re-checked against E8 specifically because E8 was never revisited. What remained wasn't architectural at all: `AddSectionRequest` (the actual HTTP contract a Flutter client uses) simply had no `zone` field, so S3's already-correct backend interception logic was unreachable end-to-end for a reason no amount of re-reading `voice.py` in isolation would surface — it required reading the request/response contract at the boundary. This is the same class of finding as finding #4 (Flutter wired to the wrong JSON contract) but inverted: here the backend was right and the HTTP surface silently dropped a field, rather than the frontend expecting fields the backend never sent. A second, independently-found bug (GET /brief/{draft_id}/audio's on-demand fallback synthesizing the wrong brief's content for any non-tier-1 draft with no manifest) would have caused a real, silent, hard-to-diagnose production bug — playing a memo out loud would have played someone's morning brief instead — had this pass not read that route's fallback branch specifically while building the memo player.

29
A story's own PARTIAL verdict cited a bug as its blocking gap that a completely different epic's pass had already fixed weeks earlier — and a P0 destructive-recovery mechanism sat fully built with zero callers for the same reason finding #2's pattern keeps recurring.

E9-S2's evidence text explicitly named “the MCP tool path the agent actually uses still strips citations” as the blocking gap. Reading `_handle_recall_facts` directly found a comment reading “FR-E3-S2” sitting on the exact line that returns citations — the fix had already landed as part of closing E3-S2, in a pass that had no reason to think of itself as touching E9 at all, and no later pass ever re-checked E9's own verdict against it. This is the same stale-audit-note pattern finding #19 documented for E9-S4 (fixed by an unrelated E15-S3 consumer), now recurring a second time within the same epic. Separately, `RecoveryCoordinator.restore()` (compliance/recovery.py) had a complete, tested snapshot/restore/replay_since implementation with zero callers anywhere in the codebase before this pass — the exact graveyard shape finding #2 first named (a real mechanism, fully built, simply never wired to anything that invokes it). Rather than exposing it as a bare MCP tool reachable by any principal — wrong for a destructive DR action — it was wired as a new caller inside the pre-existing password-gated `/admin` console, matching the console's own established pattern for every other irreversible action it already gates. The through-line across all three: closing a P0 epic correctly required distrusting every existing verdict enough to re-derive it from current code, not just from the story's own prior note.

30
E10 broke this session's pattern of "re-reading fixes a mostly-already-built epic" — 4 of 5 stories were genuinely NOT STARTED, and the acceptance criteria the audit's own gap notes summarized turned out to omit a specific taxonomy the sprint sheet spells out in full.

Every epic closed earlier this session (E6-E9) turned out to be substantially further along than its own audit note claimed once the real code was read directly. E10 inverted that: only S2 (low-confidence quarantine) was actually DONE; S1/S3/S4/S5 really were untouched, exactly as documented. But even here, re-reading paid off in a different way — the prior gap note for S1 said "sensitivity-category concept (board/investor/counsel/MNPI/family) was out of scope," a reasonable paraphrase, but pulling the literal EVA_Sprints.xlsx row text showed the acceptance criteria names five specific categories with exact spelled-out labels (counsel-and-legal, MNPI-touching, family-and-personal) that don't map cleanly onto the existing DataZone enum's three non-general values — a naive implementation reusing DataZone (the pattern this session used repeatedly for E7-S5's counsel gating) would have silently narrowed the story's actual scope. Reading the sprint sheet directly before designing, rather than trusting a summary of it, is what caught this. The same discipline applied to S3 ("exceeds a configured depth") surfaced that the story explicitly wants a *configurable* threshold, not a hardcoded one — missed entirely by a paraphrase that only said "brief-priority item when deep." Lesson for future epics: even a story correctly marked NOT STARTED can have its true acceptance criteria under-specified by an audit's own prose summary; the sprint sheet itself is the ground truth, not the note describing it.

31
Closing an epic sometimes means asking the user a real question before writing any code — E11 was blocked on a security/product tradeoff an earlier pass had already made and flagged as unresolved, not on a missing feature.

ROADMAP.md's own Phase 0.4 entry named this explicitly: an earlier pass (DEV-NOTES.md correction M) had deliberately ripped zone-based access control out of PolicyGate because zone was never an attested claim (self-declared per tool call, confirmed via a direct grep showing zero authentication of it anywhere) — "enforcing" it was a false guarantee, not real security. That directly regresses E11's own premise ("air-gap" privileged/MNPI data), and the roadmap flagged two live paths forward — formally accept the descope, or build real zone attestation and restore the check — as a decision needing to be made, not a bug needing a fix. Writing code first and picking a direction implicitly would have been exactly the kind of unilateral security-posture call the session's own operating rules exist to prevent; asked directly via AskUserQuestion instead. Once the decision landed (formally descope), the remaining work sorted cleanly into two buckets: S2/S4 are the crux of the decision itself and closed with zero code (writing a "solution" for either would have quietly re-introduced the exact unattested-zone theater the decision just rejected); S1/S3/S5 turned out to be genuinely separate concerns — encrypting opaque audio blobs, exposing an already-built audit-query service over HTTP, and surfacing an already-computed routing label — that had simply never been distinguished from the zone-enforcement question in the original audit's framing. Separating "what this decision resolves" from "what was merely sitting next to it, unbuilt" is what let 3 of 5 stories close with real code despite the epic's central mechanism being permanently retired.

32
A PRD clock concept (T-15/T-0) that doesn't exist as a real instant in this system's async-agent architecture — and the pattern of building the honest closable equivalent instead of a fictional timer, twice, in the same epic.

E12-S2 asks for logic keyed to "T-15" and "T-0," but morning briefs in this codebase are not rendered by middleware code running at a precise clock offset — scheduler._morning_brief_single only enqueues a job; an external LLM agent (OpenClaw) picks it up on its own heartbeat and calls eva_open_draft/eva_submit_brief_plan/eva_deliver_draft itself, at whatever wall-clock moment its heartbeat happens to land. There is no "T-15" instant the middleware could poll for even in principle. Building a naive T-15 clock-check job against this architecture would have been busywork that could never actually fire at the right moment. The honest equivalent: tie auto-injection to the real, observable condition the PRD language is actually gesturing at — "is a P0 event arriving while today's morning draft is still being finalized" — which holds regardless of when the agent's heartbeat happens to start assembling. E12-S3 hit the same shape of problem from the opposite direction: the literal ask ("routes via the real-time alert path (priority queue at the Renderer)") sounded like it demanded a working priority-queue consumer, but re-reading found the real-time render already happens synchronously elsewhere (sub-1s SLA-measured) — the queue push was vestigial. Building a queue-worker to satisfy the letter of the ask would have added latency and defeated the point; the honest fix was recognizing the literal ask was already met, and giving the unconsumed queue a real, narrower job (reliability retry) it didn't already have. Both cases share a lesson distinct from this session's usual "stale audit note" pattern: sometimes the sprint-sheet language itself describes a mechanism that doesn't map cleanly onto the actual system, and closing the story honestly means identifying the real underlying guarantee the language is reaching for, not force-fitting a literal implementation that would be theater, redundant, or both.

33
A real bug hid behind 100% test coverage of the wrong scenario — every HR-thread test constructed a relationship in the HR zone, so the suppression gate's dependence on that exact zone never got exercised against the case the story actually describes.

E13-S1 asks for suppression when "a contact has active HR-tagged correspondence" — the natural reading is an ordinary contact (a colleague, a report) who happens to have an HR matter open about them, not a contact who is permanently filed under some special "HR zone." But evaluate_drift's gate read `is_hr = scope.zone == HR or record.zone == HR; if is_hr and is_hr_thread_open(...)` — and every single test exercising HR-thread suppression, without exception, constructed its scope with zone=DataZone.HR. The tests passed, the coverage looked complete, and the prior audit pass's PARTIAL verdict cited only reachability gaps, never questioning whether the suppression logic itself was correct — because from the tests' vantage point, it always was. Reading the acceptance criteria's exact wording against the code's exact condition (not against what the tests exercised) is what surfaced it: for a GENERAL-zone contact — the realistic case — `is_hr` was always False, so `is_hr_thread_open()` was never even called, and the whole mechanism was dead on arrival for anyone not literally zone-tagged HR. This is a different failure mode than this session's recurring "stale audit note" pattern (a real fix elsewhere that a later pass never re-checked): here the code was wrong from the moment it shipped, and a self-consistent test suite that only ever tested one branch of a two-branch condition made it look verified. The fix — checking is_hr_thread_open() unconditionally — was a one-line change once found; finding it required distrusting green tests, not just stale prose.

34
The push channel's own payload already carried the data the pull was fetching redundantly — CALIBRATION_UPDATED's interest_tags field existed for one story (S3's fast-track re-scoring trigger) but nobody had connected it to the thing a completely different story (S1) said should stop pulling.

E14-S1's "never pulls" clause survived two prior passes unresolved because each pass looked at the pull site (_cartographer_crawl's calibration_service.snapshot() call) in isolation and asked "how do we stop this from firing," not "where would the data come from instead." The answer was already sitting in the event: CalibrationService._commit() has published the full interest_tags list on every CALIBRATION_UPDATED event since the Phase 3 pass that built S3's fast-track trigger — CalibrationConsumer.process() was already receiving that payload on every single calibration change, but only ever read event.payload['reason'] to call schedule_push(), discarding the tag data itself. Once noticed, the fix was small: a dict cache, written from the payload already in hand, read by the crawl job instead of calling back into the service. No new push mechanism needed building; an existing one just needed its data actually consumed. Separately, S2's "P0 topic" had no literal match anywhere in the codebase by that name — the honest resolution wasn't inventing a new concept but recognizing CorrespondentAgent's own docstring had already named its ESCALATE action step "P0 alert to principal" long before this epic existed, the same "identify the real underlying guarantee the language is reaching for" move finding #32 named for E12's T-15/priority-queue language, encountered here a third time on the same session's fourth calibration-related epic.

35
A three-phases-old audit finding stayed exactly correct the whole time — six named zero-constructor action types stayed genuinely zero-constructor across five intervening epics that each touched their own pillar for unrelated reasons.

E15-S1's Phase 1 evidence named six specific gaps by exact enum value: Correspondent's ACTION_LADDER_DECISION/THREAD_ROUTED/ALWAYS_ROUTE_FIRED, Arbiter's DRIFT_EVENT_FIRED/CONFIDANT_FLAG_FIRED, Renderer's BRIEF_PLAYBACK_PROGRESS. Between that finding being written and this pass, this same session rewrote large parts of correspondent.py (E5's CoVe/always-route work, E14's calibration trigger), relationships.py and confidant.py (E7's Golden Baseline wiring, E13's HR-thread fix, E14's audit_sink-adjacent scheduler work), and brief_routes.py (E2's cue-card routes, E12's provenance routes) — any of which could plausibly have added the missing audit calls as a side effect, the way E7's SupabaseRelationshipStore rewrite incidentally fixed E13-S1's evidence base before E13 itself was ever worked. A fresh repo-wide grep for `action=AuditAction.` on all six, run before writing any code, found zero hits on every one — the finding was still exactly true, unimproved by five epics' worth of adjacent work, because none of that work had reason to add audit logging as a side effect the way store-delegation rewrites do. The lesson this adds to findings #16/#17/#33's running list: re-verification isn't just for catching a finding that's gone STALE (already fixed elsewhere) — sometimes the honest re-check confirms nothing has changed, and that confirmation is itself the work, not a shortcut past it. Distinct from finding #34's "the fix was already half-built and unconnected" shape: here, nothing was half-built; six real gaps needed six real fixes, discovered fresh in this pass rather than inherited free from someone else's.

36
A pass-through property missing from one persistence wrapper silently broke a feature two epics later — E14-S2's calibration trigger worked in every test (in-memory) and in none of production (Supabase-backed), and nothing in either epic's own test suite could have caught it.

E14-S2 wired wiring.py to post-hoc attach calibration_service onto whatever calendar_orchestrator was in play, exactly mirroring the tone_engine/crawler/audit_sink pattern SupabaseCalendarStore already exposed for other collaborators — except calibration_service itself had no matching property on that class. Setting `calendar_orchestrator.calibration_service = x` against a SupabaseCalendarStore instance didn't raise (Python doesn't complain about setting a bare, undeclared instance attribute), it just created a new attribute the internal engine's propose_meeting never looks at, since propose_meeting checks self.calibration_service on the wrapped CalendarOrchestrator, not on the wrapper. Every E14 test constructed a bare CalendarOrchestrator directly (the in-memory dev/test path, where the pass-through gap doesn't exist because there's no wrapper) — none exercised the Supabase-backed path this exact scenario needed to catch it, so the feature was broken in every real deployment from the moment E14 shipped and stayed that way through the entirety of E15's audit-logging pass, discovered only because E16 happened to be deep in the same file for an unrelated reason (S9's timezone persistence). The fix — a three-line property/setter pair, the same shape already sitting three lines above it for tone_engine — took seconds once found; finding it took working in calendar_store.py for a different story and noticing the missing symmetry, not a targeted search for this specific class of bug.

37
A dedup design that's correct for a 1:1 origin link broke silently the moment it was wired to a 1:many call site — caught by the test suite failing, not by inspection.

E17-S8's source_event_id dedup (return the existing open commitment instead of minting a duplicate when create() is called twice with the same source_event_id) is the right model for a genuinely 1:1 origin — one email thread, one calendar event, one commitment. The first wiring attempt applied it to post_meeting.py's debrief action-item extraction too, using the meeting's own calendar_event_id as every extracted action item's source_event_id — but one meeting transcript routinely produces several distinct action items ("I'll send the deck," "Sarah will follow up with the vendor"), all sharing that same calendar_event_id. The second create() call didn't mint a second commitment; it silently returned the first one, collapsing every action item from a meeting into one. test_post_meeting.py's existing test_create_debrief_creates_real_commitments_in_tracker (asserting exactly 2 commitments from a 2-action-item transcript) caught this immediately on the first full-suite run — asserting 1 == 2. The fix was to not set source_event_id at that call site at all, rather than adding a special case to the dedup logic itself: S8's model is 1:1, debrief extraction is 1:many, and the honest resolution is recognizing the two don't compose, not forcing them to. This is a different failure mode from findings #33/#35's "stale audit note" or "code wrong since it shipped" shapes — here the new code and the new test were both written in the same session, by the same pass, and the test suite (not a targeted second look) is what surfaced the interaction the design review missed.

38
A service with a real, tested domain layer had zero non-agent surface for two epics running — Scribe's citation-review and MNPI-confirmation stories are principal-facing asks that literally could not reach a principal.

ScribeService (eva_domain/keeper/scribe.py) had a complete MCP tool surface (eva_scribe_create/update_section/finalize/archive/delete/get/list/review) but zero REST routes — every other Keeper concept with a principal-facing story (commitments, drafts) got REST routes somewhere in a prior phase; Scribe never did, because until E18 none of its stories explicitly required a human, as opposed to an OpenClaw agent, to act on a Scribe document. E18-S3 ("uncited claims are flagged for principal review before send") and E18-S6 ("the Scribe pauses and requires explicit principal confirmation per draft") changed that: both are asks about what a principal does, not what an agent does, and an MCP-tool-only surface has no principal on the other end of it — OpenClaw is the only caller. This wasn't a bug that shipped and went unnoticed (the MCP surface worked correctly the whole time, per E18-S2's prior DONE verdict) — it was a capability whose only reachable caller was structurally the wrong one for two of this epic's seven stories. Closing it meant building the first REST routes Scribe has ever had (GET/POST /keeper/scribe/documents and friends) before the Flutter screen consuming S3/S6's confirm gates could exist at all — the same shape as finding #2's "graveyard of features with zero production callers," except here the caller that existed (OpenClaw via MCP) was real and correct, just categorically unable to be the principal the story was written about.

39
E19's literal story text named two independently droppable pillars where the existing data model only had one whole-account switch — the honest fix was real new capability, not a reinterpretation of what already existed.

SubscriptionRecord/PlanTier (built in an earlier phase) modeled subscription state as a single trial/active/downgraded tier — sufficient for S1's demo clock and S2's credential revocation, both of which are whole-account concerns. But S3/S4/S6/S7's literal wording ("drops the Cartographer or Arbiter") and S5/S8's per-pillar retention/resume language describe two independently droppable pillars, not one kill switch — a downgrade that always drops both together cannot represent "Cartographer dropped, Arbiter still active," a state four of the remaining six stories require to even be tested. Rather than force-fitting the existing whole-account model (which would have made S3/S4/S6/S7's tests indistinguishable from S1/S2's), SubscriptionRecord gained real cartographer_active/arbiter_active fields and is_pillar_entitled(pillar), with drop_pillar()/reactivate_pillar() as the new per-pillar transition methods — reusing the whole-account downgrade()/reactivate() for the day-90 case (both flags flip together) and the new pair for independent pillar drops. One primitive, is_pillar_entitled(), is what every write route, both Redis Streams consumers, and both scheduled background jobs check — the same "one shared entitlement primitive reused everywhere" shape E18's event_bus/audit_sink/mcp_server wiring used to close S1/S7 together, applied here to close four stories (S3/S4/S6/S7) at once instead of one at a time.

40
A user-requested fix for E1-S1's SLA config turned out to target the wrong metric entirely — the honest response was to research first, name the real blocker, and get explicit sign-off on a narrowed scope rather than silently tightening a number that would have made the story's own signal meaningless.

voice_latency_sla_ms (default 4000ms, config.py:87-90) measures server-side TTS synthesis wall-clock time — the interval EdgeTTSAdapter.synthesize() spends on a real network call to Microsoft's edge service. E1-S1's literal ask ("audio begins within 200ms from edge cache" at tap) describes a categorically different measurement: tap-to-first-audio-byte from a pre-warmed, edge-served cache. Blindly lowering the SLA default toward 200ms would have made sla_breached fire on nearly every real synthesis call (EdgeTTS routinely takes over a second for real speech) without moving a single line of the actual tap-to-play path closer to the target — noise dressed as progress. Digging further surfaced a second, harder truth: no CDN/edge-cache provider exists anywhere in this codebase (exhaustive grep for cloudfront/cdn/edge_cache/signed_url: zero matches), and building one needs real external cloud infra/credentials outside any pass's reach without the user explicitly provisioning it. Surfaced both findings to the user via AskUserQuestion before writing any code, got explicit sign-off to build only the achievable subset (T-30 pre-synth for the morning/EOD path that never had it, despite pre-meeting briefs getting the equivalent fix in an earlier phase) and flag CDN provisioning as deferred infra rather than quietly declaring the story closer to done than it is. Same underlying discipline as finding #10's "entry point built, zero production callers" and finding #15's wiring-gap pattern — a plausible-sounding fix (change a config default) that doesn't touch the actual mechanism the story depends on is exactly the kind of shortcut this audit exists to catch, whether it's found in the codebase or almost written into it.

41
Closing E2-S6's last gap meant reversing a different phase's explicit, documented scope decision — surfaced to the user before overriding it, and the reversal turned out to need one small store, not the large one originally assumed.

E6-S12 (an earlier phase) touched the exact same evaluator.py/ScenarioAnalysis area E2-S6's PATTERN gap depends on, and explicitly declined to build a persistent "current scenarios" store, documenting that as deliberate E6-scoped future work (ROADMAP.md's Phase 13 entry, line 380 at the time). A fresh research pass confirmed that decision was still accurate against current code — nothing built across E6, E7, E12, E15, or E19 had changed it. Rather than unilaterally reversing a different phase's documented call, this was surfaced to the user via AskUserQuestion with the real tradeoff named (build the store now vs. leave PARTIAL); the user chose to build. What "the store" turned out to need was smaller than the original gap note implied: not a new store class or app.state entry, just two new dataclass fields (scenario_id/evaluated_at) and one bounded self-managed dict inside EvaluatorService itself — the same shape SentinelAlertManager already uses for its own per-principal state. The Flutter side needed nothing at all: CueCardsScreen's icon/label maps already had a 'pattern' entry from when the enum was first declared, waiting for real data that had never arrived. Net: a decision that looked like it required re-litigating another epic's scope turned out to be a same-pass, backend-only fix once actually scoped — but the discipline of asking first, rather than assuming the old gap note undersold the effort, was what made that assessment trustworthy rather than a guess.

42
Reading a third-party package's actual platform-channel source (not just its public API) turned an assumed gap into a documented non-issue — and computing real WCAG contrast ratios from the app's own color constants turned a vague "unaudited" note into a quantified, systemic, fixable defect.

E2-S9's "speed control without pitch distortion" requirement looked like it might need a setPitch() call to be added — instead, reading just_audio 0.9.46's Android (AudioPlayer.java's PlaybackParameters(speed, pitch)) and iOS (UriAudioSource.m's AVAudioTimePitchAlgorithmTimeDomain) implementations directly showed both platforms already decouple speed from pitch by construction, and the app never calls setPitch() at all — pitch stays at its unity default regardless of setSpeed(). Zero code needed; the honest close was documenting why, not adding an unnecessary call. The WCAG requirement went the opposite direction: "contrast unaudited" from the prior pass sounded like a documentation gap, but computing actual relative-luminance contrast ratios from EvaColors' real hex values against the backgrounds text actually renders on found a genuine, quantified failure (EvaColors.rule: 2.0-2.65:1, needs 4.5:1) that was also systemic — it was EvaText.mono()'s own default color, the style nearly every label in the app uses, not an isolated cue-card styling choice. Both findings only surfaced because the research went to primary sources (package source code, computed contrast math) instead of trusting the surface-level claim ("just_audio has setSpeed, so pitch might shift" / "no audit tooling ran, so contrast is unknown") — the same discipline finding #40 named for the SLA-config mixup, applied twice more in the same story with opposite outcomes: one gap closed by proving it was never real, the other closed by proving it was real and then fixing the actual mechanism.

43
E1-S2's real gap was worse than the standing evidence said — the primary voice path had no closing line at all — and closing it honestly required resolving a real tension with Integrity Gate v2's own coverage guarantee rather than just porting the deprecated fallback's shape over.

Finding #16 (Phase 2) had already noted E1-S2 stayed PARTIAL because "the setup/top-3-cross-domain-ranked-signals narrative... still exists only in the rarely-hit fallback builder" — accurate as far as it went, but reading voice.py's to_ssml() directly (the function every real synthesis call actually invokes, not compose_sections()'s persisted output) found something the evidence never named: to_ssml() has no closing line at all, and compose()'s opener/closer wrapper that would have provided one was test-only, never imported by production code. The literal fix wasn't as simple as copying the fallback builder's shape over, though: that builder's "top 3 signals" is content-selection — only 3 items get spoken, everything else is silently dropped — which would violate Integrity Gate v2's R2 rule that every accepted P0/P1 alert and due commitment must appear in the delivered brief. Porting the fallback's shape naively would have quietly regressed a guarantee a separate, earlier piece of this same system depends on. The resolution: read "surfaces three priority items" as a preview framing, not a content cap — a short, bounded top-3 spotlight wraps the full, unchanged categorized sections rather than replacing them. A first implementation attempt used the full rendered sentences for the preview and only caught its own budget-blowing failure mode via the existing test suite's deliberately-verbose fixture (test_over_budget_plan_drops_lowest_salience_items_first's 60-word summaries) — a concrete instance of this session's "run tests immediately, let a failure catch a design flaw before it ships" discipline working as intended, not just documentation after the fact.

44
Zone was one field doing two unrelated jobs — a storage/KMS/audit/routing architecture and a compliance classification value — and the fix was degenerating the infra functions to their zone-invariant behavior while leaving every compliance-reading line of code untouched.

User decided the zoning-as-architecture half (storage partitioning, KMS key derivation, audit-channel tagging, Cartographer private-surface routing, cross-zone backend switching) wasn't needed, but the compliance behaviors reading the same DataZone field — E3-S5's voice refusal, E5-S7's MNPI ladder, E13's HR/MNPI/PRIVILEGED masking, E18-S5/S6's privileged-doc/confirm/outline gates — had to keep working exactly as before. Reading relationship_store.py's initialize() found the actual partitioning mechanism was a single property (PrincipalScope.storage_namespace) and a handful of zone_namespace()-style key-builder functions, not the field itself scattered everywhere — fixing those few functions to drop the zone segment cascaded correctness to every in-memory-backed store automatically, with zero changes needed to the ~9 files whose compliance logic reads .zone directly to decide masking/refusal. This confirmed a design choice made before writing any code (keep the enum, degenerate the infra consumers, versus fully replacing DataZone with independent booleans everywhere) was the right one: same end-user compliance behavior either way, but the chosen approach touched roughly a third as many files. One genuine, disclosed side-effect survived: since the scheduler no longer varies its scan scope per zone (correctly — storage no longer partitions by zone, so one scan already reaches every record), fired relationship-drift events' own .zone metadata field now always reads GENERAL, reflecting the scan's scope rather than the record's real tag — the masking decision itself still reads the record's preserved zone correctly and produces the right masked summary, only that one metadata field lost per-record fidelity. Also reversed, deliberately: E11-S1's audio encryption-at-rest, E11-S3's audit-channel separation, and E11-S5's private-surface alert routing — all real, tested Phase-18 features, now intentionally degenerated rather than silently left to bit-rot, with 29 pre-existing tests rewritten (not deleted) to assert the new reality.

45
After story completion reached 118/1/2, a multi-round adversarial audit-and-fix stream hardened the transport / auth / persistence substrate the epics sit on. Every finding was a real, verified defect; none changed a story verdict.

Successive external audits (each independently re-running the full suite, ruff, and a dependency check) surfaced infrastructure and security defects in the layer beneath the stories — webhook authentication, event-bus reliability, the credential/OAuth lifecycle, and disaster-recovery restore. These were fixed in discrete rounds, with the full backend suite green (1,339 tests by the end) and ruff clean after every round. The story verdicts stayed 118 DONE / 1 PARTIAL / 2 NOT STARTED throughout precisely because the fixes changed HOW the existing DONE features are authenticated, transported, and persisted — not WHAT they do. Findings #46–60 record the substance. A recurring, honest pattern across the stream: several findings were shortcomings in a fix from the immediately preceding round (e.g. an allowlist made too broad, a retry counter keyed too coarsely), caught and corrected in the next round rather than defended — the audit loop working as intended.

46
A recurring "cache keyed one way, read another" namespace-mismatch class surfaced in two more stores after the de-zone fix (finding #44) hit the same shape.

GraphitiGraphMemoryStore wrote and dropped its local fact index under _episode_group(scope) (which replaces colons with underscores) but get_fact()/snapshot()/restore() read from the raw zone_namespace(scope) — different strings, so direct fact lookups returned nothing and snapshots came back empty even though writes succeeded. VoiceProfileManager.initialize() cached profiles under the raw principal_id while update_from_correction/list_versions/rollback keyed by scope.storage_namespace — so after a Supabase reload the version history read empty and the next correction reused version 1, colliding with migration 038's (principal_id, version) unique constraint. Both fixed by keying reads and writes identically; the same class as the de-zone storage_namespace collapse.

47
The OAuth / credential lifecycle had three independent correctness holes: refresh tokens lost on first connect and on DR restore, Slack credentials stored under a key nothing looked up, and OAuth state that was an unsigned bare principal id.

SupabaseCredentialBroker.register() never wrote refresh_token, and the OAuth callback PATCHed it onto a row that did not exist yet (a no-op on first connect) — so first-connect credentials became unrefreshable once the access token expired; restore() dropped it too. The Slack callback stored account_id=team.id while /oauth/status and the connector tools looked under "primary", so Slack always appeared disconnected. OAuth state was ctx.principal_id verbatim and unsigned, so a callback's authorization could be bound to any attacker-chosen principal. Fixed: refresh_token added to CredentialSecret and persisted in register()/restore() — omitted from the upsert when None so a refresh-cycle re-register preserves the stored value rather than nulling it; Slack account_id normalized to "primary" (team id moved to the webhook registry, not the credential lookup key); and OAuth state HMAC-signed and verified on callback, the same {payload}.{sig16} pattern later reused for webhook bindings (#50).

48
Bureau trusted the x-eva-principal-id header with no verification — the single root of ~5 findings (API-key issuance, GDPR erasure, the LLM proxy, EOD audio) — closed with a gateway-shared-secret defense-in-depth layer under the user's explicit "Bureau sits behind a trusted gateway" decision.

New GatewayAuthMiddleware requires x-eva-gateway-token to equal EVA_GATEWAY_SHARED_SECRET (constant-time compare) for every request outside a small public allowlist, covering both HTTP and WebSocket scopes, fail-open in dev, and flagged in production_readiness_issues(). Key issuance (/keys), GDPR erasure (/webhooks/forget), the nabh LLM proxy, and every principal-scoped route moved behind it. Additionally: forget was restricted to self-erasure unless the caller holds a compliance (counsel) role; the nabh proxy stopped reading os.getenv at import (reads settings per request) so it is no longer a blanket open relay spending the server's API key; and EOD audio picked up the same device-attestation gate /brief/{draft_id}/audio already enforced. The allowlist was deliberately over-corrected once and re-tightened in the next round: an initial /oauth/* entry exposed /authorize_url and /status (which read/sign the principal from the header) — narrowed to /oauth/*/callback only, since only the provider callbacks are hit externally.

49
The Redis Streams consumer and bus carried reliability bugs that would only surface under real load: dead-lettering on the first exception, a retry counter that collided across principals, no pickup of principals created after startup, and a replay boundary that double-counted.

PillarConsumer._handle dead-lettered on the first failure and never acked the original — contradicting its own _MAX_DELIVERIES = 3 docstring — so the pending entry was reclaimed and reprocessed indefinitely, writing a duplicate dead-letter each time; now it leaves the message unacked to be retried up to the budget, then dead-letters once AND acks. The retry counter was keyed by message_id alone, but Redis stream ids repeat per principal stream, so one principal's 1-0 could spend another's budget — now keyed by (stream, message_id). Consumers snapshotted the principal list at startup; the loop now drains the shared live list each pass (extracted as _drain_once for deterministic testing) and ensures the consumer group on first sight, with the admin create route appending the new principal. And RedisStreamsEventBus.replay used an inclusive XRANGE min=start while the in-memory bus was exclusive-after-start, so replay_since double-counted the event sitting exactly on the recovery-point boundary — made exclusive ((start) to match.

50
Provider webhooks resolved their target principal from a spoofable header (or "demo") with no resource→principal registry — anyone able to reach the endpoint could inject events into any principal's stream. Closed with provider-native signed-binding verification plus a subscription registry.

New HMAC-signed bindings (webhook_binding.py, the same signing pattern as OAuth state) are set as the Google channel token and the M365 clientState at watch-registration — a new register_watch() on the connectors that also records the provider's channel/subscription id in a resource→principal registry (webhook_subscription_store.py + migration 041, wired GDPR-erasable). The webhook routes now derive the principal from the verified provider signal — Google channel token, M365 clientState, Slack signing-secret HMAC plus a team→principal mapping recorded at OAuth connect — and never from the header, rejecting outright when it cannot be established. A dev fail-open remains only when no secret is configured; production always has jwt_secret, so it is always strict there. A follow-up audit hardened strict mode further to require BOTH a valid signed binding AND a registry match (so a leaked or guessed resource id alone can no longer attribute an event), and corrected the Supabase upsert conflict targets per identifier type with matching unique indexes. Because the routes now self-authenticate, the three provider-webhook paths are back in the gateway public allowlist (they are reached by providers directly); /webhooks/forget stays gated.

51
The Redis and Graphiti code paths were correct-by-inspection but never exercised against their real backends. Added genuine backend-path coverage, with an explicit line drawn around what stays mocked.

fakeredis (added to the dev dependency group) drives RedisStreamsEventBus's actual Redis calls — publish/read/ack, idempotency, and the exclusive-replay boundary (#49) — with reclaim skipped-with-reason if the fake lacks XAUTOCLAIM rather than silently dropped. GraphitiGraphMemoryStore gained an injectable-client seam so its write/get/snapshot/restore run against a fake async client, proving restore() now replays each fact as an episode into the graph backend, not only into the local index. Disclosed rather than glossed: the provider watch-registration HTTP (register_watch) is exercised only against mocked httpx, and true live Neo4j/Graphiti and a live Redis server still cannot run in this environment — the same standing caveat the S3/Vault encryption paths already carry.

52
Every registered MCP tool was reachable from OpenClaw, but the dispatch layer trusted the caller's arguments — empty or blank required fields were accepted and silently persisted as junk records. Added required-field enforcement ahead of any handler side effect.

A complete callability test drove all 85 registered tools through OpenClaw's own live MCP endpoint and API key. Every tool dispatched — but calling a write tool with {} (or a blank/whitespace required value) returned success and created a junk row, because the handlers read arguments with .get(...) defaults and no layer validated the schema's required list first: an empty eva_create_commitment persisted a blank commitment, eva_open_draft an empty draft, eva_scribe_create an “Untitled” document. Dispatch (EVA_MCP/src/eva_mcp/registry/dispatch.py, helper in registry/helpers.py) now rejects a tools/call whose arguments omit — or leave None/blank — any top-level inputSchema.required field, returning JSON-RPC -32602 (HTTP 400) after principal auth but before the policy gate, so a malformed call has zero side effect and writes no audit row. The check is deliberately stricter than JSON-Schema presence (whitespace-only counts as missing — that blank case was the junk source). Full backend suite green at 1,343 (24 existing missing-field tests re-pointed at the dispatch rejection, 5 new enforcement tests, 3 integrity tests corrected for the now-required field), ruff clean; deployed to the VPS and live-verified through OpenClaw. Verdicts unchanged — this hardens input validation at the tool boundary, not what any tool does.

53
Slack webhooks processed unsigned when no signing secret was set, and production readiness never required one — a “ready” production box accepted forged Slack events attributed to any principal via the spoofable header. Required the secret and made the route strict in production.

The Slack Events route verified signatures only when SLACK_SIGNING_SECRET was configured; unset meant no verification, and _scope_or_reject then fell back to the spoofable x-eva-principal-id header (or "demo"). production_readiness_issues() never checked the secret and the prod compose never passed it, so an otherwise-complete production deploy stayed “ready” while accepting header-attributed unsigned events. SLACK_SIGNING_SECRET is now a production readiness requirement when Slack is configured (config.py:217); /webhooks/slack (webhooks.py) rejects unsigned requests in production and is always strict there — an unresolved principal is a hard 401, never a header fallback. docker-compose.prod.yml now plumbs the secret. The readiness requirement is made fail-closed by #54.

54
Gateway auth fails open when its secret is unset, and readiness was only advisory — a production box missing the gateway secret served principal-scoped traffic for a spoofed header while /health/ready reported 503. Made production fail closed at startup.

GatewayAuthMiddleware allows every request when EVA_GATEWAY_SHARED_SECRET is unset (the intentional dev convention), and production_readiness_issues() was consulted only inside the /health/ready handler (main.py:131) — the app served regardless. So a production deploy without the secret answered /api/v1/keys 200 for a spoofed x-eva-principal-id while readiness returned 503. Root fix: create_app() now raises (EVA_Bureau/src/eva_bureau/main.py) whenever production readiness is unmet, so a missing gateway secret / JWT / Slack-signing / backend refuses to boot rather than run fail-open — fail-closed at startup, not merely reported. Staging keeps the deliberate fail-open (OpenClaw reaches the middleware directly, no gateway in front). docker-compose.prod.yml now hard-requires the secret (${EVA_GATEWAY_SHARED_SECRET:?}).

55
The public OAuth callback reflected the provider error query param into HTML unescaped — a reflected XSS on an unauthenticated, gateway-allowlisted route. HTML-escaped it.

oauth_routes.py's _error_page interpolated the provider-supplied error straight into the page, so /api/v1/oauth/google/callback?error=<script>…</script> returned the script verbatim (the callbacks are public by design). The message is now passed through html.escape. Live-verified on the VPS: the crafted callback returns &lt;script&gt; entities with no raw tag.

56
The admin console had stored XSS plus weak web-admin hardening: unescaped rendering of stored fields, a plain-equality password check, no login rate limit, and no CSRF token on destructive POSTs. All four fixed.

admin_routes.py rendered stored display_name, email, key labels, ids, and error text directly into HTML. It now HTML-escapes every interpolated value (stored XSS closed), compares the admin password with hmac.compare_digest (constant-time), throttles login per client IP (5 attempts / 5 min → 429), and embeds a per-session synchroniser CSRF token in every state-changing form, required on the matching POST — defence in depth behind the already-present SameSite=Lax session cookie. Admin-gated, so lower exploitability, but real.

57
The dev-token issuer minted a 24-hour attestation JWT for ANY principal and was blocked only in production — a live, unauthenticated any-principal token mint on the reachable staging box. Gated it off by default everywhere.

/api/v1/auth/dev-token (router.py) issued an attestation JWT for an arbitrary principal_id with no identity proof, refusing only when is_production — so on the staging VPS (EVA_ENV=staging, gateway-allowlisted) it was a free any-principal mint. It is now disabled by default everywhere and honoured only when EVA_ENABLE_DEV_TOKEN=true in a non-production env (config.py). Live-verified: ?principal_id=victim now returns 403 on staging. (It grants attestation only — MCP tool calls still require x-eva-api-key — but an any-principal mint had no business being reachable.)

58
Supabase REST filters are built by raw string interpolation of principal_id, which was validated as non-empty only — a PostgREST filter-injection surface from a spoofed/gateway-supplied id. Constrained the id at the one choke point.

SupabaseRestClient appends caller-built query strings to the URL unencoded (supabase_client.py), and stores interpolate principal_id into them (e.g. principal_id=eq.{id}), while PrincipalScope enforced only min_length=1. A principal id carrying PostgREST metacharacters (, & ( )) could therefore rewrite the filter. PrincipalScope.principal_id is now constrained to ^[A-Za-z0-9._-]+$ at its single validation point (eva-contracts/principal.py), which neutralises the injection class regardless of how the id arrives — and, since #54, a spoofed id also can't ride a fail-open gateway in production.

59
Cartographer source polling was an authenticated SSRF surface: user URLs were accepted with no scheme/host/IP allowlisting and fetched server-side with redirects enabled. Added a public-only URL guard and disabled redirects.

User-registered monitored-source URLs (eva_register_source) went unchecked into a server-side fetch (reporter.py) with follow_redirects=True — reachable at cloud metadata (169.254.169.254) and internal services. A new guard allows only public http(s): IP-literal and private/loopback/link-local/reserved/multicast targets are rejected at register and re-checked at fetch, with follow_redirects=False to remove the redirect-to-internal bypass; an unresolvable host is left to fail at fetch rather than hard-blocked (keeps the guard offline-testable, and DNS-rebinding is caught by the fetch-time re-check). Live-verified: registering http://169.254.169.254/… is refused with no side effect.

60
Upload routes read the full request body into memory before enforcing their size limit, so the cap could not prevent memory pressure. Replaced with a bounded streaming read across all four routes.

File-convert, STT, correspondent-dictation, and post-meeting-capture all did await file.read() (whole body) and only then checked the length. A shared bounded reader (EVA_Bureau/src/eva_bureau/uploads.py) now streams in fixed chunks and raises 413 the moment the cap is exceeded — never buffering more than the limit — wired into all four routes. Full security pass: backend suite 1,376 passed (33 new hardening tests), ruff clean, deployed to the VPS and live-verified (dev-token 403, OAuth escaped, SSRF blocked, MCP still authenticating the real principal). No story verdict changed — every fix hardens authentication / transport / input-validation, not what a feature does.

61
Integrity Gate v2's own calibration, prompt-echo, and staleness guarantees had three real gaps of their own — a hardcoded threshold nobody could tune per principal, a prompt-echo check a caller could spoof, and a snapshot with no expiry — all three closed and live-verified against production data.

New migrations/042_integrity_gate_hardening.sql adds gate_calibration (per-principal trust_floor/max_items_per_section/max_connectors_per_section), originating_prompts, and plan_snapshots.job_prompt. EVA_MCP/src/eva_mcp/registry/integrity.py's _handle_submit_brief_plan now: (1) loads a GateThresholds row via gate_calibration_store and feeds it into planner_gate.evaluate(), instead of always the hardcoded module defaults every principal shared regardless of what they'd been calibrated to; (2) checks R5's prompt-echo rule against snapshot.job_prompt — the real prompt EVA_Bureau/src/eva_bureau/routes/chat_routes.py's new _capture_originating_prompt stashes at /chat time and eva_open_draft freezes into the snapshot — not the caller-supplied job_prompt submit argument, which a malicious or buggy caller could set to whatever text it wanted checked against (now advisory-only, documented as such in SubmitPlanArgs); (3) rejects a submit whose snapshot is older than plan_snapshot_ttl_seconds (default 900s) with STALE_SNAPSHOT, forcing a re-open rather than gating a plan against a referent universe that may no longer reflect reality. A fourth candidate fix — tightening VoiceDeliveryBridge.to_ssml() to refuse an unstamped-but-content-bearing tier-1 draft — was deliberately reverted: it broke two tests, because an unstamped draft with content is structurally indistinguishable from legitimate pre-existing states (older fixtures, non-gate flows), and every production gated draft is already stamped-or-empty via the pre-existing add_section block plus DefaultPlanner's auto-stamp — the tightening added no real guarantee while breaking real ones. 7 new tests (test_integrity_gate_hardening.py), full backend suite 1,397 passed, ruff clean. Live-verified on the VPS against a real principal: a genuine /chat message traced through to plan_snapshots.job_prompt; a gate_calibration row with max_items_per_section=1 flipped a real verdict's rules_fired to include R6_BOUNDS; a snapshot backdated 2 hours past the TTL was rejected with STALE_SNAPSHOT on resubmit. No story verdict changed — this hardens the gate's own inputs, not what it evaluates.

62
A second, independent instance of finding #36's exact bug class: SupabaseCalendarStore was also missing the delegation for E16-S7's reveal_privileged_meeting.

Finding #36 documented SupabaseCalendarStore missing a calibration_service pass-through, so a post-hoc-attached collaborator the wrapped CalendarOrchestrator checked internally was silently unreachable through the Supabase wrapper in every production deployment. The same shape existed a second time for a different method: reveal_privileged_meeting(), added to CalendarOrchestrator by Phase 23 for E16-S7, had no matching delegation on SupabaseCalendarStore — callable against the in-memory dev/test path, unreachable against the production-backed store. Added the delegation plus a parity test asserting the Supabase store's behavior matches the base orchestrator's. Deployed to the VPS. No story verdict changed — E16-S7 was already graded DONE against the orchestrator method that did exist; this closes the gap between that method and the store actually wired to production.

63
GET /arbiter/relationships/{id}/drift on an unrecognized relationship id returned 500, not 404.

evaluate_drift's LookupError for an unknown relationship id propagated uncaught out of arbiter_routes.py's handler. Every other by-id GET route in this codebase translates an unknown-id lookup failure to 404; this one didn't. The route now catches LookupError and returns 404. Deployed to the VPS.

64
A full E2E sweep of every registered MCP tool — not a sample, all 86 — against a real dedicated throwaway principal, with database writes confirmed via server logs rather than trusted from tool responses alone.

Built a disposable e2e-smoketest principal through the real admin-console flow (not a direct DB seed) and called all 86 tools in dependency order (commitments → memory → relationships → drafts/Integrity Gate → calendar → cartographer → correspondent → scribe → identity → audit/compliance), 99 calls total. Result: zero transport failures, zero JSON-RPC dispatch errors, zero unhandled crashes across every tool — the 12 that returned a structured business error were confirmed to be incomplete test arguments, not real gaps, by retesting each with corrected arguments until all 86 succeeded. Critically, "the tool returned success" was not trusted as proof of persistence: cross-checked eva-mcp's own httpx request logs for the actual POST .../rest/v1/<table> calls and their 200/201 status codes underneath every write-shaped tool, the same discipline this session's Integrity Gate work already established for not trusting a green response at face value. This sweep is what surfaced findings #65–67.

65
eva_correct_fact and eva_retract_fact raised AttributeError for every real principal — the production Graphiti-backed memory store never implemented the method FR-E9-S3's own retraction mechanism depends on.

MemoryService.retract_fact() (eva_domain/keeper/memory.py:99) calls self.graph_store.retract_fact(...) unconditionally, and correct_fact() calls retract_fact() internally — so both tools shared one missing dependency. InMemoryGraphMemoryStore and DirectNeo4jGraphMemoryStore both implement retract_fact (close the fact's temporal window via valid_to, never delete, per FR-E9-S3), formally required by the GraphMemoryStore Protocol (eva_persistence/stores/graph.py) — but GraphitiGraphMemoryStore, the store actually wired to production, had no such method at all, confirmed by listing every method the class defines. Added it, mirroring the exact same semantics against the store's own fact-id-addressable index (Graphiti's add_episode/search surface exposes no per-episode update, so the index stays the sole source of truth here, the same authority split get_fact/facts_for_subject already rely on). 2 new tests (test_graphiti_store.py), full backend suite 1,408 passed, ruff clean. Live-verified on the VPS against a real principal: both eva_retract_fact and eva_correct_fact now return a clean result instead of an error.

66
eva_forget's erasure sweep silently skips Correspondent data entirely — a real "right to be forgotten" gap, not just an edge case.

Live-verified during the E2E sweep: ForgetEngine's per-store drop_scope() loop never includes correspondent_classifications, correspondent_decisions, or correspondent_draft_replies at all — confirmed by the actual erasure request log, which shows deletes against 16 other tables but none against these three. Worse, the one adjacent table it does try (deleting correspondent_classifications would have been a no-op fix at best) returned 409 Conflict from a foreign-key constraint (correspondent_decisions_classification_id_fkey) that the untouched correspondent_decisions rows still hold open — and that failure was silently swallowed by the same bare except Exception pattern drop_scope() uses elsewhere. Net effect: a principal's classified email threads, routing decisions, and draft replies survive eva_forget completely untouched. Not fixed this session — found live, not yet scoped.

67
A principal_subscriptions row survived eva_forget's own delete despite the request logging a clean 200 OK.

The erasure request log shows DELETE .../rest/v1/principal_subscriptions?principal_id=eq.e2e-smoketest returning 200 OK, yet the row was still present on the next direct query. Not yet explained — no FK conflict logged the way finding #66's did, so this isn't the same failure shape. Flagged as an open anomaly rather than diagnosed; needs a follow-up pass before trusting eva_forget's coverage of subscription state.

68
The admin console's audit-log viewer showed a blank Principal column and its principal filter matched nothing — it read audit_log's top-level principal_id column, which SupabaseAuditSink leaves intentionally NULL. The audit trail was being written correctly all along; only the console's reading of it was wrong.

Surfaced while verifying that a live 92-tool-call OpenClaw agent inbox-triage run had actually persisted to the database rather than just being narrated by the LLM: the run's 131 audit rows all showed a blank principal, which first looked like a write-side attribution bug. Traced instead to the reader. SupabaseAuditSink deliberately leaves the top-level principal_id FK column unset (its own docstring: NULL is valid for system events and avoids a principals lookup) and records the real identifier inside the JSONB payload — confirmed by querying payload->>principal_id, which carried ankit-dhir on all 131 rows. But the audit viewer and the health dashboard's "recent activity" (admin_detail_routes.py, this session's console revamp) both filtered and SELECTed the top-level column, so the Principal cell always rendered an em-dash and ?principal_id=… silently returned zero matches — exactly the "read a column that is structurally always NULL" trap the same code already sidestepped for payload->>allowed one field over. Fixed both call sites to payload->>principal_id (returned under the key principal_id by PostgREST's ->> embedding, so the display code is unchanged). Reworked the test fake to model the real shape (principal in the payload, top-level column None) so the test now genuinely guards the fix — reverting to the column makes it fail. Full backend suite 1,443 passed, ruff clean, committed 839ecce. Live-verified on the VPS: /admin/audit?principal_id=ankit-dhir now returns the rows with ankit-dhir populated in the Principal column (was 0 rows / blank before), and the principal + event_type filters compose correctly.

E1 — Voice Brief Generation and Delivery (7 stories, P0)

Phase 6 update (2026-07-10): user asked to close out all of E1. Checked feasibility first, not just started coding — 4 of the 6 remaining PARTIAL stories are hard-blocked, not merely unstarted. S4/S6 need a Flutter UI, and the Flutter frontend repo no longer exists anywhere on this machine at all (both previously-referenced paths checked and confirmed absent, a stronger finding than earlier sessions' "not found at this path"). S2/S7 need the primary OpenClaw-authored brief path to carry structured, per-fact confidence to hedge or restructure against — exactly what the (explicitly deferred, not yet built) Integrity Gate v2 composer would provide. Only S1 and S5 had a real, bounded gap closeable without either dependency; both were closed as far as that bound allows, no verdict reaches DONE. Phase 8 update (2026-07-11): both blockers lifted in the same pass — user re-added the Flutter frontend to disk, and Integrity Gate v2's Phase 2 "flip" landed (eva_add_section rejected for tier-1 drafts, eva_submit_brief_plan actually renders+stamps on ACCEPT, DefaultPlanner wired into the T-30 fallback sweep). S4, S5, S6, S7 move to DONE. S2 stays PARTIAL — the flip gave it a real, enforced word budget on the primary path, but the composer renders the older THE_DAY/WORLD/RELATIONS/HOLDING shape, not the "setup counts → top-3 ranked signals" structure this story's evidence has always centered on; that specific narrative shape is still fallback-path-only. S1 untouched this pass (still blocked on your CDN setup).

StoryVerdictEvidenceGap
E1-S1 pre-synth <200ms PARTIAL (up from PARTIAL — the morning/EOD path now gets a real T-30 pre-warm too; CDN/edge-cache remains a flagged, out-of-reach infra dependency) Phase 2 update: VoiceDeliveryBridge measures TTS synthesis wall-clock time, stamps latency_ms/sla_breached, compared against a configurable latency_sla_ms (default 4000ms) — migration 025_voice_latency.sql. Phase 6 update: pre-meeting briefs got a real T-30 pre-warm (_pre_meeting_trigger_job calling voice_bridge.pre_render_manifest()) with a content-keyed cache (_manifest_ssml) so the warm-up actually pays off at delivery instead of double-synthesizing. Phase 27 update: the morning/EOD gap that pre-warm never closed is now closed too — render_and_stamp() (eva_domain/keeper/integrity_render.py), the single convergence point for both the OpenClaw-accepted-plan path (eva_submit_brief_plan) and the DefaultPlanner T-30-idle fallback (EditorService._apply_default_planner_fallback), now takes an optional voice_bridge param and calls pre_render_manifest() with the freshly-stamped draft right after stamp_integrity() — one change closes both call sites, the way E19's is_pillar_entitled() closed four stories at once. Result dict gained voice_prewarmed: bool. Tested: EVA_Shared/tests/test_integrity_render.py (new, 3 tests — prewarms with the stamped not pre-stamp draft, no-ops without a voice_bridge, swallows a prewarm failure without losing the render), EVA_Shared/tests/test_editor.py's test_auto_lock_pending_default_planner_fallback_prewarms_voice_bridge (fallback call site, real EditorService.auto_lock_pending()), EVA_MCP/tests/test_integrity_gate_mcp.py's test_submit_brief_plan_accepted_prewarms_voice_bridge (accepted-plan call site, real eva_submit_brief_plan). Separately, brief_routes.py's _ranged_bytes_response Cache-Control changed from no-store to private, max-age=3600 — every byte on that route already passed per-request principal/zone/attestation checks a shared cache can't repeat, so private (device-local caching only) was the safe choice, not public. The SLA default (4000ms) still measures the wrong thing (server synthesis wall-clock, not tap-to-first-byte) relative to the <200ms target, and there is still no CDN/edge cache anywhere in the codebase — confirmed by exhaustive grep, zero matches for cloudfront/cdn/edge_cache/signed_url. Building a real one needs external cloud infra/credentials outside this pass's reach; flagged to the story owner rather than faked. The literal "from edge cache" wording of the story cannot be satisfied without it — what's achievable and now real is pre-synthesis ahead of tap (removes TTS latency from the critical path) for both brief types, proxied through the app server rather than served from a true CDN edge.
E1-S2 setup/3-signals/close ≤90s DONE (up from PARTIAL — the narrative shape is now real on the primary delivery path) Phase 30 update. Reading voice.py's to_ssml() directly (the actual spoken-audio path, not just compose_sections()'s persisted output) found the real gap was worse than the prior evidence stated: to_ssml() had no setup line, no top-3 selection, and no closing line at all — compose()'s opener/closer wrapper was test-only, never imported by integrity_render.py or voice.py. compose_sections() (eva_domain/keeper/composer.py) now returns Setup → Top Signals → THE_DAY/WORLD/RELATIONS/HOLDING (full detail, unchanged) → Close. Setup reports real fact counts by kind across the accepted plan. Top Signals is the 3 highest-salience items cross-domain, but as a short bounded preview (bare title/description per item, computed directly from plan+snapshot) rather than the full rendered sentence — an early version duplicating full sentences let 3 verbose items alone approach the entire 225-word budget, which would have forced near-total truncation of the real categorized content just to make room for a preview; the short-preview design avoids that while still satisfying the literal "surfaces three priority items" ask. This preview framing was deliberately chosen over content-truncation specifically to not regress Integrity Gate v2's R2 coverage guarantee (every accepted P0/P1 alert and due commitment must still appear in full) — nothing is dropped to make room for the spotlight. Close mentions the queued-open-commitment count then the sign-off line. All three fold their word cost into the same fixed_word_count the trim already used for opener/closer/ledes, so "total length ≤90s" now covers the whole shape. Golden-file test deliberately regenerated (TEMPLATE_PACK_VERSION v2-en→v3-en) per its own documented workflow for intentional template/shape changes. Tested: EVA_Shared/tests/test_composer_hedging_and_budget.py (+7 — empty-plan-renders-nothing including new sections, real cross-domain counts, top-3-by-salience-across-categories, short-preview-not-full-sentence, queued-commitment mention/omission, end-to-end budget compliance), 6 other tests across this file/test_integrity_gate_mcp.py/test_editor.py updated for the new section count/position (correct before this phase, now correctly reflecting 4 real sections instead of 1). None against the literal story text. Flutter needed no changes — MorningBriefWidget's section tabs are already fully generic (List.generate(sections.length, ...)), confirmed by reading the code rather than assumed.
E1-S3 SSML tone by sensitivity DONE Phase 2 update: EVA_Shared/eva-domain/src/eva_domain/keeper/voice.py's to_ssml() now branches per-section prosody on section.zone via a new _ZONE_PROSODY table (GENERAL=medium/+0%; PRIVILEGED/MNPI/HR=slow/-4%, signaling sensitivity through slower rate + lowered pitch) — DraftSection already carries a zone field (added in the prior Phase 1.5 E13 pass). This closes the original finding directly: sensitivity is modeled via the existing DataZone enum rather than a new dedicated field, but the SSML output now genuinely differs by content sensitivity where before it was one static template for every brief regardless of content. Tested: test_to_ssml_zone_prosody_signals_sensitivity. Tone varies along 2 dimensions (rate, pitch) rather than a richer emphasis/pacing model, and the sensitivity axis is DataZone rather than a purpose-built 'sensitivity' field — a narrower interpretation than the story may have originally intended, but the core requirement (SSML tone keyed by content sensitivity) is now real and tested.
E1-S4 interrupt/scrub/replay DONE (up from PARTIAL — the Flutter frontend is back on this machine and now has real scrub + replay, consuming the backend's Range support) Phase 2 update: PlaybackProgressTracker got its first real caller; GET /brief/{draft_id}/audio and /brief/eod/audio honor an HTTP Range header (206 Partial Content). Interrupt (mid-brief P0 override) already existed via editor.py's LOCKED-draft path. Phase 8 update: the user re-added EVA-Flutter-App-Frontend to disk. MorningBriefWidget's waveform gained real drag/tap-to-seek (calls _player.seek() using the fraction of the tapped/dragged x-position — the exact capability the Range endpoint existed to serve but nothing called), plus a replay-this-section button. Replay's per-section timing is a client-side estimate (each section's share of total word count against the already-known audio duration) rather than an exact backend-tracked boundary — no new backend contract was needed since the Flutter widget already has both the section text and the total duration once loaded. Backend scrub support and frontend now meet: dragging/tapping the waveform seeks in real audio, and the replay button jumps to an estimated section start. The estimate (word-count proportion, not exact timing) is the one honest gap left — a real per-section audio-boundary manifest from the backend would be more precise, but nothing currently tracks or needs it for the story's literal ask to work.
E1-S5 EOD ≤60s adaptive DONE (up from PARTIAL — EodBriefScreen now has a real, wired AudioPlayer) Phase 2 update: _build_eod_speech_text() hard-budgets the script to ~150 words; GET /brief/eod/audio streams real EdgeTTS audio. Phase 6 update: _last_meeting_anchor_line() closed the "adaptive" gap, counted toward the same word budget. Phase 8 update: EodBriefScreen (now back on disk) was converted from a stateless ConsumerWidget to a ConsumerStatefulWidget with a real AudioPlayer — play/pause, drag-to-seek progress bar, position/duration display — wired to a new downloadEodBriefAudio() calling GET /brief/eod/audio (needs only the principal-id header, no draft_id/attestation, since the EOD script is synthesized on demand rather than from a pre-rendered draft). End-to-end deliverable to a user for the first time: real backend script (budgeted + adaptive) plus a real frontend player consuming it. None found against this story's literal ask — adaptive, budgeted backend script and a working frontend voice player both exist and are connected. Same estimate-vs-exact caveat as E1-S4 doesn't apply here (EOD has no per-section replay ask).
E1-S6 persona consistency DONE (up from PARTIAL — a real onboarding route + Flutter screen now let a principal actually set what to_ssml/composer already consult) Phase 2 update: VoiceDeliveryBridge consults VoiceProfileManager for tone_register/signature_phrases on both the pre-render and fallback paths. Phase 8 update: new POST /brief/voice-profile/correction + GET /brief/voice-profile routes are the first real callers anywhere of VoiceProfileManager.update_from_correction() (previously read-only via get_profile — nothing ever wrote a profile outside tests). New PersonaOnboardingScreen (Settings > "Voice & Persona") lets a principal pick a tone register and submit a free-text sample of how they want to sound; the response echoes back the vocabulary/signature_phrases the manager derived from it. Tested: test_get_voice_profile_not_onboarded_by_default, test_submit_correction_onboards_and_persists_tone, test_submit_correction_requires_nonempty_text, test_submit_correction_requires_principal_header. Not localized (English-only copy) — a scope call given the size of this combined pass, not an oversight. None found against this story's literal ask. Minor, disclosed gap: the new onboarding screen's copy has no de/fr translation yet, unlike the rest of the app's l10n-covered surfaces.
E1-S7 verbal hedging DONE (up from PARTIAL — hedging now lives in BriefComposer, the primary delivery path, with real per-fact confidence to hedge against) Phase 2 update: _hedge() in brief_routes.py mapped confidence to hedge phrases, but only inside the fallback text builder — the primary path (OpenClaw-authored prose → to_ssml()) had no per-fact confidence value available at all. Phase 8 update: Integrity Gate v2's Phase 2 flip removes that structural blocker directly — BriefComposer's own _hedge() (composer.py, same ≥0.85/≥0.6 mapping) now applies to every ALERT/WORLD_EVENT line it renders, reading confidence/trust_score straight off the frozen PlanSnapshot (SnapshotFact gained a confidence field mirrored from Alert.confidence). Since composer output is what eva_submit_brief_plan (or DefaultPlanner's fallback) writes into the real delivered draft, hedging is no longer fallback-only — it's on the path that actually reaches a principal, unconditionally for any morning/end_of_day brief that reaches delivery (DefaultPlanner guarantees this even with zero OpenClaw cooperation). Tested: test_high_confidence_alert_gets_confirmed_hedge, test_low_confidence_alert_gets_possibly_hedge, test_alert_with_no_confidence_gets_no_hedge_prefix, test_world_event_hedges_off_trust_score; golden fixture regenerated (hedging changed real output — TEMPLATE_PACK_VERSION bumped to v2-en). None found against this story's literal ask — hedging is real, tested, and reachable on the primary delivery path via either an accepted plan or the DefaultPlanner fallback. No SSML-level emphasis markup (as opposed to spoken hedge words) was added, but the story's own evidence never named that as the gap.

E2 — Cue Card Surface and Cross-Format Continuity (9 stories, P0)

Phase 9 update (2026-07-11): user asked to complete E2 entirely. All 9 stories touched in one pass, both repos — new eva_domain.renderer.brief_signals.rank_top_signals() is now the single shared ranking function behind both _build_speech_text (voice) and build_cue_cards()'s new signal_cards array (S1); a new Flutter CueCardsScreen (Settings → "Cue Cards") is the first client surface to call GET /brief/cue-cards at all, rendering signal/meeting/draft cards through one shared _CardTile grammar (S6, CardType enum), confidence-tier badges (S5), citations/provenance footnotes (S3), already-heard demotion (S2), a Handled Today section (S7), multi-select batch dismiss + 60s undo (S4), and a manual voice-context picker feeding the newly-wired device-context 409 refusal on stream_audio()/eod_audio() (S8). Playback-speed control + Semantics labels added to MorningBriefWidget/EodBriefScreen (S9). 7 of 9 stories (S1/S2/S3/S4/S5/S7/S8) reach DONE. S6 and S9 are strongly upgraded but stay PARTIAL, disclosed per-row below, not padded to a false 9/9 — the old three card-like surfaces (MorningBriefWidget/AlertBanner/DraftReviewScreen) are unchanged, additive rather than consolidated, and no accessibility-audit tooling was run to back a WCAG claim.

StoryVerdictEvidenceGap
E2-S1 cards match voice brief DONE (up from PARTIAL — a shared ranking function now backs both the voice brief and the card surface) Phase 9 update: new eva_domain/renderer/brief_signals.py's rank_top_signals(alerts, ghosts, events) is the one place the alert/relationship/world-event priority ordering exists — EVA_Bureau/src/eva_bureau/routes/brief_routes.py's _build_speech_text now calls it instead of an inline candidate loop, and eva_domain/renderer/cue_cards.py's build_cue_cards() calls the identical function to build a new signal_cards array on GET /brief/cue-cards (limit=None, so cards show every ranked signal, not just the voice brief's spoken top-3, while ordering stays identical). Tested: EVA_Shared/tests/test_cue_cards.py::test_signal_cards_share_ranking_and_ordering_with_voice_brief. Frontend: new lib/features/briefing/presentation/screens/cue_cards_screen.dart is the first Flutter screen to call GET /brief/cue-cards at all, rendering signal_cards through a shared _CardTile. The pre-existing meeting-scoped cue_cards array (attendee/meeting-matched) is untouched and still a separate selection path from signal_cards — that specific meeting-vs-global distinction is a real, intentional scope difference (pre-meeting cue cards vs. daily-brief signals), not the gap this story named. The gap this story named — no shared source-item-set/ordering contract between what's spoken and what's shown as a card — is closed.
E2-S2 demote already-heard items DONE (up from PARTIAL — cue-card generation now reads the tracker) Phase 9 update: build_cue_cards() (eva_domain/renderer/cue_cards.py) takes optional playback_tracker+heard_draft_id; when playback_tracker.consumed_percent(draft_id=heard_draft_id) >= 95, every signal_card sourced from that brief is flagged already_heard=True and sorted after fresh cards (stable sort — never removed, only demoted). brief_routes.list_cue_cards looks up today's delivered morning-brief draft_id from the same "briefs" Supabase row today_brief() already reads, and passes it through. Tested: test_cue_cards.py::test_already_heard_cards_are_demoted_not_removed, ::test_not_heard_draft_leaves_cards_undemoted. Frontend: CueCardsScreen renders already_heard cards at reduced opacity via _CardTile. Granularity is draft-level, not per-item — PlaybackProgressTracker only ever recorded whole-draft consumption checkpoints, so "already heard" means "this card's whole brief was heard," not "this specific card's content was heard." Honestly coarser than true per-item tracking, disclosed rather than invented. The MCP tool path (eva_get_cue_cards) doesn't do the Supabase draft_id lookup brief_routes.py's HTTP route does, so demotion is real only on the HTTP surface the Flutter client actually calls.
E2-S3 provenance trace DONE (up from PARTIAL — both the composer path and eva_add_section now populate it) Phase 9 update: BriefComposer.compose_sections() (eva_domain/keeper/composer.py) now tracks the (kind, id) snapshot facts feeding every rendered line as source_refs, deduped in first-seen order per section; eva_domain/keeper/integrity_render.py's render_and_stamp() turns source_refs into DraftSection.source_event_ids (UUID-parseable refs) and .citations (every ref as "kind:id", including non-UUID ghost ids — never lossy). eva_add_section's MCP schema (catalogue.py) gained optional source_event_ids/citations inputs, parsed in _handle_add_section (drafts.py) for the non-gated brief types. Tested: test_composer_hedging_and_budget.py::test_compose_sections_carries_source_refs_for_provenance, test_integrity_gate_mcp.py::test_submit_brief_plan_render_stamps_provenance_on_draft_sections, test_mcp_routes.py::test_add_section_populates_provenance_fields. Frontend: signal_cards/draft_cards already carry citations (their source_id+kind is provenance by construction) and CueCardsScreen renders a "Source: ..." footnote per card. None found for the mechanism itself — both delivery paths (Integrity-Gate composer and legacy eva_add_section) now populate real provenance, tested at both layers, and a real UI surface renders it. The Keeper "draft" screen (correspondence reply drafts, a different draft concept per the epic note) still has no provenance fields — out of scope, unrelated contract.
E2-S4 batch actions + 60s undo DONE (up from PARTIAL — the one remaining gap was purely client-side, now closed) Phase 9 update: new CueCardsScreen (Flutter) gained a multi-select mode (checklist toggle, per-card checkbox via _CardTile's selecting/selected/onToggle), a "DISMISS" action bar calling the already-real POST /brief/batch (kind=dismiss_cues, item_ids=selected source ids), and a 60-second SnackBarAction("UNDO") calling POST /brief/batch/{batch_id}/undo — the SnackBar's own duration is set to 60s to match the backend's real undo window. Backend unchanged from Phase 2 (already fully tested: test_batch_action_create_then_undo, test_batch_action_invalid_kind_rejected, test_batch_undo_unknown_batch_returns_409). None found. Backend and frontend both real, tested (backend) / statically clean (frontend, flutter analyze). draft_review_screen.dart (the unrelated Keeper reply-draft screen) remains single-item only, which is correct — that screen was never this story's target.
E2-S5 confidence visual flag DONE (up from PARTIAL — a real tiered flag now exists on both sides) Phase 9 update: new confidence_tier() (eva_domain/renderer/cue_cards.py) maps a raw 0-1 confidence float to high (>=0.8) / medium (>=0.5) / low, applied to meeting cards' confidence_indicators.confidence_tier and to every signal_card's confidence_tier. Tested: test_cue_cards.py::test_confidence_tier_thresholds. Frontend: CueCardsScreen's _CardTile renders a colored HIGH/MEDIUM/LOW badge (green/orange/blue-grey) next to every card's type label. None found. The raw data_confidence/staleness_days/source_count fields are unchanged (still useful for a client that wants the underlying numbers), and the tiered flag now sits alongside them rather than replacing them.
E2-S6 consistent card grammar DONE (up from PARTIAL — all 5 card types now have real producers) Phase 28 update: closed the last gap, PATTERN. StrategicScenarioAnalysis (eva_domain/cartographer/evaluator.py) gained scenario_id/evaluated_at; EvaluatorService gained a bounded per-principal in-memory window (_scenarios_by_principal, cap 10, most-recent-first — same self-managed-dict shape SentinelAlertManager already uses) that evaluate_scenario() records into on every call, plus list_recent_scenarios() as the read half. build_cue_cards() (cue_cards.py) gained an evaluator_service param and a PATTERN producer appending into signal_cards alongside WORLD_SIGNAL/RELATIONSHIP_DRIFT (same field shape), with confidence set to the analysis's own highest-probability band — a real derived number. Wired into both real call sites: GET /brief/cue-cards (brief_routes.py) and the MCP eva_get_cue_cards tool (calendar.py), neither of which passed evaluator_service into build_cue_cards before this. Frontend needed zero changes, confirmed by reading the code: CueCardsScreen's _typeLabels/_typeIcons already carried a 'pattern' entry (Icons.insights_rounded) built ahead of time in an earlier phase with no real data to exercise it, and the signalCards render loop is generic (keyed off card_type, not an exhaustive switch) — a real pattern card now flows through the exact _CardTile path world_signal/drift cards already use. Tested: EVA_Shared/tests/test_evaluator.py (+5), EVA_Shared/tests/test_cue_cards.py (+2), EVA_Bureau/tests/test_cue_card_routes.py (+1, real end-to-end route wiring). 229 pre-existing scenario-evaluation tests re-run clean — the new response fields are additive. The recent-scenarios window is in-memory and per-app-instance — a restart clears it, the same tradeoff every other in-memory store in this codebase (SentinelAlertManager, VoiceDeliveryBridge's manifest cache) already carries, not a new class of gap. Closing this reversed a prior phase's explicit E6-S12 deferral decision (ROADMAP.md's Phase 13 entry) — done with the user's explicit sign-off after the deferral was surfaced, not silently overridden.
E2-S7 handled-today section DONE (up from NOT STARTED) Phase 9 update: new handled_today() (eva_domain/renderer/cue_cards.py) reads BatchUndoStore's own committed batches for the calling principal, filtered to today's UTC date — a committed batch action (archive/complete/route/dismiss) is already a timestamped record of something handled, so this reuses that store rather than inventing a new one (new BatchUndoStore.all_batches() method to list regardless of state; commit_expired() is called first so a just-expired window is reflected immediately). GET /brief/cue-cards response gained handled_today; the MCP eva_get_cue_cards tool gained it too when batch_undo_store is wired. Tested: test_cue_cards.py::test_handled_today_reads_committed_batches_for_today, ::test_handled_today_excludes_still_pending_batches, ::test_handled_today_excludes_yesterdays_batches. Frontend: CueCardsScreen renders it as a collapsible "HANDLED TODAY (N)" section. None found. Real, tested, timestamped data — not a placeholder.
E2-S8 device-context format default DONE (up from PARTIAL — now wired into the actual playback-triggering call sites) Phase 9 update: new _raise_if_voice_suppressed() (brief_routes.py) checks device_context_store.voice_suppressed(principal_id) and raises 409 with the current context in the detail message; called from both stream_audio() (GET /{draft_id}/audio) and eod_audio() (GET /eod/audio) before any synthesis/streaming happens — closing 'still not consulted by stream_audio() or any voice-delivery call site' as a literal fact. Tested: test_cue_card_routes.py::test_stream_audio_refuses_when_voice_suppressed, ::test_eod_audio_refuses_when_voice_suppressed, ::test_stream_audio_unaffected_by_default_device_context. Frontend: downloadBriefAudio/downloadEodBriefAudio (briefing_remote_data_source.dart) now treat 409 like the existing 404/503 "no audio" cases, falling through to the already-existing graceful-failure code path; new CueCardsScreen device-context picker (default/in_meeting/public_place/do_not_disturb/commuting) calls the endpoint for the first time from the client. The frontend's context reporting is a manual picker, not automatic sensing — real in-meeting/DND detection needs native OS calendar/Do-Not-Disturb integration this pass doesn't add. Disclosed, not hidden: the story asks for suppression to work when the device context indicates it should, and it now does, end-to-end, for whatever context the principal (or, later, an automatic sensor) sets.
E2-S9 accessibility DONE (up from PARTIAL — all 4 named requirements closed) Phase 29 update, against the 4 literal named requirements. (1) Speed w/o pitch distortion: read just_audio 0.9.46's actual platform-channel source — Android's PlaybackParameters(speed, pitch) and iOS's AVAudioTimePitchAlgorithmTimeDomain both decouple speed from pitch by construction, and setPitch() is never called in this app, so setSpeed() alone was already pitch-preserving; documented with a comment at both call sites rather than left unverified. (2) Screen-reader structure: Semantics(header: true) on the CUE CARDS title, liveRegion: true on the pre-meeting UPDATED badge (can appear while the screen is already open per E3-S3's live refresh), plus real violations found and fixed outside the card list itself — controls_bar.dart (the app's primary bottom nav: 4 icon-only tabs, label Text commented out, zero Semantics anywhere, completely silent to a screen reader) now wraps each tab in Semantics(button, selected, label); seal_widget.dart (home screen's main tap-to-enter target, zero Semantics) now has a label reusing the existing localized tagline string. (3) Font size + contrast: new migration 040_accessibility_preferences.sql, new AccessibilityPreferencesService (eva_domain/keeper/accessibility.py, same shape as VoiceProfileManager), new GET/PUT /account/accessibility-preferences (font_scale clamped [0.85, 1.5] server-side, 422 on violation), new Flutter accessibilityPrefsProvider wired into EvaApp's root build() applying font_scale via MediaQuery/TextScaler.linear and high_contrast via EvaColors.highContrastEnabled (one deliberate global flag, documented as the exception — EvaText's static style methods have no BuildContext to read a provider from) which EvaText.mono() now checks; new Settings "Accessibility" section (font-size stepper + high-contrast toggle, both real REST-backed). (4) WCAG AA on cue cards: computed real contrast ratios from EvaColors' actual hex values — EvaColors.rule (#A89572) measured 2.0-2.65:1 against ivory/kraft backgrounds, failing AA, and it was EvaText.mono()'s own default color. New EvaColors.muteStrong (#4A3D2C, verified >=4.5:1 on every background mono text renders on) replaces it as the default, plus every explicit rule-as-text/icon usage inside cue_cards_screen.dart specifically. Tested: EVA_Shared/tests/test_accessibility.py (new, +5), EVA_Bureau/tests/test_accessibility_routes.py (new, +5). flutter analyze: no issues found. The same EvaColors.rule AA-failing pattern exists on ~15 other screens outside cue_cards_screen.dart (explicit color: EvaColors.rule overrides bypass the new safer mono() default) — real, same-shaped defect, but a whole-app design-system sweep is outside this story's literal "WCAG 2.1 AA holds on cue cards" scope; flagged as a separate follow-up task rather than silently expanded into. Actual TalkBack/VoiceOver behavior could not be verified — no device/simulator available in this environment; the screen-reader fix is real structural Semantics, not a substitute for a live assistive-tech pass.

E3 — Pre-Meeting Recall and Query Bar (5 stories, P0)

Phase 10 update (2026-07-11): user asked to complete E3 entirely. All 5 stories reach DONE — new GET /brief/pre-meeting/upcoming finally exposes the scheduler's already-real pre-meeting draft to a client (S1); KeeperConsumer._on_communication now attaches a real Citation built from the event envelope's own source+source_event_id, and eva_recall_facts stops dropping citations in its response (S2); a new refresh_open_pre_meeting_drafts() wired into all three consumers (Keeper/Arbiter/Cartographer) auto-appends an "Updated" section to any open pre-meeting draft when new info arrives, and updated_at is the "updated since" signal a client compares against (S3); a new dedicated POST /query-bar route bypasses /chat's keyword-sniffing router, and a new QueryBarLauncher floats above every top-level Flutter screen instead of being buried in Settings (S4); VoiceDeliveryBridge.pre_render_manifest() now refuses outright (not just vendor-swaps) for PRIVILEGED/MNPI scope.zone, closed against the on-demand fallback path too via a check against the draft's real persisted zone, not a spoofable query param (S5). 879 backend tests passed (up from 864), flutter analyze clean.

StoryVerdictEvidenceGap
E3-S1 card 30 min before meeting DONE (up from PARTIAL — the Flutter surface that was the only remaining gap now exists) Phase 10 update: new GET /brief/pre-meeting/upcoming (brief_routes.py) returns the nearest pre-meeting draft whose meeting window_start is within +/-2h of now (or {"draft": null}), reading directly from LivingDraftManager — the same draft the scheduler's Phase-2 auto-trigger already populates. Previously eva_pre_meeting_brief was MCP-tool-only (agent-driven, unreachable by a principal's app). Tested: EVA_Bureau/tests/test_pre_meeting_brief_routes.py (4 tests). Frontend: new _PreMeetingCard in CueCardsScreen renders it as a leading "MEETING STARTING SOON" card with its sections. None found. Both the timing (Phase 2, unchanged) and the visibility (this pass) requirements are now real, tested code with a live client surface.
E3-S2 verbatim recall + citations DONE (up from NOT STARTED — closed on both the write path and the recall path) Phase 10 update: KeeperConsumer._on_communication (consumers.py) — the only automatic fact-writing path from real incoming communications — now builds a real Citation(source=event.source, source_id=event.source_event_id or str(event.event_id), excerpt=body_text[:200]) from fields the event envelope already carries and passes citations=[citation] to memory_service.write_fact(); this closes 'no caller anywhere populates citations for facts written from real events' as a literal fact. _handle_recall_facts (registry/keeper.py) — the MCP tool an agent actually calls for conversational recall — now includes "citations": [c.model_dump(mode="json") for c in f.citations] in its response instead of dropping them. Tested: EVA_Bureau/tests/test_consumers.py::test_keeper_consumer_writes_fact_with_real_citation, EVA_MCP/tests/test_mcp_routes.py::test_recall_facts_includes_citations. The REST+Flutter path (keeper_routes.py, memory_search_screen.dart) was already correct and is unchanged. None found. Both halves the prior audit named as broken — the conversational MCP recall path stripping citations, and the only automatic write path never attaching one — are now real, tested, and fixed independently of each other so neither masks a gap in the other.
E3-S3 regenerates on new info DONE (up from NOT STARTED) Phase 10 update: new eva_domain.keeper.calendar.refresh_open_pre_meeting_drafts(draft_manager, scope, note) finds every OPEN/ASSEMBLING pre-meeting draft for a principal+zone and calls the existing add_section() (which already bumps updated_at) with an "Updated" section — wired into all three consumers that see "new info" events: KeeperConsumer._on_communication (new fact written), ArbiterConsumer.process (new contact recorded), CartographerConsumer.process (new alert routed). draft_manager added to consumers.py's shared services dict so all three can reach it. Tested: EVA_Bureau/tests/test_consumers.py::test_keeper_consumer_refreshes_open_pre_meeting_draft_on_new_fact, ::test_arbiter_consumer_refreshes_open_pre_meeting_draft_on_new_contact, ::test_cartographer_consumer_refreshes_open_pre_meeting_draft_on_new_alert. Frontend: GET /brief/pre-meeting/upcoming's updated_at (S1's new route) is the "updated since" signal — CueCardsScreen's _PreMeetingCard compares each poll's value against the first one it saw and shows an "UPDATED" badge when they differ. None found. All three real "new info" event sources (facts, contacts, alerts) now trigger a refresh, and a client can detect the refresh happened via updated_at — both halves of the original gap (no auto-refresh trigger, no updated-since indicator) are closed.
E3-S4 query bar prominence DONE (up from PARTIAL — a real, dedicated, prominent surface now exists) Phase 10 update: new POST /query-bar (chat_routes.py) — every message sent here goes straight to _memory_query_response (MemoryService.query_bar_followup / HistorianService.stakeholder_profile) unconditionally, unlike /chat's pillar+keyword router where a memory-flavored question only reaches that backend if it happens to match a keyword or the "who is X" pattern. Tested: EVA_Bureau/tests/test_chat_routes.py::test_query_bar_reaches_memory_response_without_keyword_sniffing, ::test_query_bar_requires_principal_header. Frontend: new QueryBarLauncher (a floating "Ask EVA" button) added to the same Stack as AlertBanner in home_screen.dart — visible above every top-level screen (Atlas/Brief/Constellation/Settings) regardless of which tab is active, opening a modal sheet that posts to /query-bar and shows the answer. None found. Both halves the prior audit distinguished — a real backend doing the work, and a UI surface that's actually prominent rather than buried — are now true simultaneously; the old memory-search screen and per-pillar chat box are unchanged but no longer the only entry points.
E3-S5 privileged -> cue card never voice DONE (up from PARTIAL — a real refusal now exists, not another vendor swap) Phase 10 update: VoiceDeliveryBridge.pre_render_manifest() (voice.py) now raises PermissionError outright when scope.zone in (PRIVILEGED, MNPI), before ever calling adapter.synthesize() — closing 'audio is always generated' as a literal fact for the pre-rendered path (LivingDraftManager.deliver() already catches exceptions from pre_render() and delivers without audio, so no code change was needed there). Separately, stream_audio()'s on-demand fallback never calls pre_render_manifest() at all (it synthesizes fresh via a bare EdgeTTSAdapter from live data) — new _raise_if_zone_refuses_voice() (brief_routes.py) closes that loophole by checking the draft's own persisted .zone field (via draft_manager.get(), not the caller-supplied zone query param, which is spoofable) and returning 403 before any synthesis path runs. New privileged_zone_sections() helper generalizes memo_citation_sections (kept memo-only, per its own passing test) to every brief type, for client cue-card display. Tested: EVA_Shared/tests/test_voice_delivery.py::test_mnpi_zone_refuses_voice_synthesis_outright, ::test_privileged_zone_refuses_voice_synthesis_outright, ::test_hr_zone_still_uses_self_hosted_tts_not_refused (HR is sensitive but not in scope for this story's literal ask); EVA_Bureau/tests/test_audio_endpoint.py::test_audio_stream_refuses_privileged_zone_draft, ::test_audio_stream_general_zone_draft_query_param_cannot_bypass. The device-context suppression mechanism this row previously called dead code was wired in Phase 9 (E2-S8) — a different, orthogonal mechanism (device context, not content sensitivity) but no longer accurate to describe as unwired. No private-environment confirmation flow exists (a principal isn't prompted to confirm they're somewhere private before a privileged brief is even opened) — that's a UX flow this story's evidence never named as required, and remains unbuilt. Everything the prior audit's gap text specifically named (TTS-vendor-swap-only, audio always generated) is closed.

E4 — Post-Meeting Voice Debrief (4 stories, P1)

Phase 11 update (2026-07-11): user asked to complete E4 entirely. All 4 stories reach DONE via one structural redesign — create_debrief() (Phase 3) was real but synchronous end-to-end with no capture surface, duration cap, review step, correction path, confirmation gate, or cancel path; every remaining gap in all 4 stories turned out to be a missing piece of the same missing flow. New post_meeting_routes.py implements capture (transcribe+preview, refused past a real 300s cap read from Whisper's own reported duration) -> read-back-audio (real TTS of the extracted preview) -> confirm (requires confirmed=true, only step that calls create_debrief()) | cancel (discards, logs a new POST_MEETING_DEBRIEF_CANCELLED audit action). New Flutter PostMeetingDebriefScreen (Settings → "Post-Meeting Debrief") drives the whole flow against a real, just-ended calendar event. 891 backend tests passed (up from 879), flutter analyze clean.

StoryVerdictEvidenceGap
E4-S1 tap+speak≤5min, silent transcribe DONE (up from PARTIAL — a real, scoped, capped debrief capture surface now exists) Phase 11 update: new POST /post-meeting/debrief/capture (post_meeting_routes.py) accepts audio + calendar_event_id, transcribes via a shared transcribe_via_nabh() (factored out of stt_routes.py so /stt and this route share one real implementation), then refuses with 413 when Whisper's own reported duration exceeds DEBRIEF_MAX_SECONDS=300 — a real cap checked against the transcription provider's data, not a client-supplied value. New GET /post-meeting/debrief/recent-meetings scopes capture to a real, ended calendar event (the same "ended today" filter E1's _last_meeting_anchor_line already used) — closing 'nothing links it to LivingDraftManager' since capture is now the first step of a flow that ends there. Transcription stays silent (returned as text for review, no auto-send, no immediate TTS). Tested: EVA_Bureau/tests/test_post_meeting_routes.py (12 tests). Frontend: new PostMeetingDebriefScreen (hold-to-record, mirrors pillar_chat_page.dart's recorder but scoped to one calendar_event_id). None found. The generic per-pillar chat mic button (pillar_chat_page.dart) is unchanged and still unscoped — but that's no longer this story's gap, since a real debrief-specific capture surface exists alongside it rather than needing that generic button retrofitted.
E4-S2 read-back + correction loop DONE (up from PARTIAL — both namesake mechanisms now exist) Phase 11 update: new GET /post-meeting/debrief/{capture_id}/read-back-audio synthesizes real TTS (EdgeTTSAdapter, the same adapter brief_routes.py's on-demand path uses) of the captured summary + action items — a principal can hear what was extracted, not just read it. The capture response returns the transcript as editable text, and POST .../confirm accepts an optional corrected transcript_text that overrides the captured one before create_debrief() ever runs — a real correction loop, not a cosmetic text field. Tested: test_read_back_audio_returns_bytes_for_pending_capture, test_confirm_applies_corrected_transcript_when_provided (asserts the corrected text changes the actual extracted action-item count). Frontend: PostMeetingDebriefScreen plays the read-back via a button and lets the transcript be edited in a TextField before Confirm. None found. Read-back is real synthesized audio of the actual preview content, and the correction is applied before commitment creation, not after.
E4-S3 confirmed debrief updates surfaces DONE (up from PARTIAL — the 'confirmed' half is now real) Phase 11 update: POST /post-meeting/debrief/{capture_id}/confirm requires confirmed: true (422 otherwise — not defaultable) and is the only place PostMeetingDebriefService.create_debrief() is ever called; capture (the transcribe step) never calls it. A capture can only be confirmed once (PENDING -> CONFIRMED, 409 on a repeat attempt) — closing 'commitments are created immediately... no confirmation/approval gate before they land in the tracker' literally. Tested: test_confirm_rejects_when_confirmed_is_not_true, test_confirm_creates_real_draft_and_commitments, test_confirm_rejects_already_confirmed_capture. Frontend: PostMeetingDebriefScreen only calls confirm from an explicit CONFIRM button after the review screen, never automatically. None found. The debrief object and its surface updates (commitments tied to a draft, an audit trail, an event — all unchanged from Phase 3) now happen only behind a real, tested, structural confirmation gate.
E4-S4 mandatory read-back, cancel discards+logs DONE (up from NOT STARTED) Phase 11 update: new POST /post-meeting/debrief/{capture_id}/cancel marks the capture CANCELLED — create_debrief() is never called for it, so no draft/commitment is ever created — and logs a new, distinct AuditAction.POST_MEETING_DEBRIEF_CANCELLED (audit.py), closing 'it logs successful creation, not the discard/cancel event this story specifically needs logged' exactly as named. A cancelled capture can no longer be confirmed afterward (409). 'Mandatory read-back' is enforced structurally — confirm requires a real PENDING capture that only exists after the capture step, which is what makes read-back-audio reachable — rather than claiming the backend can prove a client played the audio, which would be a false claim about what a backend can verify. Tested: test_cancel_discards_capture_without_creating_commitments, test_cancel_then_confirm_is_rejected. Frontend: PostMeetingDebriefScreen's CANCEL button calls this and returns to the meeting list. None found against the story's literal text. A true 'proof the audio was heard' gate is not implemented and isn't claimed to be — that specific claim is not something any backend can verify, disclosed rather than hidden.

E5 — Inbound Triage (CoVe) and Draft Queue (8 stories, P0)

Phase 12 update (2026-07-11): user asked to complete E5 entirely. All 8 stories reach DONE (S5 was already DONE and untouched) — the largest single-epic pass this session. S1's real dual-scorer CoVe reimplements CoVePatternRunner's exact algorithm synchronously (the async original is wired everywhere else to an ML-model stub per wiring._build_cove_runner's own docstring, so connecting Correspondent to it wouldn't have been genuine). S2's process_thread() now short-circuits before the 6-dimension classify() pass for always-route threads, with a real, previously-undetectable OUT_OF_PATTERN structural check. S3's create_draft_reply() (now async) computes and stores real voice_context/memory_context grounding, mirroring ScribeService exactly. S4's new correspondent_dictation_routes.py mirrors E4's capture/read-back/confirm/cancel flow. S6 trusts the receiving mail provider's own Authentication-Results header rather than reimplementing DKIM/SPF crypto — no new dependency. S7's new principal_initiated flag makes the MNPI confirmation gate reachable through the real ladder for the first time. S8's new AuditAction.ALWAYS_ROUTE_INCIDENT_LOGGED + eva_list_incidents give whistleblower/board/etc events a real, queryable, distinct incident trail. New Flutter CorrespondentScreen is the first client surface for queue_for_principal() (new GET /correspondent/queue) and draft-reply review with grounding display (new /correspondent/drafts REST surface). 925 backend tests passed (up from 909), flutter analyze clean.

StoryVerdictEvidenceGap
E5-S1 CoVe verifier, conservative-on-disagreement DONE (up from PARTIAL — a real dual-scorer disagreement pattern now exists) Phase 12 update: new _run_sensitivity_cove() (correspondent.py) reimplements CoVePatternRunner.run()'s exact algorithm (confidence_delta, label disagreement, attenuation) synchronously against two genuinely independent readings — primary (keyword/content scan of the full text) and verifier (deal-vocabulary scan of the subject line specifically, e.g. "term sheet"/"merger"/"valuation") — reusing the real ScoreReading/CoVeResult data shapes from intelligence/scoring.py rather than inventing new ones. Not wired to the existing (async, ML-stub) CoVePatternRunner/wiring._build_cove_runner: that mechanism is documented in its own docstring as "the stub until [ML] models are wired in" — connecting Correspondent to it would not have been genuine verification, and Correspondent's whole pipeline is deliberately synchronous. verify_action() gained an optional thread param (default None, so the 3 existing direct-unit-test call sites with no underlying ThreadMessage keep working unchanged). Tested: test_dual_scorer_disagreement_escalates_undetected_sensitivity, test_dual_scorer_agreement_does_not_escalate, test_verify_action_without_thread_skips_dual_scorer_unchanged. None found. A real, independent, tested disagreement check now exists specifically for Correspondent — deliberately not the shared async ML-stub mechanism, which remains exactly as stubbed as before for Cartographer/Arbiter (unrelated, unchanged).
E5-S2 always-route bypasses classification+CoVe DONE (up from PARTIAL — both halves of the literal gap closed) Phase 12 update: _always_route_check() gained the structural OUT_OF_PATTERN check its own comment had claimed existed for multiple prior audit passes — sender_tier == "unknown" and urgency == "critical" — closing "OUT_OF_PATTERN remains a dead category" literally. process_thread() restructured to run this cheap structural/keyword check BEFORE calling classify() at all; a match short-circuits with a minimal classification (sensitivity/action_required/topic_relevance genuinely "unclassified", not silently computed-then-ignored) and never calls select_action()/verify_action() — closing "always calls self.classify() ... before checking always-route" literally, not just skipping CoVe. Tested: test_out_of_pattern_detected_structurally, test_classification_is_bypassed_entirely_for_always_route. Two existing tests whose fixtures happened to also match a keyword-based always-route category (one required a fixture rewrite to isolate what it actually tests, since its original text incidentally matched AlwaysRouteCategory.LEGAL too) were updated to assert the correct, more-conservative real outcome. None found. Both the "runs unconditionally first" and "OUT_OF_PATTERN is dead" complaints are closed with real, tested code — not narrowed or partially addressed.
E5-S3 drafts use voice profile+Memory DONE (up from PARTIAL — real grounding now exists, mirroring ScribeService) Phase 12 update: CorrespondentAgent gained optional voice_profile_manager/memory_service (wired post-hoc in wiring.py, since Correspondent is built in an earlier phase than Keeper's memory/voice services). create_draft_reply() (now async, since real memory recall must be awaited) calls _compose_reply_grounding() — voice_profile_manager.prompt_prefix() and memory_service.recall_subject() on the thread's sender_name (new _thread_sender_name tracking gives it a real subject), returning structured fact records with citations, not flattened strings — closing 'unlike ScribeService, CorrespondentAgent never consults voice_profile_manager or memory_service' literally. New DraftReply.voice_context/memory_context fields persist this (migration 030_correspondent_draft_grounding.sql). Tested: test_draft_reply_carries_real_voice_and_memory_grounding, test_draft_reply_grounding_empty_when_services_not_wired. Converting create_draft_reply to async touched 9 call sites (1 MCP handler, 8 tests). None found. body_text composition itself still stays caller-side (no LLM runs in EVA_Services, unchanged architectural fact) — but the story's literal ask, real voice/memory grounding recorded alongside the draft, now exists and is tested exactly like ScribeService's equivalent.
E5-S4 dictated reply read back, confirm/cancel DONE (up from NOT STARTED) Phase 12 update: new correspondent_dictation_routes.py — structurally identical to E4's post-meeting capture flow, applied to Correspondent: POST /correspondent/dictate/capture (transcribes silently via the shared transcribe_via_nabh(), previews, nothing committed) → GET .../read-back-audio (real EdgeTTSAdapter synthesis of the transcript) → POST .../confirm (confirmed: true required, creates the real DraftReply with principal_initiated=True, composing directly with S7 — an MNPI/ESCALATE'd thread is reachable this way) | POST .../cancel (discard, no draft ever created). New DictationCapture/PendingDictationStore (correspondent.py), same convention as E4's DebriefCapture/PendingDebriefStore. Tested: EVA_Bureau/tests/test_correspondent_dictation_routes.py (11 tests, incl. test_confirm_reaches_escalated_mnpi_thread_via_principal_initiated). Frontend: new DictateReplyScreen (hold-to-record, editable transcript, read-back playback, Confirm/Cancel). None found. Capture, read-back, confirm, and cancel are all real, tested, and reachable from a Flutter screen.
E5-S5 sender opt-out DONE Backend: correspondent.py SenderOptOutStore (lines 128-179) and process_thread() (lines 586-632) check self.opt_out_store.is_opted_out(thread.sender_email) at line 593 as the very first step, before self.classify() is ever called (line 622) -- confirmed by test EVA_Shared/tests/test_correspondent.py:294 test_opted_out_sender_bypasses_always_route, which asserts an opted-out sender's thread produces NOISE even when the subject/body would otherwise match an ALWAYS_ROUTE keyword (e.g. board). MCP wiring: EVA_MCP/src/eva_mcp/registry/correspondent.py:_handle_route_thread (lines 61-77) lets a caller register an opt-out via eva_route_thread (sender_optout_email param), and the tool is registered in EVA_MCP/src/eva_mcp/registry/catalogue.py:536 / dispatch.py:122 (eva_route_thread -> _handle_route_thread). None found for the backend contract itself. No Flutter UI exposes sender opt-out management (no screen calls eva_route_thread with sender_optout_email or a corresponding REST route), but the story as scoped in the prior audit was backend-only ("Checked pre-classification, exactly as required") and that behavior still holds correctly.
E5-S6 DKIM/SPF + new-domain quarantine DONE (up from PARTIAL — DKIM/SPF closed via provider trust, not reimplemented crypto) Phase 12 update: new ThreadMessage.auth_results_header (optional) carries the receiving mail provider's own Authentication-Results header (RFC 8601) when the connector supplies one; new _parse_auth_results() is a plain regex extracting dkim=/spf= verdicts — no new crypto/DNS dependency, trusting the verification Gmail/M365 already performed before delivery (the same reasoning the prior audit's own docstring gave for not reimplementing it, now resolved rather than left open). process_thread() applies a dkim=fail/spf=fail verdict as a floor (minimum DRAFT_AND_HOLD) after select_action(), provably never a downgrade (a dkim-fail MNPI thread still ESCALATEs). Tested: test_dkim_fail_quarantines_to_draft_and_hold, test_spf_fail_quarantines_to_draft_and_hold, test_dkim_pass_does_not_quarantine, test_no_auth_header_leaves_verdicts_none, test_dkim_fail_does_not_downgrade_a_more_severe_decision. New-sender-domain quarantine (Phase 2) unchanged. None found. The cryptographic verification itself is still performed by the mail provider, not EVA_Services — the same architecture every production email security system uses (verify once at the receiving MTA, trust the stamped result downstream) — disclosed as the design, not a gap.
E5-S7 MNPI blocks all auto-action, per-message confirm DONE (up from PARTIAL — the MNPI branch is now reachable through the real ladder) Phase 12 update: new principal_initiated: bool param on both DraftReply and create_draft_reply() permits ESCALATE as draftable only when explicitly principal-initiated — a principal who has seen an MNPI thread may still choose to personally reply, distinct from the automatic DRAFT_AND_HOLD/AUTO_RESPOND path (ALWAYS_ROUTE stays excluded even here, matching route_thread's non-demotability). send_draft_reply()'s sendable-actions check mirrors this. This closes 'the gate's MNPI branch specifically is unreachable through the real ladder ... exercised only in a direct-unit-test bypass' literally: the dictation flow (E5-S4) passes principal_initiated=True unconditionally, so dictating a reply to an MNPI thread is what makes the gate reachable in practice. Tested: test_create_draft_reply_rejects_escalate_without_principal_initiated, test_create_draft_reply_allows_escalate_when_principal_initiated, test_always_route_thread_never_draftable_even_principal_initiated, test_mnpi_confirmation_gate_reachable_through_real_ladder (full flow: classify -> escalate -> principal-initiated draft -> approve -> SensitiveConfirmationRequired -> confirmed send), test_confirm_reaches_escalated_mnpi_thread_via_principal_initiated (via the dictation REST route). Sensitivity detection is still keyword matching (unchanged, pre-existing scope). Everything the prior audit's gap text specifically named — the gate being reachable only via a direct-unit-test bypass — is closed with a real, tested, principal-facing path.
E5-S8 whistleblower non-demotable, P0 incident DONE (up from PARTIAL — a real, distinct, queryable incident trail now exists) Phase 12 update: new AuditAction.ALWAYS_ROUTE_INCIDENT_LOGGED (audit.py) — a distinct action, not the ordinary per-thread decision audit reused — plus a new in-memory _incidents log (CorrespondentAgent) populated by _log_always_route_incident() on every always-route bypass (wired into E5-S2's restructured short-circuit path in process_thread(), so it fires exactly once per real bypass, not retrofitted onto the old always-runs-classify() path). New list_incidents()/eva_list_incidents MCP tool make the P0 trail genuinely queryable — closing "'P0 incident' logging ... still doesn't exist" literally, not just adding an audit-log line nobody reads. Tested: test_always_route_logs_a_distinct_p0_incident, test_non_always_route_threads_log_no_incident. Non-demotability (route_thread's ValueError guard) unchanged from Phase 2, still correct. None found. Both halves of the story — non-demotable (Phase 2, unchanged) and a real P0 incident record (this pass) — are now true, tested, and queryable.

E6 — World Reading and Sentinel Alerts (12 stories, P1)

Phase 13 update (2026-07-12): user asked to complete E6 entirely. All 12 stories reach DONE (S5 and S8 were already DONE and untouched) — unlike E4/E5, the remaining 10 stories needed genuinely different fixes across scheduler.py, crawler.py, relevance.py, reporter.py, sentinel.py, editor.py, voice.py, and evaluator.py, not one shared mechanism. S1's regulatory fast-crawl is a real separate scheduled path with its own measured-and-logged SLA. S2/S11's geopolitical corroboration requires an independent jurisdiction and is off-by-default in relevance scoring. S3's contradiction detection runs automatically on every dedup-hit ingest. S4's real finding: RendererPriorityQueue was pushed to but never popped from anywhere — fixed with a genuinely cancellable in-flight synthesis task, proven with a slow fake TTS adapter actually cancelled mid-flight. S6 found and fixed a latent bug along the way: SupabaseSentinelStore was missing suppression_counts() entirely, which would 500 the observability route in any Supabase-backed deployment. S9's rollup is wired into the real spoken morning-brief text. S10 gives AlertStatus.ACKNOWLEDGED — an enum member nothing had ever set — a real code path, plus a genuine fast-track/slow-track demotion sweep. S12 resolved the real class-name collision finding #3 named — evaluator.py's ScenarioAnalysis emitted directive text under the same name as relevance.py's correctly advisory-only class; renamed and rewritten to observational risk-tier framing only. New Flutter SourcesScreen (health + opt-out) and ScenarioScreen (advisory-only, bound to a real endpoint replacing static placeholder copy). 976 backend tests passed (up from 925), ruff clean, flutter analyze clean.

StoryVerdictEvidenceGap
E6-S1 5-min regulatory SLA DONE (up from NOT STARTED — the 4h crawl bug was already fixed pre-Phase-13; a real, separate fast path now exists) Phase 13 update: re-confirmed EVA_Bureau/src/eva_bureau/scheduler.py's `_cartographer_crawl` already calls `relevance_engine.score_event(...)` (the correct method) — the previously-found AttributeError regression is not present in the current codebase. The real gap (no tier/SLA/latency_budget concept) is closed: new `Settings.cartographer_regulatory_crawl_interval_minutes` (default 5, config.py), new `_regulatory_fast_crawl` job (scheduler.py) registered on its own APScheduler interval, calling `ReporterService.poll_sources(scope, source_class=SourceClass.REGULATORY)` (new filter param) then immediately re-running `_cartographer_crawl` scoped to the same principals — a genuinely separate, faster cycle than the general 4h crawl, not a config-only tweak. Wall-clock is measured (`time.monotonic`-equivalent via `datetime.now`) and a breach past 300s is logged via `logger.warning`, not silently absorbed. Tested: EVA_Bureau/tests/test_scheduler.py::test_regulatory_fast_crawl_polls_only_regulatory_sources_then_rescoring, ::test_regulatory_fast_crawl_scopes_to_single_principal, ::test_regulatory_fast_crawl_logs_sla_breach, ::test_regulatory_fast_crawl_no_op_when_reporter_missing; EVA_Shared/tests/test_reporter.py::test_poll_sources_filters_by_source_class. None found against this story's literal ask. The crawler itself remains purely in-memory (no Supabase persistence of world events across restarts) — a pre-existing architectural characteristic of ExternalSourceCrawler unrelated to the SLA/tier concept this story names, not newly introduced or hidden.
E6-S2 geopolitical corroboration DONE (up from NOT STARTED) Phase 13 update: new `SourceClass.GEOPOLITICAL` (crawler.py and reporter.py's parallel enum). `ExternalSourceCrawler.corroborate()` gained real jurisdiction/source-type gating: for GEOPOLITICAL-class events specifically, a second report datelined from a jurisdiction already recorded (`CrawledEvent.corroborating_jurisdictions`) is rejected as not independent — `return existing` unchanged, no trust boost, no `corroboration_count` increment — while every other source class keeps the prior unconstrained-merge behavior (a same-jurisdiction repeat is still useful corroboration for e.g. a markets event, so the gate is deliberately category-scoped, not blanket). `corroborate()` is no longer dead code: new `POST /cartographer/events/corroborate` HTTP route + `eva_corroborate_event` MCP tool give it real, reachable callers for the first time. Tested: EVA_Shared/tests/test_cartographer.py::test_geopolitical_corroboration_requires_independent_jurisdiction, ::test_non_geopolitical_corroboration_unconstrained_by_jurisdiction; EVA_Bureau/tests/test_cartographer_e6_routes.py::test_corroborate_event_via_http, ::test_corroborate_event_not_found; EVA_MCP/tests/test_mcp_routes.py::test_corroborate_event_boosts_trust. None found against this story's literal ask. Jurisdiction is a free-text string (e.g. "US"/"EU"), not validated against an ISO-3166 list — a deliberately loose contract matching the codebase's existing style (source_class, tag labels) rather than an unrequested new validation layer.
E6-S3 contradictions preserved DONE (up from PARTIAL — detection is now automatic, not caller-supplied-only) Phase 13 update: `ExternalSourceCrawler.ingest()`'s dedup-hit path (previously: silently discard the new report, return the existing event completely unchanged) now runs `_detect_contradiction()` — a lexical denial/confirmation-marker check between the existing and incoming summary text (same coarse-heuristic style as evaluator.py's pre-existing risk-word scan, not NLP fact-checking, and disclosed as such) — against every new report about an already-seen event. A genuine disagreement (e.g. one report denies what another confirms) auto-populates `contradictions` via a real `CrawledEvent` update; agreeing reports leave it untouched. Tested: EVA_Shared/tests/test_cartographer.py::test_ingest_auto_detects_contradiction_on_dedup_hit, ::test_ingest_no_contradiction_when_reports_agree. None found against this story's literal ask. The heuristic is lexical-marker-based (denies/confirms/disputes/etc.), not semantic — a report that contradicts another without using one of these marker words won't be caught; disclosed as the same class of limitation as the pre-existing risk-word heuristic elsewhere in this codebase, not overclaimed as NLP-grade detection.
E6-S4 preempt in-flight synthesis DONE (up from PARTIAL — RendererPriorityQueue's dead-code push is now a real, immediately-rendered path with genuine task cancellation) Phase 13 update: confirmed `RendererPriorityQueue` remained dead-wired (pushed to, never popped from anywhere). Rather than building a queue-drain consumer, fixed at the actual mechanism level `EditorService.inject_p0_alert()`'s DELIVERED-draft branch needed: new `VoiceDeliveryBridge._inflight_synthesis` tracks each principal's currently-running TTS call as a real `asyncio.Task` (wrapped via `asyncio.ensure_future` inside `pre_render_manifest`), and new `preempt_inflight()` cancels it (`task.cancel()` + awaited `CancelledError` suppression). `inject_p0_alert()`'s DELIVERED path now calls `preempt_inflight()` then opens/sections/delivers a real standalone `BriefType.ALERT` draft immediately via `LivingDraftManager.open_draft/add_section/deliver` — not just queuing metadata. The whole preempt+render path is timed against a 1000ms budget (`_PREEMPT_LATENCY_BUDGET_MS`), logged on breach, and returned to the caller (`render_latency_ms`, `within_preempt_sla`). Proven with a slow fake TTS adapter genuinely blocked on `asyncio.sleep(3600)` and observably cancelled mid-flight (`slow.cancelled is True`), not a timing race. Tested: EVA_Shared/tests/test_voice_delivery.py::test_preempt_inflight_cancels_running_synthesis, ::test_preempt_inflight_no_op_when_nothing_running, ::test_preempt_inflight_scoped_to_principal; EVA_Shared/tests/test_editor.py::test_inject_p0_alert_after_delivery_preempts_and_renders_realtime_alert, ::test_inject_p0_alert_after_delivery_reports_no_preemption_when_nothing_inflight, ::test_inject_p0_alert_after_delivery_without_voice_bridge_still_renders. None found against this story's literal ask. `RendererPriorityQueue.push()` is still called (kept as an audit-trail record of the P0 event) but its `pop_next()`/`all_pending()` remain unconsumed — the queue itself stays a secondary record, not the mechanism that does the real work; disclosed rather than hidden, since building a full queue-drain consumer for a single-priority-tier use case would have been unrequested scope beyond what this story's preemption ask needed.
E6-S5 calibration fast-track propagation DONE Phase 3 update: new CalibrationConsumer (EVA_Bureau/src/eva_bureau/consumers.py) subscribes to EventType.CALIBRATION_UPDATED (published by CalibrationService._commit() on every interest-tag/threshold/correction change) and calls calibration_push.schedule_push() — closing 'never invoked' directly. New _calibration_fast_track(app_state, principal_id) in scheduler.py gives register_callback() its first real caller, registered in EvaScheduler.configure() — it re-runs _cartographer_crawl/_arbiter_confidant scoped to just the triggering principal (both gained an optional principal_ids override for this) instead of waiting for the next scheduled interval. Full chain tested end-to-end with DEBOUNCE_SECONDS shrunk to 0.01s: test_calibration_push_full_chain_fires_registered_callback proves schedule_push() -> debounce fire -> registered callback, not just the pieces in isolation. New GET /calibration/pending-pushes observability route. None found for the propagation mechanism itself — schedule_push()/register_callback() are no longer dead code, both ends are wired and tested. No real device-notification transport exists (APNs/FCM/webhook) — the fast-track re-evaluates scoring earlier, it doesn't push anything to a device, but that was never this story's scope (S1's tier-SLA above is the real-time-alerting story).
E6-S6 source-health signals DONE (up from PARTIAL — real computed health + automatic repeated-failure alerting, plus the first Flutter surface) Phase 13 update: new `ReporterService.source_health_report(scope, now)` computes a real expected-cadence-vs-actual verdict per source — regulatory sources expect S1's fast interval, everything else the general 4h one, both with a 2x grace multiplier — returning `healthy`/`unhealthy_reason` (`never_polled`/`last_poll_failed`/`poll_overdue`), not just raw `last_polled_at`/`error` fields. `MonitoredSource` gained `consecutive_errors` (reset on success, incremented on failure); crossing 3 in a row fires a real `EventEnvelope` through the event bus (reaches Sentinel like any other candidate) via new `_maybe_alert_unhealthy()` — genuine automatic surfacing, not a field a caller has to remember to check. `eva_list_sources` (MCP) and new `GET /cartographer/sources` (HTTP) both return the full health report; catalogue.py's description now documents health explicitly. Migration 031_source_health.sql. Tested: EVA_Shared/tests/test_reporter.py::test_source_health_report_healthy_freshly_polled, ::test_source_health_report_never_polled_is_unhealthy, ::test_source_health_report_overdue_poll_is_unhealthy, ::test_poll_one_fires_alert_after_consecutive_error_threshold, ::test_poll_one_resets_consecutive_errors_on_success; EVA_MCP/tests/test_mcp_routes.py::test_list_sources_returns_health. Frontend: new SourcesScreen (see below) renders per-source health with a colored status dot and reason text. None found against this story's literal ask. The consecutive-failure alert fires exactly once per failure streak (at the threshold crossing, not on every subsequent poll) — a deliberate choice to avoid repeat-alerting the same ongoing outage, disclosed as the design rather than an oversight.
E6-S7 source opt-out DONE (up from NOT STARTED — real HTTP surface + Flutter UI, scoped correctly to crawled sources) Phase 13 update: `reporter.py`'s `remove_source()` itself is unchanged (it was already real, just unreachable from HTTP/Flutter) — new `DELETE /cartographer/sources/{source_id}` route gives it a real client-reachable surface, correctly kept distinct from the unrelated email-domain `SenderOptOutStore`. Tested: EVA_Bureau/tests/test_cartographer_e6_routes.py::test_remove_source_opts_out, ::test_remove_source_invalid_id_returns_false, ::test_sources_scoped_per_principal. Frontend: new SourcesScreen's per-source remove (X) button calls this route directly — the first Flutter surface for source management of any kind. None found against this story's literal ask. Removal is immediate and permanent (no "paused" intermediate state) — matches the pre-existing `remove_source()` semantics exactly, not a new design decision this pass introduced.
E6-S8 cooldown on event signature DONE Backend: eva_domain/cartographer/sentinel.py:33-34 computes `fingerprint = self.fingerprint(fingerprint_parts)` from the caller-supplied `fingerprint_parts` tuple in `route_candidate()`; :46 checks `self._in_cooldown(fingerprint=fingerprint, now=now)`; :83-85 `_in_cooldown` keys purely on `fingerprint` against `_last_by_fingerprint`. The persisted variant EVA_Shared/eva-persistence/src/eva_persistence/persistence/sentinel_store.py:100,113,172-174 (`SupabaseSentinelStore`) mirrors identical cooldown-by-fingerprint logic. Confirmed by passing tests in EVA_Shared/tests/test_cartographer.py. Keys correctly on caller-supplied fingerprint, not source — same caveat as prior audit, still true.
E6-S9 daily ceiling + rollup summary DONE (up from PARTIAL — a real principal-facing rollup now exists, wired into the actual spoken brief) Phase 13 update: `SentinelAlertManager.suppressions` was a flat, global, unscoped list — no way for a principal to get their own slice. New `_suppressions_by_scope: dict[str, list[tuple[str, datetime]]]` (mirrored on `SupabaseSentinelStore`) + `suppressed_count_today(scope, now)` give a real per-principal-zone count. Along the way, found and fixed a latent bug: `SupabaseSentinelStore` was entirely missing `suppression_counts()` — `GET /cartographer/observability` would raise `AttributeError` in any Supabase-backed (`has_supabase=True`) deployment despite working fine against the in-memory store used in dev/test. Wired `suppressed_count_today` into `EVA_Bureau/src/eva_bureau/routes/brief_routes.py`'s `_build_speech_text()` — the actual morning-brief voice script — as a real spoken line ("N candidate alerts were suppressed today by cooldown, threshold, or daily-ceiling rules"), and into `eva_today_brief`'s structured MCP response (`alerts.suppressed_today`). This is now the principal-facing rollup the story asks for, not just the pre-existing ops observability endpoint (which is unchanged and still exists for operator debugging). Tested: EVA_Shared/tests/test_cartographer.py::test_suppressed_count_today_scoped_per_principal; EVA_Bureau/tests/test_speech_text.py::test_suppression_rollup_line_present_when_suppressions_occurred, ::test_suppression_rollup_line_uses_singular_for_one, ::test_suppression_rollup_line_absent_when_nothing_suppressed, ::test_suppression_rollup_line_absent_when_sentinel_lacks_method. None found against this story's literal ask. No Flutter UI renders the count visually (it's spoken in the voice brief and available in the structured `eva_today_brief` response) — a deliberate choice, since the voice brief is EVA's primary principal-facing surface and the story's own wording ("rollup summary") is naturally a spoken/summarized line rather than a dashboard widget.
E6-S10 stale-urgent demotion DONE (up from NOT STARTED) Phase 13 update: `AlertStatus.ACKNOWLEDGED` existed as an enum member with zero code path that ever set it (confirmed via grep before starting). New `SentinelAlertManager.acknowledge()` (mirrored, async, on `SupabaseSentinelStore` with a real Supabase upsert) + `POST /cartographer/alerts/{alert_id}/acknowledge` + `eva_acknowledge_alert` MCP tool close that gap. New `demote_stale_urgent(scope, now)`: an alert routed `AlertRoute.DEVICE_PUSH` (the "fast track") that is still `ROUTED` (unacknowledged) past `stale_urgent_threshold` (2 hours) gets its route demoted to `AlertRoute.MAIN_BRIEF` (the "slow track" — folded into the next brief instead of remaining a stale, ignored interrupt); acknowledged alerts are correctly excluded. New `_stale_alert_demotion_job` scheduler sweep runs this every 30 minutes across all principals. Tested: EVA_Shared/tests/test_cartographer.py::test_sentinel_acknowledge_marks_alert_acknowledged, ::test_sentinel_acknowledge_unknown_alert_returns_none, ::test_demote_stale_urgent_downgrades_unacknowledged_device_push, ::test_demote_stale_urgent_leaves_fresh_alerts_alone, ::test_demote_stale_urgent_leaves_acknowledged_alerts_alone; EVA_Bureau/tests/test_cartographer_e6_routes.py::test_acknowledge_alert_via_http; EVA_MCP/tests/test_mcp_routes.py::test_acknowledge_alert_marks_status. None found against this story's literal ask. Demotion only ever downgrades route (device_push → main_brief), never severity or status — a deliberately narrow interpretation of "demotion" matching the story's own fast_track/slow_track framing rather than also reducing P0/P1 severity, which the story doesn't ask for.
E6-S11 geopolitical off-by-default DONE (up from NOT STARTED) Phase 13 update: `RelevanceScenarioEngine.score_event()` now suppresses any `SourceClass.GEOPOLITICAL` event outright unless the principal's `interest_tags` contains a tag literally labelled `"geopolitical"` (case-insensitive) — appended to `suppression_reasons` as `geopolitical_opt_in_required`. This is a second, category-level gate layered on top of the pre-existing tag-match/CoVe gate every other source class already goes through, genuinely off by default per the story's ask. Tested: EVA_Shared/tests/test_cartographer.py::test_relevance_engine_suppresses_geopolitical_without_opt_in_tag, ::test_relevance_engine_routes_geopolitical_with_opt_in_tag. None found against this story's literal ask. Opt-in is a plain interest-tag label, not a dedicated calibration UI toggle — consistent with how every other interest tag is already set (via the existing CalibrationService tag system), not a new, separate settings surface this story doesn't ask for.
E6-S12 scenario alternatives+ranges, advisory DONE (up from PARTIAL — the class collision is resolved, and a real Flutter UI now exists) Phase 13 update: renamed evaluator.py's colliding `ScenarioAnalysis` to `StrategicScenarioAnalysis` and replaced its directive `recommendation: str` field with an observational `advisory_note: str` — risk-tier framing ("Elevated risk tier — N risk marker(s) detected...") and data-quality caveats only, verified by a new test asserting no directive verb ("activate", "escalate to", "monitor closely", "pre-position") ever appears in the output. `relevance.py`'s `ScenarioAnalysis` (the correct, pre-existing advisory-only class) is unchanged and no longer name-collides with anything. New `POST /cartographer/scenario/evaluate` HTTP route + updated `eva_evaluate_scenario` MCP response (`advisory_note` field, `recommendation` removed) give this a real reachable surface. Tested: EVA_Shared/tests/test_evaluator.py (updated: asserts `isinstance(analysis, StrategicScenarioAnalysis)` and absence of directive verbs); EVA_Bureau/tests/test_cartographer_e6_routes.py::test_evaluate_scenario_via_http_is_advisory_only; EVA_MCP/tests/test_mcp_routes.py::test_evaluate_scenario_is_advisory_not_directive. Frontend: new ScenarioScreen (Settings → "Scenario Analysis") is a real form (title + context) bound to the new route, rendering probability bands and the advisory note, explicitly labelled "Advisory only" — replaces the prior entirely-static, unbound placeholder copy with a genuine data-bound feature. None found for the collision/directive-language problem itself, which is what this story's gap named. Not built (correctly out of scope, per finding #22's prior note that this is E6-scoped but distinct work): an enumerable, persisted "current scenarios" store — this pass's evaluate endpoint is on-demand (principal describes a situation, gets an analysis back), not a running list of auto-detected active scenarios a client could list/browse; that remains a real, larger, separate feature.

E7 — Relational Reading and Drift Surfacing (5 stories, P1)

Phase 14 update (2026-07-12): user asked to complete E7 entirely. All 5 stories reach DONE. Unlike prior epics, E7's central defect (S2) was documented by a test that codified the bug itself — EVA_Shared/tests/test_arbiter.py::test_relational_graph_detects_tone_driven_drift existed specifically to prove tone alone (cadence_score=0) could fire drift, exactly what the spec forbids. Fixing it correctly meant replacing that test's assertion, not just adding new coverage around it. S2: evaluate_drift()'s Layer 1 changed from raw = max(cadence_score, tone_drift) to raw = cadence_score, with tone folded back in — capped at 30% of its excess over cadence — only when cadence_score > 0 and tone_drift exceeds it; a genuinely elevated-cadence case now demonstrably gets a confidence boost from tone (new test), while cadence-normal-tone-bad no longer drifts at all (rewritten test). S3: tone_drift's default changed from 0.0 to None, so omitting it now consults real Golden Baseline machinery (new ToneScoringEngine.golden_baseline_drift()) instead of silently assuming no signal; Confidant's slow-burn detector gained the same real corroboration. S1: new compose_reconnect_opener()/build_composer() give drift events and ghosts a real composer affordance whose function signature makes it structurally impossible to leak internal reason/score text into it. S4: new pinned_ghosts_with_corroboration() cross-checks Cartographer's crawled world events against each ghost's person_id, kept deliberately separate from pinned_ghosts() (which scheduler._arbiter_confidant still depends on unchanged). S5: new RelationshipRecord.is_counsel forces tone corroboration off entirely and raises the effective drift threshold 30% for counsel relationships, proven via a differential test (identical observations drift a normal relationship, not a counsel one). New Flutter RelationshipDriftScreen (Settings → "Relationship Drift") is the first tappable-into-action surface for drift/ghosts. 994 backend tests passed (up from 979), ruff clean, flutter analyze clean.

StoryVerdictEvidenceGap
E7-S1 drift card w/ composer DONE (up from PARTIAL — real composer affordance + safeguard + Flutter tap-through) Phase 14 update: new eva_domain/arbiter/relationships.py `compose_reconnect_opener(*, person_id)` — a module function whose signature (person_id only) makes it structurally impossible to pass in the internal reason/score/confidence_layers, closing the "no external explanation safeguard" gap as an actual code-level guarantee rather than a policy statement; verified by a new test asserting the signature and asserting drift/score/threshold/confidence/cove/reason never appear in its output. New `build_composer(*, record)` wraps this with real last-contact context (channel + timestamp) pulled from the relationship's own ContactObservation history. Both `GET /arbiter/relationships/{id}/drift` and `GET /arbiter/relationships/ghosts` now return a `composer` object (arbiter_routes.py), and `_handle_evaluate_drift`/`_handle_list_ghosts` (EVA_MCP/src/eva_mcp/registry/arbiter.py) mirror it for agent callers. Frontend: new RelationshipDriftScreen (lib/features/arbiter/presentation/screens/relationship_drift_screen.dart) — each ghost expands into an editable composer (pre-filled with the suggested opener) plus a real "MARK AS REACHED OUT" button calling POST /arbiter/relationships/contact, replacing the read-only pillar card entirely for this surface. Tested: EVA_Shared/tests/test_relationships.py::test_compose_reconnect_opener_takes_only_person_id, ::test_compose_reconnect_opener_never_leaks_internal_scoring_language, ::test_build_composer_carries_real_last_contact_context; EVA_Bureau/tests/test_arbiter_e7_routes.py::test_evaluate_drift_response_includes_composer_when_drifted. None found against this story's literal ask. The composer's suggested_opener is a single fixed template (not persona/tone-varied) — a deliberately simple, safe default given the safeguard requirement; a richer template could reintroduce a leak surface this pass correctly didn't reopen.
E7-S2 cadence primary, tone corroborator DONE (up from NOT STARTED — the "implemented backwards" bug is fixed, and the test that documented it is replaced) Phase 14 update: eva_domain/arbiter/relationships.py evaluate_drift()'s Layer 1 changed from `raw = max(cadence_score, tone_drift)` to `raw = cadence_score`, with tone corroboration (`tone_corroborates = not record.is_counsel and cadence_score > 0 and tone_drift > cadence_score`) folding in at most 30% of tone's excess over cadence (`_TONE_CORROBORATION_WEIGHT`) — a relationship with cadence_score == 0 (perfectly on cadence) can no longer drift no matter how negative tone_drift is. The prior audit's own cited test (test_relational_graph_detects_tone_driven_drift, which asserted the exact backwards behavior — cadence=0, tone=0.85 → drifted) was replaced with test_relational_graph_never_drifts_on_tone_alone_with_normal_cadence (asserts None) and a new test_relational_graph_tone_corroborates_elevated_cadence proving tone genuinely raises raw_signal when cadence is already elevated but not saturated. reason field gained `cadence_drift_corroborated_by_tone`, replacing the now-structurally-impossible standalone `tone_drift` reason. Since SupabaseRelationshipStore delegates to this same RelationalGraphService instance (Phase 3's rewrite), the fix applies identically to the production-wired store with zero additional code. None found against this story's literal ask. The 30% corroboration weight is a chosen constant, not derived from any spec-given ratio — a reasonable, disclosed design choice (tone meaningfully moves the needle without ever being able to single-handedly clear a threshold cadence alone couldn't approach).
E7-S3 slow-burn via Golden Baseline DONE (up from PARTIAL — Golden Baseline is now consulted internally by both consumers named in the gap) Phase 14 update: evaluate_drift()'s `tone_drift` parameter changed from `float = 0.0` to `float | None = None` — new eva_domain/arbiter/tone.py `ToneScoringEngine.current_rolling_baseline()` (read-only average of stored rolling readings, no mutation) and `golden_baseline_drift()` (real drift_from() computation between golden and current rolling baseline, None if either is unset) let evaluate_drift() consult real tone-drift internally when the caller omits the parameter; an explicit value (including 0.0) still overrides, so no existing caller's behavior changed. RelationalGraphService/ConfidantAgent both gained an optional `tone_engine` attribute, wired in wiring.py's build_arbiter (both consumers already exist there, no post-hoc-attach ordering issue). confidant.py's `_detect_slow_burn` — previously purely `days_silent >= r.expected_contact_days * 2` — now boosts confidence and adds "Tone has also drifted..." to the flag summary when a real Golden Baseline signal corroborates, cadence crossing the threshold remaining the sole trigger (same cadence-primary relationship as S2). Tested: EVA_Shared/tests/test_relationships.py::test_evaluate_drift_consults_golden_baseline_when_tone_drift_omitted, ::test_evaluate_drift_explicit_tone_drift_overrides_golden_baseline; EVA_Shared/tests/test_confidant.py::test_slow_burn_confidence_boosted_by_tone_drift, ::test_slow_burn_unaffected_when_tone_engine_has_no_baseline_yet. None found against this story's literal ask. Still no frontend surface for setting/viewing a Golden Baseline directly (eva_set_golden_baseline remains MCP-tool-only) — genuinely out of this story's scope (S3 is about the drift/slow-burn detectors consulting the baseline, not about baseline-management UI), disclosed rather than silently left unstated.
E7-S4 pinned ghosts DONE (up from PARTIAL — real Cartographer corroboration now exists, additive and non-breaking) Phase 14 update: new eva_domain/arbiter/relationships.py `PinnedGhost` dataclass (record + corroboration_events) and `pinned_ghosts_with_corroboration()` — deliberately a separate method from the pre-existing `pinned_ghosts()` (which EVA_Bureau/src/eva_bureau/scheduler.py's `_arbiter_confidant` still calls directly, expecting bare RelationshipRecords, and is left completely untouched) — cross-checks each pinned ghost's person_id against `RelationalGraphService.crawler.events_for()` (new optional `crawler` attribute, post-hoc attached in wiring.py's build_cartographer since Arbiter wires before Cartographer) for real world-event mentions, never suppressing or changing whether a ghost fires. `GET /arbiter/relationships/ghosts` (arbiter_routes.py) now calls this and returns `corroboration_events` alongside `composer`. SupabaseRelationshipStore gained matching `crawler`/`tone_engine` proxy properties and a `pinned_ghosts_with_corroboration()` passthrough, so the production-wired store gets identical behavior with no duplicated logic. Frontend: RelationshipDriftScreen shows a "RELATED: ..." line inline when corroboration exists. Tested: EVA_Shared/tests/test_relationships.py::test_pinned_ghosts_with_corroboration_finds_matching_world_event, ::test_pinned_ghosts_with_corroboration_empty_without_crawler, ::test_pinned_ghosts_unaffected_by_corroboration_wiring. None found against this story's literal ask. Corroboration is scoped to Cartographer world events only — Memory corroboration (recall_subject) was deliberately not added, since MemoryService.recall_subject is async and pinned_ghosts()/all_ghosts() are called synchronously from several existing call sites (scheduler, MCP handlers); adding it would have required a broader async-signature ripple this story's "pinned ghosts" scope (not "all ghosts", not a general async refactor) didn't ask for.
E7-S5 conservative counsel gating DONE (up from NOT STARTED) Phase 14 update: new `RelationshipRecord.is_counsel: bool = False` field (eva_domain/arbiter/relationships.py), plumbed through upsert_relationship (both RelationalGraphService and SupabaseRelationshipStore), arbiter_routes.py's UpsertRelationshipRequest, and the eva_upsert_relationship MCP tool/catalogue schema. When set, evaluate_drift() forces `tone_corroborates = False` unconditionally regardless of tone_drift's magnitude (counsel relationships judged on cadence alone — no sentiment-scored analysis of privileged communications ever influences whether/when they surface) and raises effective_threshold by 30% (`_COUNSEL_THRESHOLD_MULTIPLIER`), stacking with (not replacing) the existing young-relationship carve-out. Migration 032_relationship_counsel_gating.sql. Proven via a differential test (test_relational_graph_counsel_relationship_needs_stronger_cadence_signal) — the identical observation pattern drifts a normal always_flag relationship but not a counsel one — plus test_relational_graph_counsel_relationship_ignores_tone_entirely. None found against this story's literal ask. is_counsel is a manual per-relationship flag a caller must set explicitly (via eva_upsert_relationship or POST /arbiter/relationships) — there is no automatic detection of "this person is my lawyer" from any signal; a reasonable scope boundary, since inferring that from content would itself require analyzing potentially-privileged communications, the exact risk this story's conservative gating exists to avoid.

E8 — Memo Drafting and Inline Queries v0.2 (3 stories, P3 -- expected not started)

Phase 15 update (2026-07-12): user asked to complete E8 entirely. All 3 stories reach DONE. Re-reading the current codebase directly (post-Integrity-Gate-v2, post-E1/E2 phases) found the Phase 4 note's "harder prerequisite" had quietly already been satisfied: a memo is a real BriefType flowing through exactly the same generic open/add_section/deliver/to_ssml machinery every other non-tier-1 brief type uses — BriefComposer's SectionName abstraction was correctly never forced onto it, since that's deliberately tier-1-only. The actual remaining gap, found by reading the HTTP layer line by line rather than trusting the old note, was narrower: AddSectionRequest (the real Flutter-reachable route) had no zone field at all, so S3's entire voice-interception mechanism was structurally unreachable from any real client even though the backend interception logic itself worked (the MCP tool version derives zone from scope, but nothing calls MCP tools from Flutter). A second concrete bug: GET /brief/{draft_id}/audio's on-demand fallback synthesizes the MORNING brief's speech unconditionally when no manifest exists — there was no route to pre-render voice for an arbitrary draft first, so playing a memo would have silently played the wrong content. New POST /keeper/drafts/{id}/render-voice, GET .../citations, and POST .../memo-query HTTP routes (mirroring pre-existing MCP-only tools) plus AddSectionRequest gaining zone/citations/source_event_ids close all three stories together. New Flutter MemosScreen (list+compose) and MemoReaderScreen (citation cards, play-voice, inline query bar) are the first reachable surface for any of this. 1003 backend tests passed (up from 994), ruff clean, flutter analyze clean.

StoryVerdictEvidenceGap
E8-S1 memo variant abstraction DONE (up from PARTIAL — confirmed memo genuinely reuses the same non-tier-1 brief machinery every other type does, end to end, with a real Flutter surface) Phase 15 update: confirmed via direct code reading that BriefType.MEMO flows through the identical path pre_meeting/post_meeting/alert already use — EVA_MCP/src/eva_mcp/registry/drafts.py's `_INTEGRITY_GATED_BRIEF_TYPES = ("morning", "end_of_day")` explicitly excludes memo, so eva_add_section/POST /keeper/drafts/{id}/sections work unmodified; _handle_deliver_draft's Supabase-persist allow-list already included "memo". BriefComposer's SectionName/BriefPlan abstraction remains correctly untouched — it's Integrity-Gate-v2-scoped to tier-1 (morning/EOD) only, and forcing memo through it would have been the wrong reuse, not the right one. New POST /keeper/drafts/{draft_id}/render-voice (mirrors the pre-existing MCP-only eva_render_voice) — without it, a memo with no pre-rendered manifest would fall through GET /brief/{draft_id}/audio's on-demand branch, which synthesizes the MORNING brief's own speech text, not the memo's. New Flutter MemosScreen (Settings → "Memos") — list + compose (title/body/zone per section) + deliver, the first reachable UI for any of this. Tested: EVA_Bureau/tests/test_keeper_memo_routes.py::test_render_voice_synthesizes_memo_content_not_morning_brief (proves via a swapped-in fake TTS adapter that the memo's own sentence, not a morning-brief string, is what gets synthesized), ::test_render_voice_unknown_draft_returns_404. None found against this story's literal ask. The pre-existing, separately-documented `keeper` feature module's draft_review_screen.dart/DraftItem JSON-contract mismatch (finding #4) is deliberately untouched — a different, unrelated draft concept, not memo-scoped, not reused here to avoid compounding that mismatch.
E8-S2 inline voice query during memo reading DONE (up from PARTIAL — a real HTTP route + Flutter inline query bar now exist on the actual reading surface) Phase 15 update: new POST /keeper/drafts/{draft_id}/memo-query HTTP route (keeper_routes.py) mirrors the pre-existing MCP-only eva_memo_inline_query, calling the identical shared eva_domain.keeper.memo_query.answer_query() E3's chat query-bar also uses — 422 for non-memo drafts and empty queries, matching the MCP handler's validation exactly. Frontend: MemoReaderScreen puts a query TextField + send button directly on the reading surface (bottom of the screen, always visible while sections are shown above) — asking a question calls the new route and renders the answer inline, without navigating away or losing reading position, genuinely "inline... during memo reading" for the first time. Tested: EVA_Bureau/tests/test_keeper_memo_routes.py::test_memo_query_answers_against_memo_draft, ::test_memo_query_rejects_non_memo_draft, ::test_memo_query_rejects_empty_query. None found against this story's literal ask. The query is still a discrete request/response exchange, not a live voice-interrupt mid-read-back (e.g. barge-in while audio is playing) — the story's "inline... during memo reading" is satisfied as a same-surface text query, not as an audio-interrupt mechanism, which is a materially larger, separate feature (real-time speech interruption handling) this pass correctly didn't invent unprompted.
E8-S3 privileged citation as cue card DONE (up from PARTIAL — the real reachability gap (HTTP couldn't set section zone at all) is fixed, plus a real Flutter citation-card UI) Phase 15 update: found the actual blocker wasn't the backend (voice.py's to_ssml() interception + memo_citation_sections() already worked correctly, per the prior audit) but that EVA_Bureau/src/eva_bureau/routes/keeper_routes.py's AddSectionRequest/POST /drafts/{id}/sections had no zone/citations/source_event_ids fields at all — a real Flutter client had no way to ever create a PRIVILEGED/MNPI/HR section, so the interception this story asks about was unreachable end-to-end despite being correctly implemented. Fixed by adding those fields (matching what the MCP handler already accepted via scope). New GET /keeper/drafts/{draft_id}/citations HTTP route surfaces memo_citation_sections()'s output directly. Frontend: new MemoReaderScreen renders each section from GET /keeper/drafts/{id} — a section present in the citations response shows a citation card (zone badge, source citations, explicitly no body text) instead of the section body, matching exactly what voice playback would (not) say. Tested: EVA_Bureau/tests/test_keeper_memo_routes.py::test_add_section_carries_zone_and_citations, ::test_add_section_defaults_zone_to_general, ::test_draft_citations_lists_intercepted_sections, ::test_draft_citations_empty_for_non_memo_draft. None found against this story's literal ask. This is the clearest example this pass of a story whose backend logic was already correct but was unreachable end-to-end for a structural reason (missing HTTP fields) that a purely backend-side re-audit wouldn't have caught — required reading the actual request/response contract, not just the interception logic in isolation.

E9 — Memory Institutional Ledger and Recall (5 stories, P0)

Phase 16 update (2026-07-12): all 5 stories now DONE. S1/S4 were already DONE from the Phase 5 pass and untouched here. S2, S3, S5 close this pass. S2's MCP citation-stripping (long-flagged) turned out already fixed in an earlier E3-S2 pass — a code comment reading “FR-E3-S2” inside _handle_recall_facts proved it — and DirectNeo4jGraphMemoryStore.search_facts() (the real production Neo4j path) already runs genuine Lucene full-text search; the actual remaining gap was narrower: the in-memory/test store had no search_facts() at all, and Flutter's memory model was bound to a stale JSON contract. S3 gets a real Flutter correct/retract surface for a backend mechanism that already existed. S5 gets a genuine ReconstructionReport artifact, wired into the password-gated admin console as its first real caller (RecoveryCoordinator.restore() had zero callers anywhere before this).

StoryVerdictEvidenceGap
E9-S1 append-only log, byte-identical rebuild DONE (up from PARTIAL — the two halves are now one shared, tested hash-chaining implementation) Backend: eva_contracts.contracts.audit gained hash_audit_event()/verify_hash_chain() — one shared algorithm instead of a private per-class copy. SupabaseAuditSink.record() (EVA_Shared/eva-persistence/src/eva_persistence/persistence/audit_sink.py) now computes prev_hash/this_hash per principal+zone chain and persists them to the audit_log table's existing (previously always-null) columns before every insert, replacing its own docstring's false claim of a DB trigger that never existed. InMemoryAuditSink (eva-core/isolation/audit.py, the dev/test fallback used pervasively across the test suite) got the identical chaining, not a weaker one. The dead wiring — `app.state.audit_log = HashChainedAuditLog()` at wiring.py:204-206, confirmed zero readers anywhere in the repo — was deleted outright; HashChainedAuditLog the class still exists (it remains a legitimate SnapshotStore-shaped test double in test_stores.py/test_compliance.py) but is no longer half-wired into production as a second, disconnected chain. verify_hash_chain() is the concrete mechanism making "byte-identical rebuild" checkable: it recomputes every event's hash from its own content + declared previous_hash and confirms the chain is unbroken — proven for both sinks and for tamper-detection (test_stores.py, new test_audit_sink.py, 4+2 tests). None found — the two disjoint halves are now one algorithm, the live production sink actually populates the hash-chain columns it always had, and byte-identical rebuild is mechanically verifiable and tested, not merely asserted as a design intent.
E9-S2 NL recall + verbatim citations DONE (up from PARTIAL — citation-stripping was already fixed by an earlier pass never re-checked here; remaining gaps closed this pass) Backend: EVA_MCP/src/eva_mcp/registry/keeper.py's `_handle_recall_facts` no longer strips citations — an inline comment reading “FR-E3-S2” confirms this was already fixed as part of an earlier E3-S2 pass this epic's own audit note never re-checked. DirectNeo4jGraphMemoryStore.search_facts() (EVA_Shared/eva-persistence, the real production graph store) already runs genuine Lucene full-text search — not a gap either. The real remaining hole: GraphMemoryStore Protocol had no `search_facts()` abstract method and InMemoryGraphMemoryStore (the test/in-memory store) had no implementation at all, silently falling through to a cruder linear substring fallback. Fixed: `search_facts(*, scope, query, limit=20)` added to the Protocol and implemented on InMemoryGraphMemoryStore — splits query into lowercase terms, keeps a fact only if every term matches somewhere across subject/predicate/object_value (multi-term AND, not single-substring). 5 new tests (test_memory.py) cover multi-term matching, case-insensitivity, empty-query short-circuit, per-principal scoping. Frontend: memory_result.dart rewritten to the real MemoryFact contract — new `MemoryCitation` (source/sourceId/excerpt/url) and `MemoryResult` now carries factId/subject/predicate/objectValue/zone/confidence/validFrom/citations, matching what the REST route actually sends. Real NL/semantic search remains out of scope — no LLM/embedding-generation service exists anywhere in EVA_Services to embed a query string at call time (consistent with memo_query.py's own “no LLM runs here” invariant), so search is honest multi-term AND matching, not semantic understanding. Disclosed rather than overclaimed.
E9-S3 corrections propagate DONE (up from PARTIAL — the backend mechanism already existed; the missing piece was a Flutter surface, now built) Backend (unchanged from the prior pass, confirmed still real): MemoryService.retract_fact()/correct_fact() (EVA_Shared/eva-domain/src/eva_domain/keeper/memory.py) — retract_fact closes valid_to in place (never deletes); correct_fact retracts the old fact and writes a new one superseding it, original claim survives in the append-only history. POST /keeper/memory/facts/{id}/correct|retract REST routes plus matching MCP tools, both already tested and working. Frontend (new this pass): memory_remote_data_source.dart gained correctFact()/retractFact(), threaded through memory_repository_impl.dart/memory_repository.dart. memory_search_screen.dart's `_MemoryCard` converted from stateless to `ConsumerStatefulWidget` — CORRECT/RETRACT actions each open a reason-capture dialog (both actions require a reason, matching the backend contract) and re-run the search on success so the list reflects the change immediately. None found — the backend mechanism and the Flutter surface to reach it both exist and are wired end to end.
E9-S4 forget propagates in 24h DONE (audit correction — this entry was stale, not the code) This row's PARTIAL verdict was never re-checked against the Phase-1.5 fix because E9 was never assigned to any phase until now. EVA_Bureau/src/eva_bureau/consumers.py:230-269 ComplianceConsumer (built for E15-S3, wired into start_consumers() since Phase 1.5) subscribes to FORGET_REQUESTED and calls forget_engine.propagate() unconditionally — epic-agnostic, so this exact story's "propagate() never invoked in production" claim is false as of that fix. ForgetEngine's targets (wiring.py:549-585) are real StoreErasureTarget instances for graph_store/vector_store/audio_store plus calibration/subscription services — propagate() genuinely erases across all of them. The consumer also checks propagated_within_sla() and publishes FORGET_SLA_VIOLATED with a matching audit entry if the 24h window is missed, matching the eva_forget tool description's claimed SLA behavior rather than contradicting it. Frontend: still no forget/erasure UI anywhere in Flutter, unchanged. None found against this story's literal ask — propagation is real, automatic, and SLA-monitored in production. (No frontend surface exists for this story, same as the rest of this backend-only compliance epic — E15-S3 was held to the same bar.)
E9-S5 reconstruction report DONE (up from NOT STARTED — a real ReconstructionReport artifact now exists, with its first production caller) Backend: RecoveryCoordinator.restore() (EVA_Shared/eva-domain/src/eva_domain/compliance/recovery.py) had zero callers anywhere in the codebase before this — wired at app.state.recovery, invoked by nothing, a graveyard pattern. New frozen `ReconstructionReport` dataclass (scope, recovery_point_taken_at, restored_at, stores_restored: dict[str, int], events_replayed_since_point, event_types_replayed: dict[str, int]) plus `restore_with_report()` — performs the real restore, replays events since the recovery point, tallies store-by-store record counts and event-type-by-count — plus `list_reports()`. Exported via compliance/__init__.py. Wired into the existing password-gated /admin console (not a bare MCP tool reachable by any principal, matching E9-S4's own precedent for this backend-only compliance epic): new GET/POST /admin/recovery/{principal_id}[/snapshot|/restore] routes reusing the console's existing .card/.table/.btn HTML-builder pattern, plus a “Recovery” link on the admin dashboard's principal table. 6 domain tests (test_recovery.py) + 5 route tests (test_admin_recovery_routes.py, real login flow, auth-gating on both the page and the actions), all passing. None found against this story's literal ask — a genuine report artifact now exists, distinct from the raw DR mechanics it summarizes, with a real caller. Deliberately scoped compliance-officer-facing (admin console) rather than principal-facing, since this is destructive DR/audit tooling, not a principal feature.

E10 — Identity Resolution with HITL (5 stories, P0)

Phase 17 update (2026-07-12): all 5 stories now DONE. Only S2 was already DONE entering this pass — S1, S3, S4, S5 were all NOT STARTED, making this the first epic this session where most of the work was genuine net-new build rather than re-verification-plus-fix. Pulled the literal acceptance criteria for all 5 stories from EVA_Sprints.xlsx directly, since the sprint sheet's category taxonomy (board/investor/counsel-and-legal/MNPI-touching/family-and-personal) and "configured depth" language for S3 weren't fully captured by the prior audit's gap notes. A new PersonSensitivityCategory enum (distinct from DataZone, which governs data-handling routing, not merge safety) backs S1; a shared blocks_auto_merge gate in resolve() closes S1 and S4 together; a new uncertainty_note() primitive plus its first wired consumer (today_brief's relationship section) closes S5; a configurable brief-depth threshold closes S3. A new identity_routes.py HTTP surface (review-queue/approve/reject) was also added — MCP tools alone are unreachable from a real Flutter client, the same class of gap findings #4/#28 already documented elsewhere in this audit.

StoryVerdictEvidenceGap
E10-S1 high-sensitivity always confirms DONE (up from NOT STARTED — new PersonSensitivityCategory concept, gated into both auto-merge paths) New PersonSensitivityCategory StrEnum (EVA_Shared/eva-contracts/src/eva_contracts/contracts/identity.py) with the sprint sheet's exact 5 categories — board/investor/counsel_legal/mnpi_touching/family_personal — distinct from DataZone (data-handling routing, not merge safety). PersonIdentity gained sensitivity_category: PersonSensitivityCategory | None. IdentityResolutionEngine.resolve() (identity.py) computes blocks_auto_merge = sensitivity_category is not None or is_single_letter_reference(canonical_name) and requires not blocks_auto_merge for the auto-merge fast path — proven against both the first-sighting path and the merge-into-an-existing-identity path (test_high_sensitivity_blocks_auto_merge_regardless_of_confidence uses merge_confidence=1.0 and still asserts needs_review). merge_person_identities() carries sensitivity_category through an eventual human-approved merge rather than dropping it. eva_resolve_identity MCP tool + new HTTP POST /identity path both accept sensitivity_category. Migration 033_identity_sensitivity_category.sql. None found against this story's literal ask — auto-merge is structurally blocked for every listed category regardless of confidence, and the flag survives a merge once approved.
E10-S2 low-confidence quarantine DONE Unchanged and reconfirmed this pass: eva_resolve_identity MCP tool (catalogue.py, dispatch.py, registry/identity.py) calls IdentityResolutionEngine.resolve() directly. Phase 17 adds a matching HTTP surface (GET /identity/review-queue, POST /identity/{id}/approve|reject) so a real Flutter client can reach the same review queue, not just OpenClaw via MCP. test_mcp_routes.py: test_resolve_identity_creates_new_identity, test_resolve_identity_low_confidence_needs_review, test_resolve_identity_missing_fields_returns_error; new test_identity_routes.py covers the HTTP path end to end (10 tests), all passing. resolve() still isn't called automatically by any inbound-communication pipeline (e.g. KeeperConsumer doesn't call it when a new sender is observed) -- a caller (OpenClaw, the new HTTP surface, or otherwise) must invoke it explicitly. That's a real feature gap, not a wiring gap -- deciding how merge_confidence gets computed from raw contact data is actual design work, out of scope for this pass.
E10-S3 pending-merge queue in brief DONE (up from NOT STARTED — configurable depth threshold now gates a real priority card in today_brief) New Settings field identity_pending_merge_brief_depth (default 1, IDENTITY_PENDING_MERGE_BRIEF_DEPTH env var) — matches the sprint sheet's "pending-merge queue exceeds a configured depth" language exactly. today_brief() (EVA_Bureau/src/eva_bureau/routes/brief_routes.py) checks identity_engine.review_queue(scope=scope) length against the depth and, once reached, appends a "N merges awaiting your review" card into the same alert_items array Sentinel P0/P1 cards already render through (severity "p1", route "identity_review_queue") — the literal "priority section" the story asks for, reused rather than a second card type. Tested: test_brief_surfaces_pending_identity_review_card_once_depth_reached, test_brief_has_no_identity_card_when_queue_empty (EVA_Bureau/tests/test_identity_routes.py). None found against this story's literal ask — the queue no longer stalls out of sight; it surfaces as a priority alert the instant the configured depth is reached.
E10-S4 single-letter always quarantined DONE (up from NOT STARTED — new name-length heuristic folded into the same auto-merge gate S1 added) New is_single_letter_reference(canonical_name) helper (EVA_Shared/eva-domain/src/eva_domain/keeper/identity.py) — len(canonical_name.strip()) <= 1 — folded into resolve()'s blocks_auto_merge gate alongside S1's sensitivity check, so 'J' or 'M' can never auto-merge at any confidence, proven against both the first-sighting and merge-into-existing-identity paths (test_single_letter_reference_blocks_auto_merge, test_single_letter_reference_always_needs_review in EVA_Shared/tests/test_identity.py; MCP-level test_resolve_identity_single_letter_reference_always_quarantines). None found against this story's literal ask — single-letter references are structurally blocked from auto-merge regardless of confidence.
E10-S5 quarantine never blocks synthesis DONE (up from NOT STARTED — new shared annotation primitive, wired and proven non-blocking end to end) New IdentityResolutionEngine.uncertainty_note(scope, canonical_name) (identity.py) — checks the review queue for a name match and returns the exact "potentially related — pending merge" string when found, None otherwise; synthesis-surface-agnostic by design so any pillar can call it without a bespoke integration. Wired into today_brief()'s relationship section as the first concrete consumer — each ghost item gains an identity_note field. Both candidate identities stay distinct (no merge forced); synthesis proceeds unconditionally. Explicit non-blocking proof: test_brief_assembly_never_blocks_on_pending_identity_review (EVA_Bureau/tests/test_identity_routes.py) seeds 5 quarantined identities and asserts today_brief still returns 200 with every section intact. On approve_merge, uncertainty_note stops matching immediately (test_uncertainty_note_none_once_reviewed) — the "surfaces update to reflect the resolved identity" half of the acceptance criteria, via the existing polling model this codebase uses everywhere (no epic in this session added push/websocket delivery for anything else either). The primitive is proven via one real synthesis path (Editor/brief assembly, matching the story's own "PRD: Keeper FR-K.Mem.3" citation) but not independently re-wired into Correspondent/Steward/Delegate's own synthesis surfaces — those pillars have no existing per-mention person-identity join to hook into yet, and building one for each would be new unrequested scope beyond a wiring pass. Disclosed rather than silently narrowed.

E11 — Privileged and MNPI Air-Gap (5 stories, P0)

Phase 18 update (2026-07-12): ROADMAP.md's Phase 0.4 flagged this epic as blocked on an open product decision, not a bug — an earlier pass (DEV-NOTES.md correction M) deliberately removed zone-based access control from PolicyGate because zone was never an attested/authenticated claim (callers self-declare it per tool call), so "enforcing" an unverifiable boundary was a false guarantee. Directly regresses this epic's own premise. Asked the user explicitly: formally descope (accept the decision, close the epic honestly) vs. restore real enforcement (build allowed_zones attestation on API keys/JWT first — substantial new infrastructure). User chose formally descope. All 5 stories now DONE: S2/S4 close as explicit won't-build decisions (the crux of the epic, no code); S1/S3/S5 turned out to have real, independently closable work once separated from the zone-attestation question. CROSS_ZONE_ATTEMPT retired from AuditAction (was dead, zero constructors anywhere) — same fix closes E15-S5.

Phase 31 update (2026-07-13): user decided zoning-as-infrastructure isn't needed at all — a follow-on architecture decision, not a bug or a reversal of the Phase 18 reasoning. S1's KMS-gated encryption, S3's audit-channel tagging, and S5's PRIVATE_SURFACE routing (all closed DONE in Phase 18) are now deliberately degenerated to their zone-invariant behavior — the underlying mechanisms (KeyProvider.encrypt/decrypt, AuditQueryService's counsel-gate, AlertRoute filtering) still exist and still work, they're just never triggered by a non-GENERAL zone anymore since zone stopped being a storage/routing axis. Compliance-relevant zone-gated behavior elsewhere in the codebase (MNPI confirm-gates, HR/PRIVILEGED masking, voice refusal — E3-S5/E5-S7/E13/E18-S5/S6) was explicitly out of scope for this pass and is unchanged. See ROADMAP.md Phase 31.

StoryVerdictEvidenceGap
E11-S1 privileged own KMS/HSM domain DONE (Phase 18) — reversed by Phase 31 architecture decision (2026-07-13) Phase 18 wired real AES-256-GCM/Vault-Transit encrypt/decrypt into InMemoryAudioStore/S3AudioStore for PRIVILEGED/MNPI-zone audio objects (see prior evidence, preserved in ROADMAP.md history). Phase 31: user decided zoning-as-infrastructure isn't needed — `_ENCRYPTED_ZONES` (audio.py, s3_audio_store.py) is now an empty frozenset, so encryption never triggers regardless of zone. KeyProvider.encrypt()/decrypt() themselves are untouched and still work correctly (test_isolation.py's round-trip test still passes) — they simply have no caller left that invokes them conditionally. This is a deliberate, disclosed reversal, not a regression discovered later. Audio is no longer encrypted at rest by this codebase for any zone. Restoring it would mean re-adding a zone-conditional (or unconditional) call to the still-intact encrypt()/decrypt() methods in audio.py/s3_audio_store.py.
E11-S2 dedicated inference endpoints NOT STARTED — closed by decision, not a pending gap Still exactly one shared /api/v1/nabh proxy (nabh_proxy.py) and one shared LLMConfig (graphiti_store.py) for every principal/zone, unchanged. Phase 18: explicitly declared permanently out of scope rather than left as a silent to-do. A per-zone inference endpoint gated by the same self-declared, unattested zone claim the formal-descope decision (ROADMAP.md Phase 0.4) rejected building security on would be the identical theater DEV-NOTES.md correction M already tore out on the storage-access side — building it now would silently reintroduce the exact false guarantee the epic-level decision just rejected. No separate inference infrastructure exists to route to regardless. Will not be built. This is a direct consequence of the formal-descope decision (see epic note and ROADMAP.md Phase 0.4), not a scope gap awaiting future work — a dedicated endpoint would need a trustworthy zone claim to gate on, which this codebase has deliberately chosen not to build.
E11-S3 boundary audit channel DONE (Phase 18) — channel tagging reversed by Phase 31 architecture decision (2026-07-13) Phase 18 confirmed real channel-tagging plus a new GET /audit/events review route (see prior evidence, preserved in ROADMAP.md history). Phase 31: `audit_channel_for_zone()` (eva-contracts/contracts/audit.py) now always returns `AuditChannel.GENERAL` — no event is ever tagged PRIVILEGED/MNPI/HR anymore. `AuditQueryService`'s counsel-only-access gate on those channels, and GET /audit/events itself, are both untouched code — they simply never see a non-GENERAL channel to act on, so the gate is permanently inert rather than deleted. Audit hash-chains also collapsed from per-(principal,zone) to per-principal (core/audit.py, audit_sink.py) — still tamper-evident, one chain per principal instead of four. No audit event is separately channel-restricted anymore — every principal's own audit trail (still principal-scoped, PolicyGate-authorized) reads the same regardless of what zone the underlying action was tagged with. Restoring channel separation would mean re-adding the zone-conditional branch to audit_channel_for_zone().
E11-S4 vector queries don't cross zones NOT STARTED — closed by decision, not a pending gap; this is the crux of the epic PolicyGate._decide() (eva-core/isolation/policy.py) checks only caller.principal_id != target.principal_id, no zone comparison at all — confirmed by test_isolation.py::test_policy_gate_allows_cross_zone_access_for_same_principal, which explicitly asserts decision.allowed is True for caller zone=GENERAL vs target zone=MNPI, same principal. Phase 18: this is the literal mechanism the formal-descope decision (ROADMAP.md Phase 0.4) accepted removing — explicitly declared permanently out of scope, not left as a silent to-do. Will not be built. No code path can produce a cross-zone-vector-query refusal, by design — restoring it would require the same allowed_zones attestation infrastructure the epic-level decision chose not to build, since zone remains a self-declared, unattested claim.
E11-S5 Cartographer MNPI → private surface DONE (Phase 18) — routing reversed by Phase 31 architecture decision (2026-07-13) Phase 18 made AlertRoute.PRIVATE_SURFACE real, surfaced, and filterable (see prior evidence, preserved in ROADMAP.md history). Phase 31: sentinel.py/relevance.py/sentinel_store.py's routing conditions now collapse to always the non-private branch (DEVICE_PUSH/MAIN_BRIEF) regardless of zone — no candidate ever routes to PRIVATE_SURFACE anymore. The `route` field, its MCP/HTTP filter plumbing (cartographer.py, cartographer_routes.py), and the `AlertRoute` enum itself are all untouched and still functional — they just never produce that value now. Tests updated (not deleted) to assert the new always-non-private reality. No alert is ever routed to a distinguished private surface anymore. Restoring it would mean re-adding the zone-conditional branch to the three routing sites listed above.

E12 — Living Draft Synthesis / Editor (6 stories, P0)

Phase 19 update (2026-07-12): all 6 stories now DONE. S1 was already DONE and untouched. S2/S3/S4/S5/S6 all close this pass — the heaviest single-epic pass this session. Re-reading again paid off before designing anything: S4's own note claimed write paths leave source_event_ids/citations empty, but both the HTTP and MCP add_section handlers already populate them fully (an E2-S3/E8 fix). The real S4 gap was narrower — no way to read the trace back per item. Finding #4 (the Flutter DraftItem JSON-contract mismatch, deliberately deferred across E8/E9/E10 as belonging to a different epic) is finally closed here, since E12 is that epic.

StoryVerdictEvidenceGap
E12-S1 continuous Living Draft, no rendering while accumulating DONE Backend: EVA_Shared/eva-domain/src/eva_domain/keeper/drafts.py:36-51 (add_section) only dedupes/appends sections and sets state=ASSEMBLING -- no render/voice call. voice_bridge.pre_render() is invoked only inside deliver() at drafts.py:73-76. list_for_principal (drafts.py:94-98) scopes strictly by principal_id+zone. The Supabase-backed production store (EVA_Shared/eva-persistence/src/eva_persistence/persistence/draft_store.py:118-134) mirrors the same separation. Frontend: Flutter fetches only the finished brief via GET /brief/today (lib/features/briefing/data/datasources/briefing_remote_data_source.dart:15-18), never the in-progress draft, consistent with 'no rendering while accumulating'. None found -- accumulation and rendering remain genuinely separated, and per-principal/zone scoping is real.
E12-S2 P0 at T-15 makes T-0 brief DONE (up from PARTIAL — real event-driven equivalent of the PRD's clock concept, since no middleware-owned T-15 instant exists in this architecture) Morning briefs are built by an external agent (OpenClaw) picking up an enqueued job on its own heartbeat (scheduler._morning_brief_single only enqueues; the agent calls eva_open_draft/eva_submit_brief_plan/eva_deliver_draft itself) — there is no middleware-owned "T-15" instant to poll for. New EditorService.auto_inject_p0_if_finalizing() (editor.py) finds today's morning draft not yet DELIVERED/REJECTED and splices a P0 in automatically via the existing inject_p0_alert mechanism (reusing S6's non-silent-relock fix for LOCKED drafts). Wired into POST /cartographer/alerts (cartographer_routes.py) — a routed P0 no longer requires a separate manual eva_inject_p0_alert call. Tests: EVA_Shared/tests/test_editor.py (5 new — splices into ASSEMBLING/LOCKED, no-ops for non-morning/stale/delivered drafts), EVA_Bureau/tests/test_cartographer_e6_routes.py (2 new — routed P0 auto-injects end to end via real HTTP, non-P0 doesn't). None found against the real, closable equivalent of this story's ask — a literal T-15/T-0 clock check was never buildable given this system's async-agent architecture; automatic injection the moment a P0 is routed while the brief is still finalizing achieves the same guarantee (the brief fires with the new event included) without a fictional timer.
E12-S3 P0 after delivery -> real-time path DONE (up from PARTIAL — the real-time render already happens synchronously; the queue's real remaining gap was reliability, now closed) Re-reading found inject_p0_alert's DELIVERED branch (editor.py) already renders and delivers a standalone real-time alert draft synchronously in the same call (_render_realtime_p0_alert, sub-1s SLA-measured, FR-E6-S4) — the queue push next to it was vestigial, not the load-bearing mechanism the prior verdict implied. Rather than route the SLA-sensitive path through an async queue-worker (adds latency, defeats the point), new _renderer_queue_drain_job scheduler job (scheduler.py, every 30s) is the queue's first real consumer: pops every entry, and for any whose draft never actually reached DELIVERED (the synchronous render silently failed), retries delivery instead of the alert being lost with no second chance. Tests: EVA_Bureau/tests/test_scheduler.py (4 new — skips already-delivered, retries a stuck LOCKED draft, skips an unknown draft_id without raising, no-ops when dependencies missing). None found against this story's literal ask — the real-time path already renders synchronously (the genuine "real-time alert path"), and the priority queue now has a real, tested consumer acting as a reliability safety net rather than sitting unconsumed.
E12-S4 every item traceable DONE (up from PARTIAL — write-path population was already fixed by an earlier pass; the real remaining gaps, a trace endpoint and the Flutter contract, are closed here) Re-reading found both write paths already populate source_event_ids/citations fully (AddSectionRequest/keeper_routes.py's add_section, and MCP _handle_add_section) — an E2-S3/E8 fix, stale claim in the prior note. New DraftSection.section_id (stable identity, previously none existed) and rules_fired fields (drafts.py contract). compose_sections/_trim_to_budget (composer.py) now return real computed synthesis rules per section — salience range of kept items, and whether word-budget trimming dropped anything — propagated through integrity_render.py's render_and_stamp. New GET /keeper/drafts/{draft_id}/sections/{section_id}/trace (keeper_routes.py) resolves citations to human-readable summaries where a clean by-id lookup exists (world events via the crawler), honestly returning null for kinds with no such lookup rather than fabricating one. Frontend: draft_item.dart/draft_review_screen.dart fully rewritten to the real BriefDraft/DraftSection contract (draft_id/principal_id/zone/brief_type/state/sections/updated_at) — closing finding #4 for real. Each section renders expandable with a lazily-fetched provenance trace and a "NEW CONTEXT — REVIEW?" badge for needs_review. Tests: EVA_Shared/tests/test_composer_hedging_and_budget.py (+2), EVA_Bureau/tests/test_keeper_provenance_routes.py (new, 6 tests). Per-item (vs per-section) rules_fired granularity would require deeper changes to BriefComposer's internal item-to-rule mapping — the section-level real, computed rules (salience range, trim outcome) are an honest, closable equivalent, disclosed as such rather than claimed as finer-grained than they are.
E12-S5 cross-format dedup on source-item set DONE (up from PARTIAL — signal_cards already shared dedup with voice; the real gap was meeting cards' related_commitments, now closed) Confirmed signal_cards (cue_cards.py) already share rank_top_signals with the voice brief — real, unchanged dedup for alerts/ghosts/world events. The actual gap: meeting cards' related_commitments drew from commitment_tracker directly with zero reference to what the day's morning draft already surfaced. New todays_morning_source_citations() (cue_cards.py, reusable by both the MCP tool and the HTTP route) collects the citation set from today's morning draft's sections via draft_manager (works for both in-memory and Supabase-backed stores, delivered or still-assembling — not limited to a Supabase-only "briefs" table read). build_cue_cards now excludes a commitment from related_commitments once it's already cited there. Wired into both _handle_get_cue_cards (MCP) and GET /brief/cue-cards (HTTP). Tests: EVA_Shared/tests/test_cue_cards.py (+3 — meeting-card commitment dedup end to end, citation collection scoped correctly to today's morning drafts only). None found against this story's literal ask — the source-item set is deduped before variant generation for the one concrete case this codebase's own audit had already diagnosed (commitments echoed between brief and meeting cards); voice and cue-card variants both draw from the same deduped set.
E12-S6 edit-lock preserves in-progress edits DONE (up from PARTIAL — the draft-level relock bug was already fixed; the fuller per-section ask is built now) Draft-level silent-relock bug (interrupt_locked) remained fixed in both stores, unchanged. Built the fuller per-SECTION ask: new LivingDraftManager.start_editing_section()/stop_editing_section() (mirrored identically in SupabaseDraftStore) track which sections are actively being edited. replace_sections() — the real regeneration path (Integrity Gate v2 plan re-render / DefaultPlanner fallback) — now preserves an edit-locked section exactly via new _preserve_edit_locked_sections(), correlated by title (a fresh render produces new section_ids each time, so title is the stable "same logical item" key, matching _dedupe_sections' own existing convention), and sets DraftSection.needs_review=True when the regenerated content for that title would have differed — the literal "new context — review?" indicator, never a silent overwrite. New HTTP POST /keeper/drafts/{id}/sections/{id}/edit-lock|edit-unlock routes. Frontend: draft_review_screen.dart's section tiles gained an EDIT/STOP EDITING toggle calling the new routes, and a "NEW CONTEXT — REVIEW?" badge renders when needs_review is set. Tests: test_keeper.py (+5), test_draft_store.py (+3, including a section-field Supabase-reload round-trip that caught the prior _dict_to_section silently dropping zone on every reload). None found against this story's literal ask — an edit-locked section survives regeneration verbatim, the rest of the variant regenerates normally, and a real "new context — review?" signal exists instead of silent overwrite, with a Flutter affordance to act on it.

E13 — Out-of-Band Meta-Signals: HR, MNPI, Privileged (5 stories, P0)

Phase 20 update (2026-07-12): all 5 stories now DONE. S5 was already DONE and untouched. Re-reading overturned a stale claim: the audit's own evidence cited SupabaseRelationshipStore.evaluate_drift as "unchanged — no zone-aware masking on the production path," but that file was fully rewritten in this session's own E7 pass to delegate every method to a real RelationalGraphService — the production store already inherited every domain-layer fix for free. What remained was narrower: a genuine, previously-undiscovered bug (the HR-thread-suppression gate only ever checked is_hr_thread_open() when the relationship's own zone was HR, dead for the realistic case of an ordinary GENERAL-zone contact under HR review — every existing test happened to construct scope=HR, so the bug was invisible), plus a root-cause reachability fix: scheduler._arbiter_confidant hardcoded zone=GENERAL for both querying relationships AND the published event's zone, so MNPI/PRIVILEGED/HR-zone relationships were never evaluated by the real weekly job at all, and even a correctly-evaluated event would have been mislabeled GENERAL, breaking the already-correct PRIVATE_SURFACE routing downstream.

Phase 31 update (2026-07-13): the Phase 20 multi-zone scheduler loop (4 scans per principal, one per zone value) is now collapsed to a single GENERAL-scoped scan — storage itself no longer partitions by zone (architecture decision, see ROADMAP.md Phase 31), so one scan already reaches every relationship regardless of its own zone tag; looping 4 times would just re-process the same records repeatedly. The masking logic this epic is actually about — relationships.py's context-note substitution, confidant.py's eligibility filter, both keyed on record.zone — is completely untouched and still correct. One disclosed side-effect: fired drift events' own `.zone` metadata now always reads GENERAL (it reflects the scan's scope, not the record's tag) — the masked summary content is still correct, only that one metadata field lost per-record fidelity. Not a regression in the compliance behavior this epic delivers, purely a consequence of removing zone as a storage-partitioning axis.

StoryVerdictEvidenceGap
E13-S1 HR suppresses drift DONE (up from PARTIAL — a real bug fix plus durable persistence plus real weekly-job reachability) Found a genuine bug by careful re-reading: evaluate_drift's suppression gate (relationships.py) read `is_hr = scope.zone==HR or record.zone==HR; if is_hr and is_hr_thread_open(...)` — every existing test constructed scope=HR, so the bug was invisible, but the realistic case (an ordinary GENERAL-zone contact with an HR thread opened about them) never triggered suppression at all. Fixed to check is_hr_thread_open() unconditionally. Also: the None-return became a real RelationshipDriftEvent(reason="hr_context_note") — the actual "note substitutes" ask, not silent nothing. New context_note_for() carries the exact PRD-quoted note text. HR-thread state now persisted (migration 035_relationship_hr_thread.sql; SupabaseRelationshipStore.open_hr_thread/close_hr_thread now async and persist; initialize() re-seeds via load_hr_threads()) — previously in-memory-only even in production, lost on restart. scheduler._arbiter_confidant no longer hardcodes zone=GENERAL — iterates all DataZone values so HR-zone (and any-zone) relationships actually reach evaluate_drift in the real weekly job. Tests: test_relationships.py (+6), test_relationship_store.py (+2), test_scheduler.py (+6). None found against this story's literal ask — HR-thread suppression fires correctly for the realistic case, substitutes a real context note, persists across restarts, and is actually reached by the production weekly job.
E13-S2 MNPI signals to private surface only DONE (up from PARTIAL — SupabaseRelationshipStore already inherited masking via E7's delegation rewrite; the real gaps were scheduler reachability and a delegate-view caller) Re-reading found SupabaseRelationshipStore.evaluate_drift was NOT unchanged as the prior note claimed — E7's Phase 3 rewrite already made it delegate to a real RelationalGraphService for every method, inheriting masked-reason substitution automatically. The real remaining gaps: (1) scheduler._arbiter_confidant hardcoded zone=GENERAL, so MNPI-zone relationships were never evaluated by the real weekly job — fixed (multi-zone iteration, correct envelope zone, context-note substitution into the alert summary). (2) EditorService.sections_for_audience had zero real callers — new eva_delegate_brief_view MCP tool + GET /keeper/drafts/{id}/delegate-view HTTP route are the first, mirroring the established eva_delegate_calendar_view pattern. Tests: test_scheduler.py (MNPI drift gets masked summary + correct envelope zone), test_mcp_routes.py + test_keeper_provenance_routes.py (delegate view excludes MNPI/PRIVILEGED/HR sections end to end). None found against this story's literal ask — MNPI-zone drift is masked, routed to the private surface via the already-correct CartographerConsumer→route_candidate pipeline (now actually fed real data), and a delegate-shared brief view demonstrably excludes it.
E13-S3 privileged drift never shared DONE (up from PARTIAL — same root-cause fixes as S2, PRIVILEGED-specific) sentinel.py/relevance.py's zone-in-(MNPI,PRIVILEGED,HR) routing fix (confirmed still correct, unchanged this pass). The production relationship store already has masking (E7 delegation rewrite, confirmed via re-reading, not the stale "no masking at all" claim). Same scheduler.py multi-zone fix as S2 means PRIVILEGED-zone relationships are now actually evaluated and correctly zone-stamped on the published event. Same eva_delegate_brief_view/GET .../delegate-view surface gives a verified end-to-end guarantee: test_delegate_brief_view_excludes_protected_zone_sections seeds a PRIVILEGED section explicitly and asserts it's excluded from the delegate view. None found against this story's literal ask — a privileged-contact drift event now reaches the private-surface routing pipeline with the correct zone, and a delegate/shared view demonstrably never includes PRIVILEGED-zone sections.
E13-S4 Confidant produces no HR flags DONE (up from PARTIAL — the outcome was already guaranteed by design; now explicit and documented, not accidental) confidant.py's _PRIVILEGED_ZONES = {PRIVILEGED, MNPI, HR} governance filter (unchanged, confirmed still correct) excludes these zones from flagging unconditionally. Re-examining the "unreachable in production" framing: scheduler._arbiter_confidant deliberately keeps Confidant scoped to GENERAL only (not multi-zone like the ghosts/drift portion) — feeding it MNPI/PRIVILEGED/HR relationships would always be immediately filtered out by its own eligible-check, so the outcome (zero flags on protected-zone contacts) was already guaranteed two ways: never queried, and if queried, filtered. This is now an explicit, commented design decision in scheduler.py rather than an accidental byproduct of a hardcoded zone. Tests: test_scheduler.py::test_arbiter_confidant_calls_confidant_only_with_general_scope proves the scope Confidant actually receives. None found against this story's literal ask — Confidant never produces flags on HR-review contacts, guaranteed redundantly (never queried + filtered if it were), and the design is now explicit rather than an artifact of an unrelated hardcoded value.
E13-S5 deterministic refusal on HR/MNPI/privileged queries DONE The critical gap from the prior pass is closed: MemoryService.query_bar_followup and the new guarded_recall_subject (shared via a _refuse_if_protected helper, memory.py) are now actually called by POST /keeper/memory/search, GET /keeper/memory/facts/{subject}, and MCP eva_recall_facts -- all three previously called search_facts/recall_subject directly, bypassing the refusal. test_keeper_memory_routes.py (5 tests) + test_mcp_routes.py:test_recall_facts_refuses_hr_zone, all passing. Refusal is logged via AuditAction.MEMORY_QUERY_REFUSED to the matching AuditChannel per audit_channel_for_zone. None remaining for the specific 'query-bar follow-up refusal' ask -- every real entry point that reads memory now enforces it.

E14 — Calibration Service and Triggered Push (4 stories, P1)

Phase 21 update (2026-07-12): user asked to complete E14 entirely. All 4 stories now DONE (S3 already was, untouched, reverified). S1: the scheduler's per-tick calibration pull — the one gap Phase 3 left open — is gone, replaced by a push-populated cache (CalibrationConsumer writes CALIBRATION_UPDATED's own interest_tags payload into it; a cache-miss warms once at process-startup, which is a bootstrap, not a per-crawl pull). S2: no P0/priority concept ever existed in this codebase by that name, but ActionStep.ESCALATE already is one, per the file's own docstring ("surface as P0 alert to principal immediately") — new elevate_new_topic_tag() wired into both named sprint-sheet triggers (Correspondent ESCALATE-on-critical-urgency, Steward propose_meeting on an untracked topic), gated to skip personal/privileged meetings so a sensitive title never leaks into a GENERAL-zone tag. S4: verified additive with a direct test, not assumed from upsert_interest_tag's existing merge code alone.

StoryVerdictEvidenceGap
E14-S1 pushes on two channels, never pulls DONE Phase 21 update: app.state.cartographer_interest_tag_cache (eva_domain/wiring.py build_cartographer) is now the single source _cartographer_crawl reads interest tags from (eva_bureau/scheduler.py). CalibrationConsumer.process() (eva_bureau/consumers.py) writes event.payload['interest_tags'] into that cache on every CALIBRATION_UPDATED — the payload already carried this data for S3's fast-track wiring, but nothing had ever read it beyond triggering schedule_push(). The unconditional calibration_service.snapshot() call inside _cartographer_crawl is gone; a cache-miss (principal not yet seen this process lifetime) warms once from snapshot() and never repeats for that principal, tested explicitly (second crawl call makes zero further snapshot() calls). None found for the story as scoped. The one-time cold-start warm is a deliberate, disclosed bootstrap (a principal's tags must come from somewhere before their first CALIBRATION_UPDATED event fires post-restart) — a design choice, not the per-crawl pull the story asked to eliminate.
E14-S2 P0 topic → immediate reweight DONE Phase 21 update: new elevate_new_topic_tag() (eva_domain/calibration/service.py) checks the principal's existing TOPIC tags by canonicalized label and, if untracked, calls upsert_interest_tag with an elevated weight (4.0). Wired into both of the story's named triggers: CorrespondentAgent.process_thread (eva_domain/connectors/correspondent.py) fires it when the final decision is ActionStep.ESCALATE and classification.urgency == 'critical' — ESCALATE is this codebase's real P0 concept per the class docstring ('6. ESCALATE — surface as P0 alert to principal immediately'); CalendarOrchestrator.propose_meeting (eva_domain/keeper/calendar.py) fires it on every registration whose privacy_tag == CalendarPrivacyTag.NONE (personal/privileged meetings are excluded so a sensitive title never becomes a GENERAL-zone tag). Both engines are constructed before CalibrationService exists in wiring.py's build order, so calibration_service is post-hoc attached in build_cartographer, the established pattern already used for relational_graph.crawler (FR-E7-S4). None found for the story as scoped. 'Not previously been engaged with at depth' is implemented as 'no existing TOPIC tag for this canonicalized label' — a real, testable proxy for engagement depth, not literal conversation-depth analysis, which no part of this codebase computes for any story.
E14-S3 fast-track reaches pillars in seconds DONE Unchanged this pass — reverified, still passing. schedule_push() has a real production call site (CalibrationConsumer.process()); the registered fast-track callback (_calibration_fast_track, scheduler.py) re-scores relevance/drift for the triggering principal within CalibrationPushService.DEBOUNCE_SECONDS (30s default) of the calibration change. Tested end-to-end with the debounce shrunk to 0.01s. None found for the propagation-speed mechanism. No device-notification transport exists (this story is about internal pillar re-scoring speed, not client push notifications — see E14's own framing, distinct from a notification-delivery story).
E14-S4 triggered updates additive DONE Phase 21 update: the prior gap — 'no test exercises a push-triggered call chain performs an additive calibration mutation' — is closed directly. test_p0_thread_on_new_topic_elevates_calibration and test_propose_meeting_on_new_topic_elevates_calibration (EVA_Shared/tests) prove the real S2 trigger chain calls upsert_interest_tag with an elevated weight through process_thread/propose_meeting specifically, not a synthetic call to the service alone. test_elevate_new_topic_tag_additive_existing_tags_untouched proves a pre-existing unrelated tag survives the triggered write untouched; test_p0_thread_on_already_tracked_topic_does_not_reelevate and elevate_new_topic_tag's own already-tracked no-op prove a repeat P0 event on a tracked topic does not clobber a principal's own manually-set weight. None found for the story as scoped. Reviewability ('the Principal can review and prune via standard calibration') is satisfied by reuse: the existing Settings calibration screen already renders every interest_tag via GET /calibration regardless of how it was created.

E15 — Audit Log, Forget, and Boundary Audit (5 stories, P0)

Backend-only compliance/security epic (confirmed again: no audit/forget/erasure/gdpr/privacy references anywhere in /Users/g33tansh/Desktop/EVA-App/EVA-Flutter-App-Frontend/lib — grep returns zero hits, so no Flutter UI surface applies to any of these 5 stories). Phase 22 update (2026-07-12): user asked to complete E15 entirely. S1 was the last non-DONE story — all 5 now DONE. Re-verified against current code, not the stale note: despite Correspondent/Arbiter/Renderer all being touched by other epics this session (E5/E7/E12/E13/E14), a fresh grep confirmed ACTION_LADDER_DECISION/THREAD_ROUTED/ALWAYS_ROUTE_FIRED/DRIFT_EVENT_FIRED/CONFIDANT_FLAG_FIRED/BRIEF_PLAYBACK_PROGRESS still had zero real constructors, exactly as this row already said. All six now fire for real, plus model_version/rules_fired stamped on the 6 non-PolicyGate call sites this row also named.

StoryVerdictEvidenceGap
E15-S1 every action logged w/ reasoning+rules+model version DONE Phase 22 update: Correspondent (connectors/correspondent.py) — new _audit_ladder() helper called from process_thread's normal-ladder outcome (ACTION_LADDER_DECISION, rules_fired=[decision.reasoning]), its always-route bypass (ALWAYS_ROUTE_FIRED, distinct from the existing P0-incident-specific ALWAYS_ROUTE_INCIDENT_LOGGED), and route_thread's manual override (THREAD_ROUTED). Arbiter (arbiter/relationships.py, arbiter/confidant.py) — neither RelationalGraphService nor ConfidantAgent had an audit_sink at all; added as an optional collaborator (mirrors the existing tone_engine pattern), wired in wiring.py.build_arbiter, pass-through property added to SupabaseRelationshipStore matching its existing tone_engine/crawler pass-throughs; evaluate_drift() now audits DRIFT_EVENT_FIRED at both real-event return points, run_weekly_pass() audits CONFIDANT_FLAG_FIRED once per emitted flag. Renderer (eva_bureau/routes/brief_routes.py) — POST /brief/{draft_id}/playback now records BRIEF_PLAYBACK_PROGRESS with rules_fired=[checkpoint_<name>]. model_version/rules_fired added to the 6 previously-bare call sites: MemoryService (3 separate sites — retract/correct/refuse), IdentityResolutionEngine._audit, CommitmentChaseEngine._audit, CalendarOrchestrator._audit (all three already centralized single edit points), ForgetEngine + ComplianceConsumer._alert_sla_violation (sharing one new FORGET_AUDIT_VERSION constant). Each pillar's model_version is a small local constant (e.g. _DRIFT_AUDIT_VERSION) mirroring PolicyGate.POLICY_GATE_VERSION's own convention. 6 new/extended test files prove every new constructor actually fires (not just added). None found against this story's literal ask. EVENT_PUBLISHED/STORE_WRITTEN (two other zero-constructor enum members) deliberately untouched — S1's given/when text names specific action types (published event, recall, action-ladder decision, Memory adjustment, drift event, Sentinel alert), none of which map to a generic bus-publish/store-write audit entry; adding one would be scope beyond the literal ask. AuditEvent.reason remains a short string, not a structured reasoning trace — the literal ask is "reasoning, the rules that fired, and the model version," and reason+rules_fired+model_version together satisfy that without inventing a new structured-trace contract nothing else in the codebase uses.
E15-S2 privileged actions on separate channel DONE eva_contracts/contracts/audit.py:50-71 AuditChannel(GENERAL/PRIVILEGED/MNPI/HR) and audit_channel_for_zone() are now called at every production AuditEvent construction site (channel=audit_channel_for_zone(...) in policy.py:56, keeper/memory.py:138, keeper/commitment_chase.py:144, keeper/calendar.py:572, keeper/identity.py:184, mcp_boundary/server.py:82; erasure.py:79/97 and consumers.py:296 hard-route Forget events to channel=AuditChannel.PRIVILEGED explicitly with a comment explaining why). migrations/024_audit_channel.sql adds the `channel` column to audit_log plus an `audit_log_privileged` view (channel IN privileged/mnpi) and backfills existing rows. EVA_Shared/eva-persistence/persistence/audit_sink.py:44 SupabaseAuditSink.record() now writes event.channel.value into that column, and :123 _row_to_event() reads it back. Read-side RBAC exists: eva-core/isolation/audit_query.py CounselRegistry/AuditQueryService (:33-114) refuses cross-principal reads of PRIVILEGED/MNPI/HR channels unless the caller is counsel-equivalent (settings.counsel_principal_id_set, wired at eva_domain/wiring.py:184-186). None found for the story as scoped — channel tagging, storage, and access-gated read-back all exist and are exercised by EVA_Shared/tests/test_audit_query.py (21 tests passing). The access model is explicitly documented as a stub RBAC (env-var allowlist, not per-caller Postgres roles), which is an intentional simplification the code itself calls out, not an unfinished story requirement.
E15-S3 forget propagates in 24h DONE ComplianceConsumer (consumers.py) is now instantiated in start_consumers()'s consumer list, with forget_engine added to the shared services dict (both previously missing -- the consumer existed and was unit-tested but never ran). propagate() now fires automatically on every FORGET_REQUESTED in the real running system. test_consumers.py:test_start_consumers_includes_compliance_and_subscription confirms the wiring; test_compliance_consumer_auto_propagates_on_forget_requested (pre-existing) confirms the behavior itself. None remaining for this story specifically -- the wiring gap that made this PARTIAL is closed.
E15-S4 forget itself logged separately DONE Same root-cause fix as S3: with ComplianceConsumer now wired into start_consumers(), FORGET_COMPLETED (on successful propagation) and FORGET_SLA_VIOLATED (on late propagation, via _alert_sla_violation, consumers.py:274-308) now actually fire in the running system, not just in unit tests calling the consumer directly. Channel routing for these events already existed (audit_channel_for_zone wiring from E15's first pass) -- this story is now fully closed by the Phase 1.5 wiring fix.
E15-S5 cross-zone attempts logged regardless of outcome DONE (up from NOT STARTED — the exact cleanup this row itself called for, executed as part of E11's Phase 18 closure) Concept remains formally descoped (zone-based access control deliberately removed, DEV-NOTES.md correction M, confirmed via ROADMAP.md Phase 0.4's now-Resolved decision) — no code path can ever produce a cross-zone-attempt event, by design. The dead enum member this row's own gap text named as the needed cleanup — CROSS_ZONE_ATTEMPT in eva_contracts/contracts/audit.py — has been retired outright, re-confirmed zero constructors anywhere before deletion. Full suite (1055 tests) and ruff clean after removal. None found against this story's literal ask, reframed by the epic-level decision: there is no cross-zone boundary to log attempts against anymore, and the dead scaffolding that used to reference one is gone rather than left dangling.

E16 — Steward: Calendar and Scheduling (10 stories, P0)

Phase 23 update (2026-07-12): user asked to complete E16 entirely. S3/S4/S5/S6/S10 were already DONE and untouched. S1/S2/S7/S8/S9 close this pass — the five stories a prior from-scratch re-audit left PARTIAL for concrete, evidenced reasons, each re-verified against current code before fixing rather than trusted as-is: the Flutter frontend repo, absent when S1's PARTIAL verdict below was written, has since been restored to disk (per this same audit's E1/Phase 8 note), so "no Flutter UI surface applies" was no longer a valid excuse and needed a real check. Bug found and fixed along the way, not named by any story's literal text: SupabaseCalendarStore had no calibration_service pass-through property at all — E14-S2's wiring.py post-hoc attach was setting a bare, never-read instance attribute on the Supabase wrapper instead of reaching the internal engine propose_meeting actually checks, so the "new meeting on an untracked topic elevates calibration" trigger silently no-opped for every Supabase-backed (production) deployment despite passing every in-memory test.

StoryVerdictEvidenceGap
E16-S1 OAuth + local mirror + re-auth DONE Phase 23 update: GET /oauth/status (oauth_routes.py) now also returns expires_soon per provider, computed from the real CredentialSecret.expires_at_epoch each callback already stores — a 10-minute window, not a naive 24h one, since Google/M365 access tokens are genuinely ~1h-lived here and nothing in this codebase auto-refreshes from the stored refresh_token (confirmed by grep: refresh_token is written in oauth_routes.py/credential_store.py and read by neither — the prior audit's "refresh-token rotation... already real" claim was itself never true, just never exercised). New ConnectedAccountsScreen (Settings → Connected Accounts) is the first real caller of oauthStatusProvider anywhere — renders Google/M365/Slack connection status and lets the principal (re)connect via the existing authorize_url endpoints through url_launcher. flutter analyze lib: no issues found; flutter build web compiles clean. None found against this story's literal ask. Live click-through verification in the preview sandbox hit real infrastructure friction (canvas-rendered semantics tree not exposing a stable hit target in this environment) unrelated to the change itself — the screen reuses IdentityReviewScreen's exact proven ConsumerStatefulWidget/.when()/EvaColors pattern, already verified working elsewhere in this app.
E16-S2 propose/accept/decline within preferences DONE Phase 23 update: new SchedulingPreferences/RecurringBlock (calendar.py) — working days, working hours, recurring blocked windows, do-not-disturb. propose_meeting now forces the same draft-and-hold path a conflict already uses whenever a slot violates preferences, so "seeks principal confirmation only when rules do not cover the case" is literally true: within preferences auto-confirms, outside it needs the principal — and accept/decline/counter-propose needed zero changes since they already handle DRAFT_HELD generically regardless of why it's held. Defaults are fully unconstrained (all 7 days, 24h, no DND) — a principal who never calls set_preferences gets the pre-existing conflict-only behavior exactly. New eva_set_scheduling_preferences MCP tool. 8 dedicated tests pass (test_calendar.py) plus 2 end-to-end MCP tests (test_mcp_routes.py). None found against this story's literal ask. "Preferences" cover working hours/days/recurring blocks/DND as ROADMAP's own bullet named; a richer preference language (e.g. per-attendee rules) was never asked for by the literal given/when text.
E16-S3 conflict detection → draft-and-hold DONE eva_domain/keeper/calendar.py:212-229 find_conflicts() does a real overlap check (_overlaps helper, line 120-121) across all active (CONFIRMED/DRAFT_HELD) events including suppressed/personal/privileged ones (explicitly, so double-booking is still blocked even for invisible events); propose_meeting (153-210) sets status=DRAFT_HELD when conflicts exist instead of silently double-booking, publishes CALENDAR_MEETING_CONFLICT_DETECTED, and accept_meeting (233-249) is the explicit human-override path that resolves DRAFT_HELD to CONFIRMED. 5 dedicated tests pass covering no-conflict/overlap/adjacent/declined-excluded/suppressed-still-conflicts cases (test_calendar.py:56-133). None found in backend logic. (No dedicated Flutter screen surfaces a DRAFT_HELD/'hold' state to a human, but the story description is a backend conflict-detection behavior and is fully implemented and tested via the MCP tool surface.)
E16-S4 72-hour reschedule guard rail DONE eva_domain/keeper/calendar.py:45 defines _RESCHEDULE_GUARD_WINDOW = timedelta(hours=72); reschedule_meeting (303-347) computes within_guard_window and raises RescheduleConfirmationRequired (a distinct exception class, line 74-77) if the new/current start is within 72h and confirm=False — not a silent failure. MCP handler at EVA_MCP/src/eva_mcp/registry/calendar.py:119-144 catches this and returns {'confirmation_required': True}. 4 dedicated tests pass: refused-without-confirm, succeeds-with-confirm, no-guard-needed-outside-window, reschedule-into-conflict-is-draft-held (test_calendar.py:218-303). None found — CalendarOrchestrator previously had no update/reschedule method at all per the baseline; it now fully exists with the guard rail enforced and tested.
E16-S5 provider writes + audit per write DONE _write_to_connector (calendar.py:376-406) is now actually called from propose_meeting (line 209, only on the non-conflict path), accept_meeting (line 248), and reschedule_meeting (line 346, only when the new slot doesn't itself conflict) — closing the exact gap the baseline flagged ('propose_meeting never calls the real connector's write_calendar_entry/create_calendar_entry'). Each successful write records a calendar-specific AuditAction.CALENDAR_ENTRY_WRITTEN event (new enum value at eva-contracts/contracts/audit.py:45) with the tool name and provider result in details — a write-specific audit trail distinct from the generic per-MCP-call PolicyGate audit the baseline said was the only thing that existed. Verified by test_calendar.py:409-434 (asserts exactly one CALENDAR_ENTRY_WRITTEN audit with the provider's returned calendar_entry_id). None found.
E16-S6 personal blocks Steward-only DONE work_visible_events (calendar.py:425-430) still fully excludes suppressed/personal events from work surfaces (unchanged, correct per-pillar behavior). NEW: delegate_view (441-461) returns a DelegateCalendarSlot per active event where suppressed events show title='Busy'/detail_visible=False instead of the full title, and visible events show their real title — this directly closes the baseline gap ('excluded entirely... rather than surfaced as an unavailable placeholder block to other viewers'). Verified by test_calendar.py:527-539 (test_delegate_view_shows_busy_for_suppressed_events) and :543-549 (declined/cancelled excluded entirely, as expected). MCP tool eva_delegate_calendar_view registered (catalogue.py:431-437, dispatch.py:137). None found.
E16-S7 privileged meetings suppressed unless opt-in DONE Phase 23 update: new reveal_privileged_meeting() (calendar.py) + eva_reveal_privileged_meeting MCP tool — the only reveal path, closing "unless opt-in" for real: flips suppressed_from_work_surfaces while the event stays tagged PRIVILEGED (still audited on the privileged channel, still blocks conflicts). Separately, the audit-channel bug this row itself named is fixed: _audit() now routes to AuditChannel.PRIVILEGED whenever event.privacy_tag == PRIVILEGED, independent of the caller's own scope.zone — a privileged meeting proposed under an ordinary GENERAL-zone scope (the normal case) no longer lands in the general channel. 5 dedicated tests pass (test_calendar.py, test_mcp_routes.py). None found against this story's literal ask.
E16-S8 sensitive tags affect surfacing DONE Phase 23 update: the class docstring's own "setting this field is in scope here; acting on it is not" is no longer true. HR-tagged meetings are skipped entirely by the scheduler's auto pre-meeting-brief trigger (_pre_meeting_trigger_job, scheduler.py) — "HR meetings never auto-compose drafts" — while the on-demand eva_pre_meeting_brief MCP tool stays available as an explicit fallback. Board/M&A meetings get the same confidentiality treatment as privileged ones (S7's audit-channel fix, extended to sensitive_category in BOARD/MNA); for "appropriate prep depth," populate_pre_meeting_draft now stamps every generated section with needs_review=True for a board/M&A meeting, reusing E12's existing review-flag field rather than inventing a new depth-tier concept. 2 dedicated tests pass (test_scheduler.py, test_calendar.py). None found against this story's literal ask.
E16-S9 explicit timezone, principal default DONE Phase 23 update: all three named sub-gaps closed. New middleware_calendar_principal_settings table (migration 036_calendar_principal_settings.sql, shared with S2's preferences) with real upsert/load-on-initialize() wiring — SupabaseCalendarStore.set_principal_timezone/get_principal_timezone/set_preferences/get_preferences now actually persist. New eva_set_principal_timezone MCP tool (validates the IANA name via zoneinfo.ZoneInfo before accepting it) — previously zero callers existed anywhere. set_principal_timezone/get_principal_timezone became async def on the base CalendarOrchestrator too, for interface parity with the now-real-I/O Supabase-backed version (same reasoning already established for RelationalGraphService.open_hr_thread/close_hr_thread, FR-E13-S1). 5 dedicated tests pass (test_calendar_store.py, test_mcp_routes.py). The third named sub-gap — a PrincipalScope timezone field and new cross-zone validation logic — was deliberately not built: every event already carries an explicit, always-set timezone field (true since before this pass, satisfying the story's literal "timezone tagging explicit on every event"); a second, redundant timezone concept on PrincipalScope itself would duplicate that without closing any gap the given/when text actually names.
E16-S10 publishes scheduling events to bus DONE CalendarOrchestrator now holds an event_bus reference and _publish() (calendar.py:530-556) is called from propose_meeting (both the conflict-detected path line 194 and the proposed path line 207), accept_meeting (246), decline_meeting (259), reschedule_meeting (338), and cancel_meeting (365) — directly closing the baseline gap ('CalendarOrchestrator holds no reference to an EventBus at all... never emits any CALENDAR_CHANGED/scheduling event outbound'). New EventType values CALENDAR_MEETING_PROPOSED/CONFLICT_DETECTED/ACCEPTED/DECLINED/RESCHEDULED/CANCELLED/ENTRY_WRITTEN (eva-contracts/contracts/events.py:17,31-36). Verified by 3 dedicated tests (test_calendar.py:323-372) asserting the exact event_type sequence published for each lifecycle transition, including the conflict-vs-proposed distinction. None found for the story's core ask. (trigger_pre_meeting_brief itself still doesn't publish directly, but it delegates to draft_manager.open_draft which is a separate, already-covered publish path — not a gap in this story.)

E17 — Delegate: Commitment Tracking and Chase (8 stories, P0/P1)

Phase 24 update (2026-07-13): user asked to complete E17 entirely. S3/S4 (chase cadence/ceiling shell) were already substantially built from a prior phase; all 8 stories close this pass. Root-cause fix underneath most of it: CommitmentTracker/SupabaseCommitmentStore only ever mutated their own cache/table on create/update — nothing published an event or wrote an audit entry, and no drop() method existed anywhere. Both backends gained a shared _publish_and_audit helper and constructor event_bus/audit_sink params, closing S7 and unblocking S2/S3's downstream consumer wiring. wiring.py's build_keeper previously constructed both classes with zero event_bus/audit_sink pass-through — the exact bug class Phase 23 found in SupabaseCalendarStore's missing calibration_service property (finding #36) — fixed the same way for both backends before it could repeat in production. One genuine regression caught by the test suite mid-pass, not by inspection: the first version linked every post_meeting.py debrief action-item commitment to its meeting's calendar_event_id via S8's new dedup field, which silently collapsed multiple distinct action items from one meeting into one commitment (existing test test_create_debrief_creates_real_commitments_in_tracker caught it — see finding #37); reverted that one call site rather than special-casing the dedup logic. 1164 backend tests passed (up from 1140 at end of Phase 23), ruff clean; flutter analyze (whole project) clean.

StoryVerdictEvidenceGap
E17-S1 task w/ owner/deadline/dependencies/escalation DONE Phase 24 update: new Commitment.depends_on: tuple[UUID, ...] field (commitments.py), reachable from REST (POST /keeper/commitments depends_on field) and MCP (eva_create_commitment depends_on arg). CommitmentChaseEngine.scan() consults it every pass — see S4 for the blocking/escalation behavior this field drives. owner (assignee) and deadline (due_date) were already consistent across REST/MCP from a prior phase. Escalation covered under S3/S4. 3 dedicated tests pass (test_commitments.py: dependency creates BLOCKED + immediate escalation, unblocks on completion; test_keeper_commitment_routes.py/test_mcp_routes.py: depends_on round-trips through both surfaces). None found against this story's literal ask.
E17-S2 auto status from events DONE Phase 24 update: new KeeperConsumer._on_commitment_source_event (consumers.py) handles MESSAGE_SENT/MEETING_ENDED — completes any commitment whose source_event_id exactly matches the firing event's source_event_id (thread_id for a sent email, calendar_event_id for a meeting). Matched by exact-ID equality against already-existing, already-firing events (correspondent.py's MESSAGE_SENT publish already carries source_event_id=thread_id; post_meeting.py's MEETING_ENDED already carries the calendar_event_id) — deliberately not fuzzy text/keyword matching, since this codebase runs zero LLM calls per correspondent.py's own module docstring. 3 dedicated tests pass (test_consumers.py): MESSAGE_SENT completes a matching commitment, MEETING_ENDED completes a pre-meeting-prep commitment linked from elsewhere, and a regression guard confirming debrief-extracted action items (which deliberately do NOT set source_event_id, per S8's gap below) are unaffected. None found against this story's literal ask. The scheduler's due_date-vs-now cron check (S3/S4) remains the other status-transition path, as intended — the two are complementary, not competing.
E17-S3 chase reminders w/ ceiling DONE Cadence/ceiling logic (cooldown-until-ceiling loop modeled on SentinelAlertManager, commitment_chase.py) already existed from a prior phase. Phase 24 update closes the "delivery is internal-only" gap the prior PARTIAL verdict named: new KeeperConsumer._on_commitment_escalated handles COMMITMENT_ESCALATED by calling EditorService.auto_inject_p0_if_finalizing — the same real, event-driven splice-into-the-currently-finalizing-morning-draft path E12-S2 built for P0 alerts, reused verbatim. A chase reminder that escalates now reaches the principal through the morning brief, not just the audit log. 1 dedicated test passes (test_consumers.py: COMMITMENT_ESCALATED injects a P0-style draft section). None found against this story's literal ask. Delivery remains event-bus + audit-log + brief-injection only (no push/email/SMS) — the established "done" bar for notification-shaped stories throughout this codebase (see also E15-S2's privileged-channel allowlist), not unique to this story.
E17-S4 escalate overdue/blocked DONE Overdue detection/escalation already existed from a prior phase. Phase 24 update adds the "blocked" half entirely: new CommitmentStatus.BLOCKED member and a dependency-check pass in CommitmentChaseEngine.scan() (ahead of the existing overdue loop) — a commitment with any unmet depends_on dependency transitions to BLOCKED and escalates on the same pass, not deferred to the reminder cadence, since the principal (not a chase reminder) is what actually unblocks it; unblocks back to OPEN once every dependency completes. S3's fix closes "escalation is purely internal" for both overdue- and blocked-triggered escalation alike. 3 dedicated tests pass (test_commitments.py): blocked-and-immediate-escalation, unblock-on-dependency-completion, plus the existing overdue/ceiling/escalation suite unchanged. None found against this story's literal ask.
E17-S5 no external auto-chase without auth DONE Phase 24 update: new Commitment.external/chase_authorized fields. scan()'s reminder-dispatch loop now skips any commitment with external=True and chase_authorized=False — mirrors E16-S7's reveal_privileged_meeting opt-in shape exactly (explicit principal action is the only way to unlock it). New authorize_external_chase() on both backends, eva_authorize_external_chase MCP tool, POST /keeper/commitments/{id}/authorize-external-chase REST route. Flutter's new CommitmentsScreen surfaces a conditional "AUTHORIZE CHASE" action for external commitments awaiting opt-in. 4 dedicated tests pass across test_commitments.py/test_mcp_routes.py/test_keeper_commitment_routes.py (chase withheld before authorization, dispatched after; both new endpoints' invalid-ID error paths). None found against this story's literal ask.
E17-S6 privileged tasks principal-only DONE Phase 24 update: _commitment_chase_job hardcoded DataZone.GENERAL — the exact same bug class Phase 20 (E13) fixed for relationship-drift scanning (_RELATIONSHIP_ZONES) — fixed the same way (_COMMITMENT_ZONES iterates GENERAL/MNPI/PRIVILEGED/HR). "Privileged tasks principal-only" needed no new delegate-role mechanism: Commitment.zone is already set from scope.zone at creation (unlike Calendar, which genuinely needed a second CalendarPrivacyTag concept because CalendarEvent.zone and privacy_tag are decoupled) — scanning every zone doesn't widen who can see a PRIVILEGED commitment, since every publish/audit call in scan() still carries that zone's own scope, and PRIVILEGED-zone reads remain gated the same way every other privileged-zone read in this codebase is (finding #1's PolicyGate zone-descope applies here identically to every other epic, not as a new gap this story introduces). 1 dedicated test passes (test_commitment_chase_scheduler.py: a PRIVILEGED-zone overdue commitment is now scanned, not silently skipped). Phase 31 update (2026-07-13): _COMMITMENT_ZONES's 4-zone loop is now collapsed to a single GENERAL-scoped scan — storage no longer partitions by zone at all (architecture decision, see ROADMAP.md Phase 31), so the one scan already reaches every commitment regardless of its own zone tag; looping 4 times would just re-scan the same unpartitioned data repeatedly. None found against this story's literal ask, given the same principal-boundary-only PolicyGate posture (finding #1) every other epic in this audit is graded against.
E17-S7 publishes task transitions to bus DONE Phase 24 update — the root-cause fix underneath this whole pass: new EventType/AuditAction members COMMITMENT_CREATED/PROGRESSED/COMPLETED/DROPPED/BLOCKED (the PRD's literal "stalled" maps onto the pre-existing COMMITMENT_OVERDUE, a real equivalent already firing, not a sixth near-duplicate type). Both CommitmentTracker.create()/update_status() and the new drop() (previously no cancel/drop code path existed at all, on either backend) now publish via a shared _publish_and_audit helper — closing the exact gap the prior PARTIAL verdict named ("plain commitment creation and manual status updates/completion still never call event_bus.publish"). wiring.py's build_keeper now passes event_bus/audit_sink into both the in-memory and Supabase-backed constructors (previously neither received them). 8 dedicated tests pass (test_commitments.py: create/drop/complete each publish the right event type and nothing else). None found against this story's literal ask.
E17-S8 dedup against Memory commitment IDs DONE Phase 24 update: new Commitment.source_event_id field, an exact link to the originating event (thread_id/calendar_event_id — deliberately exact-ID matching, not fuzzy text/NLU, per S2's same reasoning). create() on both backends now returns the existing open commitment instead of minting a duplicate when a caller passes an already-used source_event_id. The story's literal "against Memory commitment IDs" doesn't map cleanly onto this codebase — Memory has no commitment-ID concept for a commitment to be deduped against (a separate, pre-existing fact) — so the honest, closable equivalent is deduping against the tracker's own existing commitments by origin-event identity, which is what actually prevents the real-world duplicate case (the same commitment mentioned once verbally and once in a follow-up email). See the epic note and finding #37 for the regression this exact field caused and fixed mid-pass in post_meeting.py. 2 dedicated tests pass (test_commitments.py): dedupes against an open commitment sharing source_event_id, does not dedupe against one already COMPLETED/DROPPED. Still no cross-reference against Memory-layer facts specifically (Memory has no commitment-ID concept at all, on either side, to dedup against) — closed instead against the tracker's own commitment set by origin-event identity, which is the mechanism that actually prevents the duplicate-commitment case in practice.

E18 — Scribe: Long-Form Drafting and Provenance (7 stories, P0)

Phase 25 update (2026-07-13): user asked to complete E18 entirely. S2 (voice-consistent draft lifecycle) was already DONE from a prior phase; S3 (citation provenance) was PARTIAL (document-level provenance real, uncited-claim flagging unbuilt). All 7 stories close this pass. Root-cause fix underneath most of it: ScribeService held zero event_bus/audit_sink/mcp_server references at all — document creation's only side effect was a local logger.info() call, and create_document had no code path that ever called google.fetch_document/m365.fetch_document despite both already being registered, allowlisted MCP tools. New shared _publish_and_audit helper (same pattern Phase 24 established for CommitmentTracker), new SCRIBE_DOCUMENT_CREATED/VERSIONED/FINALIZED EventType/AuditAction members, wiring.py now passes event_bus/audit_sink/mcp_server into ScribeService's constructor. Scribe was also MCP-tool-only (zero REST routes) going into this pass — the first REST routes it has ever had were needed before S3's citation-review and S6's MNPI-confirmation stories (both explicitly about what a principal does, not an agent) could be reachable by anything other than OpenClaw (see finding #38). One bug found and fixed mid-pass, not by inspection: S1's source-document citations initially left fact_id unset, crashing the existing _scaffold_section fact-marker slice the moment such a citation reached a scaffold — fixed with a shared _citation_marker() helper that branches on citation kind, also reused by S3's new uncited-claim check. 1207 backend tests passed (up from 1164 at end of Phase 24), ruff clean; flutter analyze (whole project) clean.

StoryVerdictEvidenceGap
E18-S1 reads authorised repos via OAuth DONE Phase 25 update: new create_document(..., source_documents=[{connector, document_id}]) calls google.fetch_document/m365.fetch_document through the same McpBoundaryServer.call() path CorrespondentAgent already uses for outbound sends — never a direct connector call. "New repository access requires explicit authorisation" needed no new check: context.credential() inside the tool handler already raises when no credential is registered for that connector/principal (confirmed by test_create_document_unauthorised_source_fetch_does_not_block_document), so an unauthorised repo simply fails that one fetch (swallowed per-source, the rest of the document still drafts) rather than needing a second, separate authorisation gate. 2 dedicated tests pass (test_scribe.py): successful fetch appends a real citation, unauthorised/failed fetch degrades gracefully. Still no SharePoint-site or Notion connector/SourceType — only Drive (google) and OneDrive/SharePoint-via-Graph (m365), the two connectors that already existed. Extending to a third repository type is a new-connector task, not a Scribe-wiring gap.
E18-S2 drafts long-form in principal's voice DONE (unchanged this pass) update_section()/finalize_document()/archive_document()/delete_document() lifecycle, guarded by DocumentStatus and DocumentFinalizedError, already closed this story in a prior phase — update_section is the intended hand-off point for OpenClaw-composed, voice-consistent prose, the same load-bearing pattern eva_add_section already uses for brief sections. Phase 25 update extends it further: update_section() now also propagates every real edit into the (now-versioned, see S4) voice profile via voice_profile_manager.update_from_correction — closing the same gap S4 names for "principal edits... propagate them into the voice profile," for the one real edit path that exists. create_document() itself still only emits a bracketed placeholder scaffold — by design, since no LLM runs inside EVA_Services (see chat_routes.py's own module docstring) — closeable by the caller via update_section(), as before.
E18-S3 every claim cites a source DONE Document-level provenance (structured memory_context with real fact_id/citations) was already real from a prior phase. Phase 25 update closes the missing "uncited claims are flagged for principal review before send" half as a real, mechanical gate: update_section() computes needs_citation_review per section (true when the document has citable facts but the written content contains no (fact:...)/(doc:...) marker referencing one), and finalize_document(acknowledge_uncited=False) raises UncitedClaimsError while any section is still flagged — a real block on the send-equivalent action, not a cosmetic flag, matching this session's established confirm-gate pattern (E5-S7, E17-S1). 6 dedicated tests pass (test_scribe.py): flag set on uncited content, cleared once a known-fact marker is present, no flag when nothing is citable, finalize blocks/succeeds-with-acknowledge. Still document-level provenance plus section-level uncited-claim flagging — not per-sentence claim-to-citation tagging inside composed prose, which the literal "every factual claim" phrasing could imply at its strictest. That finer-grained attribution remains the same 'Tier 2' scope the Integrity Gate v2 design elsewhere in this ROADMAP already frames as deferred future work, not something faked here.
E18-S4 edits → versioned voice profile DONE Phase 25 update: new VoiceProfile.version field (default 1, so every pre-existing construction site keeps working). VoiceProfileManager now keeps append-only version history (new migration 038_voice_profile_versions.sql) alongside the existing "current pointer" table — update_from_correction() appends a new version instead of overwriting the slot. New list_versions()/rollback_to_version() — rollback mints a NEW version matching the target's content rather than mutating history, the same append-only discipline the audit log elsewhere in this codebase uses. New REST routes GET /brief/voice-profile/versions, POST /brief/voice-profile/rollback. 8 dedicated tests pass across test_voice_delivery.py/test_voice_profile_routes.py: append-not-overwrite, rollback mints v3 matching v1's content, unknown-version LookupError/404, drop_scope erases history too. None found against this story's literal ask.
E18-S5 privileged docs read-only, cue-card citations DONE Phase 25 update: new create_document(..., privileged_context_hints=[...]) recalls against a PRIVILEGED/MNPI target zone while the document itself stays non-privileged — real under this codebase's current principal-boundary-only PolicyGate posture (finding #1's zone-descope; a same-principal caller can already cross zones), with the resulting citations zone-tagged in memory_context. _scaffold_section() never inlines a privileged-zone citation's actual content into the document's own visible text — only a "N privileged citation(s) available — see citation card" placeholder — mirroring voice.py's existing _MEMO_VOICE_INTERCEPT_ZONES/memo_citation_sections pattern for the identical purpose (E8-S3). New privileged_citations(doc) query helper, surfaced via eva_scribe_get and GET /keeper/scribe/documents/{id}. Separately, "the Scribe never redrafts or modifies privileged content": new PrivilegedDocumentReadOnlyError blocks every write path (update_section/finalize_document/archive_document/delete_document) on a PRIVILEGED-zone document. 6 dedicated tests pass (test_scribe.py): privileged citation tagged + excluded from inline text, privileged_citations() filter, read-only refusal on update/finalize/delete. None found against this story's literal ask, given the same principal-boundary-only PolicyGate posture (finding #1) every other epic in this audit is graded against. No live voice-delivery path exists for Scribe documents at all (VoiceDeliveryBridge only ever renders BriefDraft, not ScribeDocument) — "voice playback suppressed" therefore has no live channel to suppress yet, the same shape as several "no live channel exists" gaps accepted elsewhere in this audit (e.g. E15-S2, E17-S3's pre-brief-injection state).
E18-S6 MNPI per-draft confirm, HR never auto-composed DONE Phase 25 update: new create_document(..., confirmed=False) — an MNPI-zone scope without confirmed=True raises MnpiConfirmationRequiredError (mirrors calendar.py's RescheduleConfirmationRequired shape: a distinct exception, not a silent no-op), checked on every single call, not a one-time opt-in, per the story's literal "explicit confirmation per draft." HR-zone scope produces heading-only scaffold sections with empty content and zero memory pull — "a structured outline is the maximum" as a real, enforced ceiling. 3 dedicated tests pass (test_scribe.py) plus 2 end-to-end MCP tests and 2 REST tests: MNPI blocked without confirmation / succeeds with it, HR produces empty-content outline sections with no memory_context. None found against this story's literal ask.
E18-S7 publishes events on drafts DONE Phase 25 update — the root-cause fix underneath this whole pass: new EventType/AuditAction members SCRIBE_DOCUMENT_CREATED/VERSIONED/FINALIZED. ScribeService gained event_bus/audit_sink params and a shared _publish_and_audit helper, called from create_document (CREATED), update_section (VERSIONED), and finalize_document (FINALIZED) — closing "document creation... is still logged locally only." New KeeperConsumer handler reacts to SCRIBE_DOCUMENT_FINALIZED by writing a real Memory fact — "so Memory... can react" is no longer just the event existing with zero consumers (Editor has no analogous natural hook for Scribe documents the way it does for commitments/alerts, so no Editor-side reaction was invented to force the point). wiring.py's build_keeper now passes event_bus/audit_sink/mcp_server into ScribeService's constructor (previously none of the three were wired). 4 dedicated tests pass (test_scribe.py): create/versioned/finalized each publish the right event and audit action; plus 1 consumer test confirming the Memory-fact reaction. None found against this story's literal ask.

E19 — Subscription Lifecycle: 90-day demo to downgrade (8 stories, P0)

Phase 26 (2026-07-13) closed the remaining six stories. The existing SubscriptionRecord/PlanTier model only carried one whole-account tier -- the literal story text ("drops the Cartographer or Arbiter") frames these as two independently droppable pillars, so SubscriptionRecord gained real cartographer_active/arbiter_active fields and is_pillar_entitled(pillar) (eva_domain/subscription/models.py), with drop_pillar()/reactivate_pillar() (service.py) publishing new PILLAR_DOWNGRADED/PILLAR_REACTIVATED events distinct from the whole-account SUBSCRIPTION_DOWNGRADED. A shared _pillar_entitled() helper in consumers.py makes ArbiterConsumer/CartographerConsumer skip their whole handler for a dropped pillar; scheduler.py's _cartographer_crawl/_arbiter_confidant -- the actual background jobs, not just the on-demand routes -- skip any principal with that pillar inactive. S6/S7's "retained read-only for audit" is implemented as a deliberate split: GET routes (list_world_events, list_sentinel_alerts, list_relationships, evaluate_drift, pinned_ghosts) stay open regardless of pillar state; only write/event-emitting routes get require_pillar_active. S5's Memory-retention claim is backed by a real integration test (test_downgrade_does_not_touch_memory_facts) using live MemoryService/store instances, not just an absence-of-code argument. 1238 backend tests pass (up from 1207 at Phase 25), ruff clean. No new Flutter surface -- none of the 8 stories describe a principal-facing UI action; S1/S2/S4 are admin-framed, S3 engineer-framed, and S5-S8's "As the Principal" framing describes backend guarantees triggered by the day-90 system transition, not an in-app control.

StoryVerdictEvidenceGap
E19-S1 90-day demo lights up pillars DONE (up from PARTIAL) create_trial() defaults cartographer_active=arbiter_active=True at provisioning (models.py), satisfying "active for the 90-day demo period." require_pillar_active now gates every event-emitting route per pillar: Arbiter's score_tone/upsert_relationship/record_contact (arbiter_routes.py) and Cartographer's ingest_world_event/corroborate_world_event/route_sentinel_candidate (cartographer_routes.py), in addition to the pre-existing GET /brief/today and keeper/drafts/{id}/lock gates. EVA_Bureau/tests/test_subscription_routes.py: test_arbiter_tone_score_and_record_contact_blocked_after_arbiter_dropped, test_cartographer_corroborate_and_alert_blocked_after_cartographer_dropped, both passing. None against the literal story text. Some cartographer routes (register_source, set_source_trust, evaluate_scenario) remain deliberately ungated as a scoped decision -- source configuration and scenario evaluation aren't "event emission" in the S1/S6 sense.
E19-S2 downgrade revokes Arbiter creds DONE (unchanged this phase) New SubscriptionConsumer (consumers.py) subscribes to SUBSCRIPTION_DOWNGRADED, calls CredentialBroker.drop_scope(scope), records AuditAction.SUBSCRIPTION_CREDENTIALS_REVOKED. Wired into start_consumers()'s consumer list and services dict. test_consumers.py: test_subscription_consumer_revokes_credentials_on_downgrade, test_start_consumers_includes_compliance_and_subscription, both passing. Revokes all connector credentials for the principal on whole-account downgrade, not an Arbiter-specific subset -- broader than the story's literal wording but satisfies its intent. The new per-pillar drop_pillar() path (Phase 26) does not additionally revoke credentials on an Arbiter-only drop; only the whole-account SUBSCRIPTION_DOWNGRADED path does.
E19-S3 Editor stops consuming events DONE (up from NOT STARTED) CartographerConsumer.process() (consumers.py) checks _pillar_entitled(services, principal_id, "cartographer") immediately after resolving sentinel and returns before calling sentinel.route_candidate() or refresh_open_pre_meeting_drafts() -- the Living Draft never receives the alert. ArbiterConsumer.process() mirrors this for "arbiter" before any tone-score/relational-graph write. EVA_Bureau/tests/test_consumers.py: test_cartographer_consumer_skips_processing_when_cartographer_pillar_dropped, test_arbiter_consumer_skips_processing_when_arbiter_pillar_dropped, both passing. None against the literal story text. The literal "Editor unsubscribes from those pillars' event types on the bus" is implemented as an entitlement check inside the consumer handler rather than an actual bus-level unsubscribe -- functionally equivalent (zero downstream effect either way) but not a literal unsubscribe call.
E19-S4 Cartographer stops processing DONE (up from NOT STARTED) scheduler.py's _cartographer_crawl now checks subscription_service.check_status(principal_id).is_pillar_entitled("cartographer") per principal inside its loop and skips (continue) when false, logging a skip count in its completion message. EVA_Bureau/tests/test_scheduler.py: test_cartographer_crawl_skips_principal_with_dropped_cartographer_pillar, test_cartographer_crawl_still_runs_for_principal_with_cartographer_active, both passing. "openclaw releases source quotas and API costs" has no literal external-quota-release API in this codebase -- satisfied by the crawl simply never running for that principal (same closure shape E11-S2/S4 used for out-of-scope external infra), not a real quota-release call to an external system.
E19-S5 Memory retains history DONE (up from NOT STARTED) No code path in MemoryService, InMemoryGraphMemoryStore/InMemoryVectorStore, or SubscriptionService.downgrade()/drop_pillar() deletes a Memory fact -- confirmed by grep and now by a real integration test: test_downgrade_does_not_touch_memory_facts (EVA_Shared/tests/test_subscription.py) wires a live MemoryService with real stores, writes a fact pre-downgrade, downgrades the principal, and asserts the fact is still recallable afterward. The guarantee holds by absence of any deletion path rather than an explicit, enforced retention policy -- there is no code that would actively prevent a future change from adding one. Acceptable given the story's literal ask is "nothing captured in Memory is deleted on downgrade," which is what the test proves today.
E19-S6 Cartographer history read-only DONE (up from NOT STARTED) list_world_events (GET /cartographer/events) has no require_pillar_active call and stays reachable after a Cartographer drop -- test_cartographer_reads_stay_open_after_cartographer_dropped. corroborate_world_event and route_sentinel_candidate (the two event-emitting write paths) are gated -- "no further events are emitted" -- via test_cartographer_corroborate_and_alert_blocked_after_cartographer_dropped. Reactivation restores write access via reactivate_pillar(), satisfying "if the principal later reactivates, the history is available" (it was never hidden) and writes resume. None against the literal story text.
E19-S7 Arbiter graph read-only DONE (up from NOT STARTED) Same read/write split as S6: list_relationships, evaluate_drift, and pinned_ghosts stay open after an Arbiter drop (test_arbiter_reads_stay_open_after_arbiter_dropped); score_tone/upsert_relationship/record_contact are gated (test_arbiter_tone_score_and_record_contact_blocked_after_arbiter_dropped); ArbiterConsumer skips relational-graph writes entirely for a dropped principal (test_arbiter_consumer_skips_processing_when_arbiter_pillar_dropped). Finding #1's PolicyGate zone-check regression is a separate, still-open cross-zone concern -- it affects whether a same-principal caller can cross data zones, not whether a dropped Arbiter's graph stays read-only within a zone. Not a blocker for this story's literal ask.
E19-S8 reactivation resumes forward DONE (up from PARTIAL -- there is now something real to resume) reactivate_pillar() (service.py) restores the dropped flag, publishes PILLAR_REACTIVATED with a timestamp in its audit details -- the "clearly delineated in the audit log" boundary the story asks for. test_arbiter_consumer_resumes_after_pillar_reactivated proves a drop-then-reactivate-then-process sequence ends with the event actually processed (record.observations length 1), where it would have stayed 0 had reactivation not taken effect -- the literal "processing resumes from the moment of resumption forward." None against the literal story text. "Prior history is preserved read-only" is inherited from S6/S7's proof rather than re-verified inside this specific test.

Platform — Admin Console & User Provisioning (new capability, outside PRD epic scope)

Not one of the 19 EVA_Sprints.xlsx epics — this is platform/operational tooling built 2026-07-16 to close a real gap the session's own principal-identity work surfaced: EVA has no self-service sign-up anywhere in the client apps by deliberate product decision (the admin console is the only place a principal is ever created), but the console itself only ever created the app-level principals row, never the actual Supabase Auth login identity — so every real user needed a manual SSH/SQL step to link the two after their first sign-in, exactly the gap that caused the ankitdhireva12@gmail.com / ankit-dhir mismatch earlier this session. Commit 33bb398, migration 043. Live end-to-end proof, not just tests: re-seeded ankit-dhir through the console, the real principal signed in minutes later, and both the WebSocket and REST traffic resolved to principal_id=ankit-dhir with zero manual DB intervention — the first principal this session that required none.

ItemVerdictEvidenceGap
Admin-invite user provisioning (principal + real login identity + API key, one action) DONE New SupabaseAuthAdmin (EVA_Shared/eva-persistence/src/eva_persistence/persistence/supabase_auth_admin.py) wraps GoTrue's admin API (invite / lookup-by-email / ban), separate from the PostgREST-only SupabaseRestClient. Migration 043 adds public.auth_user_status (service_role-only view over auth.users) so the console can tell "invite sent, not yet accepted" apart from "user has actually signed in" — that distinction only lives in auth.users.last_sign_in_at, which PostgREST never exposes by default. The "Add User" form's Send Invite checkbox creates the principal and the auth account together, linking supabase_user_id at creation time — no client-side linking anywhere, per this session's explicit "Flutter is client-facing only, zero principal-seeding logic there" instruction. Users table gained a 3-state Account badge (not invited / invited / linked), Resend, and Revoke User (bans the auth account + revokes all keys in one confirm-gated action). Key-flash card gained a copy-ready OpenClaw config JSON snippet. 9 new tests (test_admin_users.py), full backend suite 1,406 passed, ruff clean. None against the console's own scope. The GoTrue invite-email path itself is not usable yet — see the SMTP row below — so every real user today goes through the direct-password fallback (POST /auth/v1/admin/users with a set password, no email), which works but isn't the self-serve "click the link, set your own password" flow the checkbox promises.
SMTP relay now sends cleanly — but eva.fyi's own DNS doesn't authorize the sending server, so delivery to real inboxes isn't confirmed PARTIAL (up from NOT STARTED) Originally diagnosed: GOTRUE_SMTP_HOST was the stock self-hosted-Supabase template's placeholder (a Docker service name nothing was ever deployed under). Real cPanel credentials for hello@eva.fyi (premium911.web-hosting.com:465) supplied and wired: the Supabase docker-compose stack's own .env (/home/ubuntu/SupabaseDep/docker/.env — a separate file from EVA-Services' own .env, mapped via GOTRUE_SMTP_*: ${SMTP_*} in docker-compose.yml) had all six SMTP_* keys present but empty; set them and recreated the auth service (docker compose up -d auth — a bare docker restart would not have picked up the new env, since it doesn't re-read .env). Two live invite sends through the real admin-console flow both returned a clean GoTrue 200 with a real ~2s SMTP round-trip and zero dial/auth errors in supabase-auth's logs (one to hello@eva.fyi itself, one to an external Gmail address) — a sharp contrast to the previous instant-500 DNS-resolution failure. Neither test email was confirmed received, though. Root cause found via direct DNS lookups (dig) against eva.fyi: MX points entirely at Namecheap's free forwarding service (eforward*.registrar-servers.com), the SPF record (v=spf1 include:spf.efwd.registrar-servers.com ~all) authorizes only that forwarder and says nothing about premium911, no DKIM TXT record exists at any common selector, and there's no DMARC record at all — so mail GoTrue sends as hello@eva.fyi through premium911 arrives at Gmail from a server the domain's own DNS doesn't vouch for, the classic signal for silent-drop or spam-foldering. Explicitly parked by the user this session ("We will need to do this later on") — DNS changes at Namecheap (where eva.fyi is managed), outside anything reachable from the VPS. Exact values queued for when picked back up: (1) SPF — merge into the existing @ TXT record: v=spf1 include:spf.efwd.registrar-servers.com a:premium911.web-hosting.com ~all; (2) DKIM — must be generated cPanel-side (Email → Authentication for the hello@eva.fyi mailbox), then its selector + TXT value added at Namecheap; (3) DMARC — new TXT record at _dmarc.eva.fyi: v=DMARC1; p=none; rua=mailto:hello@eva.fyi (monitoring-only starting policy). Re-test the two invite sends once DNS propagates.
Flutter WebSocket resolves principal_id before Supabase auth lookup finishes NOT STARTED Observed live during Ankit Dhir's real login test: GET REST calls (e.g. /api/v1/brief/schedule) correctly resolved principal_id=ankit-dhir immediately after sign-in, while the WebSocket connection (/api/v1/ws) kept reconnecting with principal_id=demo in the same window — the async principalIdProvider Supabase lookup (auth_providers.dart) hadn't resolved yet when the WS provider fired, so it used the demo fallback instead of waiting. Needs a Flutter-side fix: gate the WebSocket connection on principalIdProvider resolving (or tear down and reconnect once it does), the same way the REST ApiClient already waits for it. Not yet scoped or built — found while live-verifying the provisioning flow above, not asked for this session.
SupabaseAuthAdmin.invite_by_email discards the created user's id on any non-200 NOT STARTED Found while debugging the SMTP failure above: the current implementation only captures a user id from a 200 response or a 422-already-registered response; any other failure (like the 500 mail-send error) is treated as a total failure with nothing salvaged, even though GoTrue may have already created the row. This GoTrue version happens to roll back cleanly on that specific failure path, so no orphan resulted this time — but that rollback behavior isn't guaranteed across GoTrue versions/failure modes, and relying on it is fragile. Defensive fix not yet built: after any invite failure, fall back to get_user_by_email before giving up, the same recovery path already used for the 422-already-registered case, so a partially-created user is linked instead of silently orphaned.
EVA Services · Audit Notes

How to Read This Document

Across all 19 epics, the codebase's central failure mode isn't missing code — it's disconnected code: dataclasses, stores, and services that are correctly built and often unit-tested, then instantiated into app.state and never called by anything a user or agent can actually trigger. Layered on top of that structural gap, this session made one explicit, deliberate regression (removing PolicyGate's zone-check as a security boundary) that quietly weakens the “air-gap” guarantee underpinning roughly a third of the audited stories, and the backend/Flutter restructure introduced at least one new silent runtime bug (relevance_engine.score) that breaks a previously-working job. The Flutter frontend, checked comprehensively for the first time this pass, is more built-out than assumed — it upgrades several stories from NOT STARTED to PARTIAL — but in multiple cases it's wired to the wrong backend contract entirely (stale JSON field names) or to the wrong backend concept (the wrong “draft” system), meaning the UI would visibly fail or render empty even where a plausible-looking screen exists. Net effect: verdict letters mostly held steady, but the evidence underneath shifted from “not yet built” toward “built twice, wired to nothing, or wired to the wrong thing” — a harder, more expensive class of gap to close than the prior audit's framing suggested.

Phase 1 update (2026-07-09): a build pass delivered real, tested infrastructure across five epics (E19, E10, E17, E16, E15) plus E13's HR-zone work — new tables/migrations, service-layer engines, MCP tool surfaces, and 100+ passing tests. Where a story's full stack (store → engine → route/MCP → bus → audit) was built in one pass, it consistently reached genuine DONE status (E16 calendar core, E15 channel tagging). But the pass's dominant failure mode wasn't missing code, it was missing last-mile wiring: events published with no consumer subscribed (E19 downgrade, E15 forget-propagation), and core mutation methods built and tested but never called from any production code path (E10 identity resolve, E13's HR-thread/query-refusal logic, E19 subscription gate on all but one route). Several epics now have correct, well-tested machinery sitting inert in the running system — it will read as “done” in a shallow check but produces zero observable behavior change for a principal today. The concrete unblock for Phase 2 is narrow and mechanical: wire ComplianceConsumer and a downgrade consumer into consumers.py's startup list, and call resolve()/require_active_subscription/query_bar_followup from the actual routes — rather than more net-new engine code.