Periodic number re-attestation: the SIM-swap residual control
RFD-AGT-013 T2-rat / D7 (adversary A-2). A bound WhatsApp number proves
possession at bind time; SIM-swap means that proof decays. This
story makes every binding re-attest periodically: a scheduled worker
sends a static "confirm your number" challenge, a binding that lets its
period lapse is refused at the ingress seam — fail-closed,
static message, zero LLM, zero run — and replying
CONFIRM on the bound channel re-enables it instantly.
Unbind/offboard remain the only kill paths; a lapsed attestation is a
pause, never a revocation.
Zero new migrations — the state is derived
The Story-2847 schema seed already carries
channel_identities.last_attested_at (its comment names
T2-rat as the owner). The attestation state is computed, never stored:
baseline = COALESCE(last_attested_at, bound_at) due_at = baseline + channel_reattestation_interval_seconds (default 30d) expired ⇔ now ≥ due_at (fail-closed at the boundary) challenge ⇔ due_at ≤ now + challenge_window (default 7d before expiry)
alembic check confirms no schema drift; the binding's
status vocabulary (active/revoked)
is untouched, so every existing kill path (unbind, admin-revoke,
offboard sweep, revocation epoch) behaves exactly as before.
The three surfaces
WORKER (src/workers/channel_reattestation/handler.py, schedule-triggered)
scan active bindings due inside the challenge window
└─ no non-failed reattestation_prompt delivery record against the
CURRENT baseline? → send static challenge via the Story-2851 sender
(durable delivery record; failed = retried next run, R5)
SEAM (src/domains/channel/refusal.py — inside evaluate_sender)
resolve_bindings ── any FRESH binding? ──► PASS on the fresh subset only
│ all expired
▼
SAME ledger + advisory-lock + window-backoff machinery as the zero-amp
shield, new kind attestation_expired: ONE static "reply CONFIRM" reply
per refusal window, then ledger-counted silence; at the window cap,
byte-identical silence with no classify and no ledger write.
CONFIRM (src/whatsapp_webhook/handler.py — intercepted BEFORE the shield)
text body == "CONFIRM" (exact, case-insensitive)?
├─ sender has active bindings → stamp last_attested_at on each (one
│ channel_identity.reattested audit row per binding), send ONE static
│ ack (reattestation_ack delivery record), dedupe row stamped
│ processed (R5), zero enqueue
└─ no active bindings → fall through to the shield: the keyword reads
exactly like any unregistered probe (no oracle)
Why fail-closed is safe here
| Scenario | Outcome |
|---|---|
| Worker down / challenge lost | Binding still refuses at the seam when its period ends; the scan picks it up next run (no non-failed prompt on record) |
| Legitimate owner, expired | One static reply names the way back; CONFIRM re-enables in one message |
| SIM-swapper holding the number | Converse access pauses at period end; wrapper links still require an app session + matter access (D18), remote logout kills everything (D19) |
| Multi-firm subject, one firm expired | Sender passes on the fresh subset only — one firm's lapse never blocks another firm |
What the grant picture looks like (R2)
The worker invokes the non-VPC sender Lambda through the same
Story-2851 dispatch path the webhook already uses — no new cloud
capability class, but the worker's Lambda shell, EventBridge schedule,
and its lambda:InvokeFunction grant on the sender are
pramaan-infra Terraform resources (kanban protocol: code lanes never
own runtime config). The infra twin is tracked in the PR body. The
check that goes red when the grant is absent: the R4 canary
(channel_reattestation_due_scan) runs the real due-scan
query against the real DB, and every challenge carries a durable
delivery record whose terminal failed stamp is counted
and retried — a missing grant surfaces as counted failures in the run
log, never as silent loss.
Tests (the ticket's three named scenarios)
- expiry → refused at the seam:
test_expired_binding_is_refused_at_the_seam— 31-day-old binding, kindattestation_expired, static CONFIRM reply, ledger row, binding staysactive. - confirm → re-enabled:
test_confirm_reenables_and_no_confirm_stays_refused— stamp + audit row + durable ack, next evaluation passes. - no-confirm stays refused: same test — two evaluations, reply once then windowed silence, still refused.
- Worker: due-only challenge, no repeat of a delivered prompt, retry of a failed one, expired-never-challenged pickup.
- Unit: keyword contract (exact, case-insensitive, never substring), baseline/expiry boundary (fail-closed at the due instant), seam partition, webhook interception (CONFIRM → ack + processed, zero SQS; unregistered CONFIRM → uniform refusal).
Files
src/domains/channel/reattestation.py— the state machine + confirm + scan (new)src/workers/channel_reattestation/handler.py— the scheduled challenge worker (new)src/domains/channel/refusal.py—attestation_expiredkind + fresh-subset passsrc/whatsapp_webhook/handler.py— CONFIRM interception before the shieldsrc/domains/channel/ingress.py—mark_inbound_processed(R5 stamp)src/domains/channel/delivery.py—reattestation_prompt/reattestation_ackkindssrc/domains/channel/service.py+dtos.py—ResolvedBinding.last_attested_atsrc/shared/settings.py—channel_reattestation_interval_seconds(30d) /channel_reattestation_challenge_window_seconds(7d)src/shared/audit/registry.py—reattestedverb (access_security)src/shared/whatsapp/formatter.py— static challenge + confirmed-ack texts- Tests:
tests/unit/test_channel_reattestation.py, T2-rat section intests/unit/test_whatsapp_webhook_handler.py,tests/integration/test_channel_reattestation_db.py