Story 1975: domain-events contract, transactional outbox, and the matter.created producer

First pramaan-functions slice of RFD-EVENT-001 (EPIC-1973). Before this Story, matter.created died as an in-transaction audit row — nothing downstream could react, so the Vega framer had no wake-up path. This Story lands the typed envelope contract, the ops.domain_event_outbox table, and one producer insert inside create_matter's existing transaction. The relay Lambda, bus Terraform, and framer trigger are the sibling pilot Stories; nothing is published to AWS yet.

The one-door rule

create_matter txn:
  matter row + audit row + ops.domain_event_outbox row   <- atomic (commit or nothing)
    -> relay (Story-1976 wave): FOR UPDATE SKIP LOCKED, seq order
    -> pramaan-events-<env> EventBridge bus
    -> rule -> SQS -> framer_trigger (later Story)

The outbox insert (src/shared/domain_events/outbox.py) runs on the caller's open session. A crash between the state change and the relay can never lose the fact (durable at commit) or fabricate one (rollback takes the row with it). The integration test proves both directions against a real database through the real router: tests/integration/test_domain_event_outbox_producer.py.

The envelope: no field a title fits

FieldType / rule
event_idOutbox row UUID — delivery/replay dedupe at consumers
idempotency_keyHelper-composed <detail-type>:<firm_id>:<subject.id>, regex-validated; never caller-supplied, never content-derived
detail_typeAudit-registry action string (matter.created) — ONE vocabulary for audit and events, validated via registry.resolve()
schema_version, seq, occurred_atint / identity / commit time (server_default now())
environmentProducing env; consumers refuse a mismatch
firm_id, subject{kind,id}, actor{kind,id}UUIDs + closed enums; subject.kind is DERIVED from the detail_type's registry entry
traceparent / tracestateW3C-regex validated; sourced from inject_trace_context() ONLY
Content ban, verbatim: "No names, labels, excerpts, prompts, summaries, filenames, or legal content in domain-event detail unless an event-specific schema exception is approved." The envelope enforces it structurally: extra='forbid', every field typed, and the helper signature takes IDs only — audit.emit's Subject.label and snapshots are unusable by construction (EB-05 test class). Metadata smuggling is killed on VALUE: a title in the idempotency key or tracestate fails the regex and the error names the violating field (EB-20 test class).

Two keys, two jobs

event_id dedupes deliveries (Archive replay, at-least-once SQS). idempotency_key guards retried logical inserts: the ON CONFLICT (firm_id, idempotency_key) DO NOTHING insert returns the existing row, so a retried create_matter enqueue resolves to the same event instead of a second one.

Migration 0144

ops.domain_event_outbox clones the proven outbox shape (0064 projection / 0110 document ingest): identity seq, dispatch bookkeeping, durable W3C trace columns, RLS with a firm insert policy plus staff relay SELECT/UPDATE policies, and DELETE revoked from pramaan_app. New here: skipped_at/skip_reason — the relay freshness horizon (EB-15) marks stale rows skipped durably instead of publishing week-old facts after a point-in-time restore. The DB also enforces value shape: detail-type grammar, composed idempotency-key regex, and W3C traceparent regex checks.

detail_type has NO IN-list check constraint on purpose. The vocabulary lives in src/shared/audit/registry.py and grows by code edit, never by migration — the registry doctrine. Code validates at insert and consume (HR-21); the DB checks only the grammar.

Trace invariant

The insert runs under the new registry span functions.domain_event_outbox.enqueue (PRODUCER kind, required attributes firm_id + event.type; pramaan-docs ADR-124 closed-registry extension, ratify with an amendment). traceparent/tracestate are captured onto the row so the relay batch span LINKs the stored context and per-record consumer spans PARENT from it — one continuous trace id from click to (eventual) AgentCore invoke.

CI gate (EB-10)

.github/scripts/domain-events-gate.sh (wired in domain-events-gate.yml) bans put_events, client("events"), and events.amazonaws.com in product code outside src/shared/domain_events/. tests/unit/test_domain_events_gate.py seeds violations into a temp tree and asserts the gate fails — a gate that never fails is decoration. One documented homonym: src/ingest_api's CloudWatch put_log_events wrapper is name-allowlisted; the EventBridge client rules still apply there. The structural kill (bus IAM resource policy, EB-19) lands with the infra Story.

Verification

CheckResult
ruff check on changed filesclean (repo baseline debt untouched)
mypy src/shared/domain_events/clean
Unit: envelope contract, EB-05, EB-20, migration, gate (EB-10)24 passed
Integration: real POST /app/matters → exactly 1 outbox row; aborted txn → 0 rows2 passed (isolated Docker test DB)
Alembic upgrade head / downgrade / upgrade + alembic checkclean roundtrip, no drift