Story-2882

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

ScenarioOutcome
Worker down / challenge lostBinding still refuses at the seam when its period ends; the scan picks it up next run (no non-failed prompt on record)
Legitimate owner, expiredOne static reply names the way back; CONFIRM re-enables in one message
SIM-swapper holding the numberConverse 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 expiredSender 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)

Files