Story-2850

Zero-amp shield: static refusal of unregistered senders

RFD-AGT-013 T2a-4 / D8 (adversary A-8), acceptance CC-08 + CC-49. The Story-2849 wax-seal proves a webhook delivery came from Meta — it says nothing about the sender. This story plugs the zero-amplification shield into that receiver, after the dedupe claim and before any SQS enqueue: an unregistered, forged-but-validly-signed, or post-terminal sender gets a static, templated, rate-limited refusal — zero LLM involvement, zero generative output, zero inference cost, zero run.

The decision tree (src/domains/channel/refusal.py)

One verified message, one evaluate_sender call on the receiver's privileged session:

resolve_bindings (Story-2848)  ── active binding? ──► PASS — enqueue as before
        │ empty
        ▼
advisory lock (per provider+subject, txn-scoped)
        ▼
count ledger rows in window    ── ≥ max_attempts? ──► BACKOFF — byte-identical
        │                                               silence, NO ledger
        ▼                                               write, NO classify query
classify revoked identities
        ├─ no rows at all            ──► unregistered    → static refusal text
        ├─ revoked, terminal reason  ──► post_terminal   → ONE terminal notice
        │   (offboard/remote_logout)                        ever (CC-22)
        └─ revoked, other reason     ──► revoked_binding → static refusal text
        ▼
INSERT one channel_refusal_ledger row (the backoff accounting)

Reply-once rules: unregistered/revoked_binding reply only on the first refusal in the window; post_terminal replies once ever, stamped terminal_notice_sent only after a successful send, so a failed notice is retried rather than lost (R5). Both texts are uniform constants — the reply is never an oracle into which state the number is in.

Why this is zero amplification (CC-49)

Attacker spendsWe spend (bound sender)We spend (flood)
1 valid webhook1 indexed SELECT1 dedupe INSERT (audit) + 1 indexed count SELECT
N more in window—≤ max_attempts ledger rows total, 1 reply
beyond the cap—silence — no lookup, no write, no reply, no enqueue

The LLM, the run rail, and the outbound sender are all unreachable from the refused path: the shield module carries no model-client symbol (pinned by test), and nothing is enqueued onto pramaan-whatsapp-ingress-staging.

What the grant picture looks like (R2)

The shield's reply invokes the non-VPC sender Lambda directly — a zero-firm refusal has no firm/binding to hang a delivery record on, so the refusal-ledger row is the durable audit record. The webhook role does NOT yet have lambda:InvokeFunction on the sender: the grant is the linked pramaan-infra twin ticket. Until it lands, reply dispatch is failure-soft (logged, ledger intact, never a 500 retry storm), and the deploy canary's new dry-run sender probe goes RED — the missing grant fails the deploy, not the first live refusal.

Migration 0209 (the one migration)

The 0207 seed shipped no NULL-firm write path for channel_refusal_ledger and 0208's header deferred that grant to this ticket. 0209 adds staff_write_policy FOR ALL (the 0208 pattern) plus a column-scoped GRANT UPDATE (terminal_notice_sent) — the CC-22 flag is the one sanctioned mutation; every other column stays append-only for the app role. Red-first: the staff-context insert is denied on a database at 0208, passes at 0209 (verified by downgrade/upgrade runs).

Red-first evidence

Files