Skip to content

WHATSAPP-002B: pure one-time verification-code issuance decision (source only)#436

Merged
daliu merged 2 commits into
mainfrom
whatsapp-002b-issuance
Jul 21, 2026
Merged

WHATSAPP-002B: pure one-time verification-code issuance decision (source only)#436
daliu merged 2 commits into
mainfrom
whatsapp-002b-issuance

Conversation

@daliu

@daliu daliu commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Closes #435. Child of #87 (WHATSAPP-002 — "Link a verified WhatsApp number and versioned consent to a member UID"). Parent #87 stays open and owner-gated (needs-external-config).

What this adds (source only, unused)

One pure, deterministic, non-throwing frozen-verdict reducer for the issuance half of #87's scope line "generate a one-time server code … make it short-lived, one-use, rate/attempt limited, and App Check protected" — the half the landed redemption reducer §8.16 (#419) explicitly leaves with #87 — plus its exhaustive test and a SYSTEM_DESIGN §8.23 section. Imported by nothing (no index, route, Rules, callable, or worker) → zero runtime behavior; nothing here awaits the owner-gated deploy.

classifyWhatsappVerificationIssuance(request, currentChallenge) decides, for one request to issue a code for a (member, phone) pair and the issuance-relevant projection of the durable challenge the server already has for that pair, whether a new one-time code may be issued now:

  • request: { verificationIssuanceSchemaVersion, memberRef, phoneRef, actor, asOf }.
  • currentChallenge: a literal null (no prior challenge) or { verificationIssuanceSchemaVersion, challengeRef, memberRef, phoneRef, status, expiresAt, reissueAfter }, status ∈ { pending | verified | voided }, where reissueAfter is the caller-computed instant before which no new code may be minted.
  • Returns exactly one frozen verdict:
    • { decision: 'issue', reason: 'fresh', subject } — nothing live to replace;
    • { decision: 'issue', reason: 'superseding', subject, supersededChallengeRef } — replaces a still-live pending prior, naming it for the caller to void;
    • { decision: 'refuse', reason }actor_not_owner | subject_mismatch | already_verified | cooldown_active (all change nothing);
    • { decision: 'denied', reason }malformed_request | malformed_current_challenge.

It never throws and imports only node:util. It mints nothing (no code, hash, or new challenge id) and holds no code/secret vocabulary at all (a source-boundary test enforces the absence).

Why it exists alongside §8.16 (the composition)

§8.16 (redemption) bounds how many guesses a single challenge permits (per-challenge attempt budget, one-use, expiry). It cannot see two abuse vectors:

  1. exhaust → void → reissue: burn the attempt budget, void the challenge, immediately mint a fresh one, repeat — unbounded guessing.
  2. parallel challenges: open many live challenges for one pair, multiplying the total guess budget.

