Story-2849

Verified Meta ingress: HMAC fail-closed on the raw body + SQS

This story ships the WhatsApp webhook receiver — the D24 wax-seal, the most security-critical code in RFD-AGT-013 — into the Terraform-owned Lambda shell pramaan-whatsapp-webhook-staging (Chore-2845). Every Meta delivery must prove transport authenticity before anything else happens: X-Hub-Signature-256 HMAC-SHA256 over the raw request body bytes, before any parsing, before identity resolution, before dedupe, before any work is queued.

Why raw bytes are the whole property (adversary A-1)

The classic webhook bug is verifying the HMAC over the parsed/re-serialized body. A parse round trip changes key order, whitespace, and unicode escapes — so a re-serializing verifier authenticates a body we invented, not the bytes that arrived. The proof lives in tests/unit/test_whatsapp_webhook_signature.py: a validly signed non-canonical body (shuffled keys, odd spacing) verifies — a re-serializing implementation rejects Meta's real signature — while the same semantics re-serialized and signed with the wrong key fails closed.

The pipeline order is the security property

StepWhat happensOn failure
1. Wax-seal HMAC over raw bytes; secret from SSM /pramaan/stg/meta-app-secret, resolved in-process at cold start (ADR-162 D3). 401 + audit row (channel_webhook.rejected, system actor, NULL firm) + structured log. Zero dedupe rows, zero SQS, zero runs (CC-44).
2. Dedupe One channel_inbound_messages row per (provider, provider_message_id) — insert-and-claim in a single round trip before any queueing. Replay (Meta redelivery): 200, nothing re-queued (CC-15).
3. Enqueue One small JSON pointer envelope per message onto pramaan-whatsapp-ingress-staging (never media bytes); dedupe rows commit only after every send succeeded. SQS/DB failure: 500 so Meta redelivers; the dedupe claim rolls back with the transaction, so the redelivery re-claims cleanly.

GET /webhooks/whatsapp answers Meta's one-time verification handshake (constant-time token compare; a wrong token never gets the challenge back). Status callbacks pass through dedupe-less — callback idempotency is T2d-a's delivery-records upsert. The channel-ingress consumer (binding resolution + live membership check + clerk-rail re-enqueue) is the follow-up behavior ticket's code, not this story's.

The schema gap the seed left (migration 0208)

The 0207 schema-seed intended NULL-firm dedupe rows ("resolution fields stay NULL until identity resolution succeeds") but shipped no RLS write path for them: firm_isolation_policy's WITH CHECK can never pass a NULL-firm row, and the staff policy was SELECT-only. Red-first proof: the ingress integration tests fail on a database at 0207 with new row violates row-level security policy for table "channel_inbound_messages" and pass at 0208. The fix mirrors the 0018 webhook precedent — a staff_write_policy on exactly one table; a permanent test proves the same insert without staff context is still denied, so firm isolation is unchanged.

Code + grant + check travel together (R2)

CC-44 acceptance

Scenario: An unsigned or forged webhook starts no run
  Given an inbound webhook whose signature is missing or invalid
  When it claims to be a message from a bound counsel's number
  Then the adapter rejects it before identity resolution
  And no run is enqueued and the attempt is audit-logged