Story 2450: THE READ announces itself on the domain bus

WHY. The live domain bus (ops.domain_event_outbox) carries facts other lanes react to: document_card.updated, matter_assertions.updated, and five more. But THE READ — the document insight brief — landed silently. No insight.completed fact existed. So the Insights page had to poll "Read pending"; the Brief rail and the Context Engine had no signal to rebuild on; and the planned live per-lane fan-out page (Epic-2423) had nothing to listen to. A pipeline that produces a result nobody can hear about is half a pipeline. This Story gives THE READ a voice — and nothing more: it announces, it does not add consumers.

The shape: the sibling pattern, verbatim

The card lane already does exactly this. When Blake's card is verified and upserted, the card ingest boundary emits document_card.updated in the same transaction as the row write (16 live receipts prove it). This Story copies that pattern one seam over, onto the brief write. There is no new mechanism to invent — the transactional outbox (src.shared.domain_events.insert_domain_event) is the only door to the bus (R7), and it was built for precisely this.

Two edits, one deletion of silence

1. The vocabulary (a registry edit, never a migration)

insight.completed is registered in the audit-registry vocabulary (src/shared/audit/registry.py): the verb completed and the subject kind insight. Adding a verb or subject there is a data edit to two dicts — the detail_type columns are bounded VARCHAR, validated in code, never a Postgres enum (the migration 0144 docstring makes this explicit). The domain-event grammar is single-dot <subject_kind>.<verb>, so insight.completed means subject kind insight, verb completed — and insert_domain_event derives the subject kind from the token, so a subject/detail-type mismatch is impossible by construction.

The subject is the facet, not the document. Exactly as the card lane's subject kind is document_card (not document) with subject_id = document_id, the insight lane's subject kind is insight — the L2 brief facet of a document — with subject_id = document_id. Same document, different facet. The idempotency key composes to insight.completed:<firm>:<document>, so a re-read coalesces onto one pending event per document, matching the card and matter-assertions lanes (the outbox's own idempotency semantics — no bespoke dedup).

2. The emit (same transaction as the store)

In src/shared/context/insight_brief.py, the write phase now calls one small helper, _store_and_announce_brief, inside its open write transaction:

await _upsert_brief_row(session, firm_id=firm_id, brief=brief)
await insert_domain_event(
    session,
    detail_type=INSIGHT_COMPLETED_DETAIL_TYPE,   # "insight.completed"
    firm_id=firm_id,
    subject_id=document_id,                       # the insight facet of this document
    actor_kind="system",                          # background enrichment, no acting user
    actor_id=None,
)

The store precedes the emit deliberately: a store failure aborts the whole transaction before any event row exists. traceparent is carried automatically — the helper sources it from the ambient OTel context and callers cannot supply it (EB-20), so trace continuity across the handoff is free. actor_kind="system" because this is a background write with no acting user; the producing agent run rides the brief row, not the event actor.

Why a helper, not two inline awaits. The atomicity guarantee — "no brief without its event, no event without its brief" — is the whole ticket. Extracting the store+emit into one named function gives the unit test a seam to prove it against without standing up the entire three-phase seat (read → agent → write). One caller, one test target, zero speculative abstraction.

What this Story deliberately does NOT do

Proof — and the red run behind it

AGENTS.md: no green counts until it has been red. The DB-backed atomicity test was observed failing on a planted negative (the emit disabled) before the suite was allowed to pass.

TestWhat it proves
test_read_store_failure_emits_no_event
(tests/unit/context/test_insight_brief.py, pure, no DB)
The AC directly: the store precedes the emit on the same session, so a failed brief store aborts before any domain_event_outbox INSERT is reached — READ store fails → no event row.
test_insight_announce_atomicity
(tests/integration/test_insight_brief_store.py, real Postgres)
Positive: one brief → one insight.completed row with subject_kind='insight', subject_id=document_id, actor_kind='system', traceparent carried, dispatched_at NULL (the relay ships it post-commit). Negative: a txn that aborts after the store leaves NEITHER a brief NOR an event.
Red-first: with the emit disabled, the positive gate (_count_events == 1) was observed failing, then reverted.
If you remember one thing. A pipeline stage that lands silently is invisible to every other lane. THE READ now speaks the same event grammar the card and assertion lanes already speak — one insight.completed fact, emitted in the same transaction that stores the brief, so it can never lie about a landing that did not commit. The consumers that listen are the next tickets; this one just makes sure there is something to hear.