This reducer closes both: the cooldown gate applies to a voided prior exactly as to a pending one (so a fresh budget cannot be minted faster than the owner's cooldown), and supersession keeps at most one live challenge per pair. Redemption without issuance leaves those holes open.

Safety invariants (all test-locked)

  1. Owner-bound. Only the signed-in member may request their own code (actor must equal memberRef); otherwise actor_not_owner, checked before any challenge state is consulted, so no one can trigger a code-send to a phone on another member's behalf and an unauthorized requester learns nothing.
  2. Idempotent on verified. A verified pair never gets another code (already_verified), regardless of expiry/cooldown.
  3. Cooldown-bounded (brute-force-loop-closing). A request before reissueAfter is cooldown_active; the gate covers voided priors, closing the exhaust→void→reissue loop.
  4. At-most-one-live-challenge. Issuing over a live pending prior is superseding and names the prior supersededChallengeRef to void; a terminal/expired prior is fresh and names nothing.
  5. Mints nothing / confers nothing. The verdict carries only the subject and (when superseding) an existing ref to void — never a code, hash, new id, or policy value — and grants no auth/membership/role; the phone is a channel handle (all-digit OK) while member/actor are letter-requiring identities, so a phone can never masquerade as the member.
  6. Invents no policy. Cooldown duration, code lifetime, and attempt budget stay owner config; the caller supplies reissueAfter/expiresAt and this reducer only enforces the gates by lexical UTC comparison (cf. the MEMBERS-DUES-001 — Build server-authoritative annual membership checkout and renewal #114 owner-decision rule).
  7. Clockless + hostile-input-safe. Fixed-width UTC compared lexically over calendar-validated instants (no clock, no Date); descriptor-based reads (no getter ever invoked); proxies (incl. revoked), foreign prototypes, inherited/extra/missing/symbol keys, and out-of-shape values deny rather than being partially interpreted or thrown. A literal null is the sole "no prior challenge" signal.

Tests

functions/whatsappVerificationIssuance.test.js (114 cases): frozen surface + a "no code/mint/grant/auth vocab" enum check; the full decision matrix vs an independent hand-authored oracle + a decision:reason coverage assertion; each invariant above with its boundary (asOf == reissueAfter allowed; asOf == expiresAt expired → fresh not superseding; void-prior cooldown; supersededChallengeRef is an existing ref); precedence (malformed_requestmalformed_current_challenge; actor_not_owner ≻ subject ≻ verified ≻ cooldown) plus a lock that structural validation of both inputs precedes the actor gate — the actor gate is the first semantic gate, so a non-owner actor with a malformed challenge denies malformed_current_challenge, and no actor != memberRef input over the full well-formed challenge matrix ever reaches an issue; null-is-the-sole-no-challenge-signal (undefined/0/'' deny); malformed-request and malformed-current-challenge batteries (proxy/revoked/array/foreign-prototype/inherited/extra-enumerable-and-non-enumerable/missing/symbol/accessor-never-invoked/trap-counting-proxy/bad-version/bad-enum/bad-ref/impossible-and-leap dates/trailing line-terminators); hostile-input never-throws (both argument positions incl. cyclic); determinism/immutability/singletons incl. a strict-mode tamper and post-call input mutation; clockless lexical=chronological; phone-can't-masquerade; and comment-stripped source-boundary checks (word-bounded code/hash/secret/PII vocabulary, header §8.23/WHATSAPP-002B, imported-by-nothing, requires only node:util).

Local pre-flight: ESLint 8.56.0 exit 0; jest 114/114 pass (secrets unset).

Distinct from every sibling

Out of scope (stays with gated parent #87 and deps #85/#81, AUTH-001/ABUSE-001)

Code generation/hashing/send, App Check, the durable challenge store, the live rate limiter, versioned-consent capture, phone normalization, multi-UID conflict handling, and all WhatsApp/provider wiring. This contract performs none of them; it only decides the issuance disposition and returns it.

Not a deploy proof

Source change, tests, merge, and Firebase deployment are separate states. Merging this deploys nothing and changes no live behavior.

@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for luminous-fox-7c393f ready!

Name Link
🔨 Latest commit 578cc14
🔍 Latest deploy log https://app.netlify.com/projects/luminous-fox-7c393f/deploys/6a5fb798afeb480008a4e93a
😎 Deploy Preview https://deploy-preview-436--luminous-fox-7c393f.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

…nce in tests

Test-only hardening (module byte-unchanged). Documents and locks the
by-design precedence surfaced in adversarial review: structural validation
of both inputs precedes the actor gate (the actor gate is the first
*semantic* gate, not the first check). Adds an explicit assertion that a
non-owner actor with a malformed challenge denies malformed_current_challenge,
and a matrix assertion that no actor != memberRef input ever reaches an
issue decision. 114/114 jest, ESLint clean.
@daliu
daliu merged commit f656ab8 into main Jul 21, 2026
9 checks passed
@daliu
daliu deleted the whatsapp-002b-issuance branch July 21, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WHATSAPP-002B — one-time verification-code issuance decision (source only)

1 participant