Story 1503: document_ingest_outbox — transactional outbox + relay (FN-OUTBOX)
Why this exists (the catastrophe it prevents). When a lawyer finalizes an upload, two things must happen: the document's status changes to uploaded, and the ingestion pipeline (scan → extract → classify, EPIC-009) must be told to start. The naive way — write the status change, commit, then call SQS — has a fatal gap: if the process dies between the commit and the enqueue, the document is forever uploaded with no job. The file sits in S3, the register shows it, and nothing ever processes it. The inverse (enqueue first, then write the row) is just as bad: a job for a document whose status never committed. A dropped document in a litigation matter is evidence that silently never enters the case. This story closes the gap with a transactional outbox: the job ticket is written in the same database transaction as the status change, so the two commit or roll back together — never one without the other.
The shape: one row in the same txn, one relay to the queue
A new operational table ops.document_ingest_outbox holds one ticket per ingest request. DocumentService.finalize_upload inserts the ticket on the caller's open session, right after the status transition to uploaded. A separate relay Lambda later drains undispatched tickets to the ingest queue and stamps each row dispatched_at.
finalize_upload (ONE transaction):
Document.status = 'uploaded' # the status change
record_status_transition('pending' -> 'uploaded')
insert_document_ingest_event(...) # the outbox ticket
emit('document.uploaded') # audit row
-- commit: all four, or none --
relay (later, separate Lambda):
SELECT ... WHERE dispatched_at IS NULL FOR UPDATE SKIP LOCKED
client.enqueue(pointer envelope) -> SQS
UPDATE SET dispatched_at = now()
The queue message is a pointer, never bytes: {document_id, firm_id, matter_id, object_key, content_type}. The worker fetches the S3 object itself and computes checksum_sha256 at the stored transition (EPIC-009). SQS's 256 KB limit is irrelevant to file size because the file never rides the queue.
| Column | Meaning |
|---|---|
event_id | UUID primary key (gen_random_uuid()). |
idempotency_key | Deterministic document_ingest:<sha256(event_type, firm_id, document_id)>. Firm- and document-scoped: one finalized upload yields exactly one ticket; a redelivered finalize resolves to the same row via ON CONFLICT. |
event_type | CHECK-constrained to 'document.ingest.requested' (Mode 1). The closed list extends without touching the relay. |
seq | BIGINT IDENTITY — the relay drains in insertion order. |
firm_id, matter_id, document_id | The pointer's identity. FK to business.firms/business.matters (RESTRICT) and documents.documents (CASCADE — a spent job must never block document erasure, mirroring document_status_events). |
object_key, content_type | The S3 pointer payload. Non-empty CHECK on both. |
traceparent / tracestate | Durable W3C trace carrier so the relay can link the queue message back to the request span that enqueued it across the async boundary (pramaan-docs ADR-124). |
dispatched_at, dispatch_attempts, last_dispatch_error | Relay bookkeeping. A partial index on (seq) WHERE dispatched_at IS NULL keeps the drain scan cheap. |
Crash-safety invariant
The insert runs on the caller's open session, not a fresh one. So it is bound to the same commit boundary as the status change. A crash anywhere before commit rolls back both; a crash after commit leaves both durable, with the ticket waiting for the relay. There is no window in which a status change exists without its job, or a job without its status change. This is the whole point of the outbox.
Idempotent transitions
Two layers make re-runs safe. The insert uses ON CONFLICT (firm_id, idempotency_key) DO NOTHING and returns the existing row, so a redelivered finalize never enqueues a second job. The relay marks dispatch with UPDATE ... WHERE event_id = :id AND dispatched_at IS NULL and counts a success only when rowcount == 1, so two relays racing the same row (or a redelivered SQS message) converge on one dispatch. On a FIFO queue the envelope's idempotency_key is also the MessageDeduplicationId.
Tenancy & tamper-evidence
RLS mirrors ops.projection_outbox (0064): firm_isolation_policy (firm members touch only their firm's rows) plus relay SELECT/UPDATE policies gated on app.is_staff_or_false(). The app role is granted SELECT, INSERT, UPDATE with DELETE revoked — the outbox is a ledger, not a scratchpad. The new firm-scoped table is registered in the support-confidentiality allowlist so on-duty support (config scope, no content grant) sees zero rows.
Observability (pramaan-docs ADR-124)
The enqueue and the relay batch are genuine queue boundaries, so each gets a registry-approved manual span — functions.document_ingest_outbox.enqueue (PRODUCER) and functions.document_ingest_relay.batch (CONSUMER) — added to the closed ManualSpanName registry beside the projection-outbox pair they mirror. Span attributes are metadata only (firm_id, matter_id, batch count) — never object content. The relay links each batch span to the stored traceparent of the rows it drains, and emits pramaan.document_ingest_relay.duration with a non-fatal failure boundary. No raw tracer calls.
Acceptance evidence
| Acceptance criterion | Test / check |
|---|---|
| Outbox table exists (RLS, firm-leading indexes, append-only grants) | alembic upgrade/downgrade/upgrade + alembic check clean; test_document_ingest_outbox_migration.py; test_support_app_session_reads_no_confidential_tables sees the new table with zero rows. |
| Same-txn write (crash between never yields row-without-job) | test_outbox_insert_uses_caller_transaction_and_rolls_back_with_domain_change (rollback drops both); integration test_app_documents_presign_finalize_… asserts finalize wrote exactly one undispatched ticket against a real DB. |
| Relay publishes; idempotent transitions | test_relay_selects_pending_marks_dispatched_and_skips_dispatched; test_insert_document_ingest_event_is_idempotent_per_firm_document; test_fifo_queue_uses_envelope_idempotency_for_sqs_deduplication. |
| Pointer not bytes; durable trace context | test_document_ingest_envelope_carries_pointer_not_bytes; test_insert_document_ingest_event_persists_active_trace_context; test_relay_wraps_batch_in_manual_span_with_links_for_stored_traceparents. |
Scope discipline. Story-521 (the documents/processing-jobs schema) is still open and does not own an outbox, so this is not a skip. The ingest worker that consumes the queue — malware scan, extraction, checksum_sha256, firm-scoped duplicate_of dedup — is EPIC-009 / Mode 2-3 and out of scope here. This story delivers only the crash-safe handoff: the row, the same-txn write, and the relay.