A WhatsApp document cannot file itself
Acquired channel media now waits in one durable confirmation row per document. The target IDs come only from the sender's live, RLS-bounded bindings; a caption matter code is merely a selector over that authorized set. Sticky conversation scope and provider-supplied firm or matter hints never become filing authority.
The safe path
verified WhatsApp sender + acquired media receipt
→ one batched context read validates user + every live binding
active membership + revocation epoch + canonical matter access
→ derive one accessible firm + matter
ambiguous → ask for an explicit matter code; file nothing
→ require matters:write
→ create pending confirmation (10-minute expires_at)
→ prompt names filename + firm + matter
→ YES / M-NNN YES / per-document C-XXXXXXXX YES
transaction lock serializes this counsel's pending queue
row lock proves status=pending and expires_at>now()
→ recheck binding, membership, permission, and matter access
→ DocumentService.presign_upload
→ server-side copy channel-media → documents
→ DocumentService.finalize_upload
→ stamp confirmed with resolved_document_id
→ GuardDuty scan → extraction → registration → Blake
enabled rate(1 minute) worker
→ app-only expiry capability (no table-wide staff content read)
→ bounded due-row UPDATE ... FOR UPDATE SKIP LOCKED, using DB clock
→ pending → expired (no document side effect)
→ late YES names expiry and opens one fresh 10-minute ask
Acceptance behavior
| Scenario | Result |
|---|---|
| CC-07 | prompt names firm and matter; only that document enters the existing Blake pipeline after confirmation |
| Binding authority | the confirmation boundary accepts no provider firm, matter, or recipient authority; only the verified binding target can become pending, and no document row exists before counsel confirms. M4 email transport is outside the v1 ceremony. |
| CC-28 | bare YES/NO with several pending documents is rejected and every pending filename gets a C-XXXXXXXX selector; same-matter documents remain independently confirmable |
| CC-29 | the existing one-minute worker stamps elapsed rows while the channel is silent; late YES files nothing, acknowledges the old expiry once, and offers a fresh ask from current binding scope |
| CC-30 | a target outside the current firm scope requires the exact typed matter code; bare YES leaves it pending |
State and concurrency
Migration c8e2a4f6d9b3 appends the frozen Blake handoff
facts to the existing forced-RLS table: filename, detected content type,
byte size, matter number, typed-confirmation flag, and the time an
expired ask was handled. A
transaction-scoped PostgreSQL advisory lock serializes one counsel's
confirmation queue; a row lock then makes expiry and Blake handoff single
winners, so two concurrent YES deliveries cannot mint two document rows
and the clock cannot expire a row after S3 side effects begin. Context
derivation uses one narrow SECURITY DEFINER batch reader:
one GUC query plus one data query for one or many bindings (HR-19), with
verified-user, membership, epoch, and canonical matter-access checks
inside the reader. Failed prompt delivery aborts the row; failed S3
handoff rolls the database savepoint back and leaves the confirmation
retryable. Because confirmation rows now contain filenames and storage
receipts, the migration removes their blanket staff metadata policy:
support reads require an active, firm-scoped support grant (HR-17), while
the scheduled worker can call only the bounded pending-to-expired
database function. If the binding dies between media acquisition and
the second authorization read, the webhook transaction fails for
redelivery; the media turn is never silently consumed.
Red-first proof
- The first DB run made every locked row disappear because staff SELECT RLS does not grant row UPDATE; the final design uses user-self policies plus a narrow expiry function, and staff content reads require a support grant.
- A concurrent two-YES integration test proves exactly one handoff and one document row.
- The first clock-driven BDD run failed because no sweep existed; the final test proves due-row expiry, idempotent second sweep, late-YES remint, handled-old-state, and zero documents.
- One-versus-many binding integration tests count the same two context queries and reject forged or mixed-user binding lists.
- Same-matter multi-pending, read-only membership, failed prompt delivery, typed cross-firm retry, and migration compatibility each have a focused regression.
- The webhook seam proves attacker-supplied firm, matter, and recipient fields never cross into confirmation authority.
Capability contract
Clock expiry adds no cloud capability: it reuses the already enabled
Terraform-owned rate(1 minute) relay shell, and its deploy
smoke executes the exact bounded expiry function in a separate
transaction.
The Blake copy path does require the live WhatsApp webhook role to read
channel-media/*, write the documents raw-object prefix, and
use the documents KMS key. Those Terraform grants and a copy-path
canary are deployment-owned work; the functions tests plant both
successful and denied S3 clients so the state machine stays honest
around that boundary.