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
| Field | Type / rule |
|---|---|
event_id | Outbox row UUID — delivery/replay dedupe at consumers |
idempotency_key | Helper-composed <detail-type>:<firm_id>:<subject.id>, regex-validated; never caller-supplied, never content-derived |
detail_type | Audit-registry action string (matter.created) — ONE vocabulary for audit and events, validated via registry.resolve() |
schema_version, seq, occurred_at | int / identity / commit time (server_default now()) |
environment | Producing 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 / tracestate | W3C-regex validated; sourced from inject_trace_context() ONLY |
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
| Check | Result |
|---|---|
ruff check on changed files | clean (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 rows | 2 passed (isolated Docker test DB) |
Alembic upgrade head / downgrade / upgrade + alembic check | clean roundtrip, no drift |