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 spends | We spend (bound sender) | We spend (flood) |
|---|---|---|
| 1 valid webhook | 1 indexed SELECT | 1 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
- Unit + integration tests were written before the implementation and
failed on
ModuleNotFoundError: src.domains.channel.refusal. - The RLS proof was observed red on a real database downgraded to
0208 (
test_null_firm_refusal_insert_needs_staff_context) and green afterupgrade head. - The
GRANT UPDATEneed was itself discovered by a red run (permission denied for table channel_refusal_ledger) before the grant was added.
Files
src/domains/channel/refusal.py— the shield (new)src/domains/channel/ingress.py—mark_inbound_refused(R5 stamp)src/domains/channel/delivery.py— publiclambda_sender_invokehandlesrc/whatsapp_webhook/handler.py— shield stage + canary sender probesrc/shared/settings.py—channel_refusal_window_seconds(3600) /channel_refusal_max_attempts(3)alembic/versions/e4b9d2f7a1c3_0209_channel_refusal_staff_write.py- Tests:
tests/unit/test_channel_zero_amp_shield.py, zero-amp section intests/unit/test_whatsapp_webhook_handler.py,tests/unit/test_channel_refusal_staff_write_migration.py,tests/integration/test_channel_zero_amp_shield_db.py