Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions SYSTEM_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,39 @@ This contract invents no policy and duplicates no sibling. It owns the *reminder

The module is imported by no runtime or Functions index and requires only `node:util`. It reads no clock, randomness, environment, network, Firestore, or provider service; is imported by no endpoint; stores and logs nothing; holds no event content and no secret; mints only the deterministic in-memory `draftId` it returns; performs or persists nothing beyond the returned verdict; and can cause no draft creation, approval, or publish. It defines no Firestore schema or Rules, runs no reconciler or scheduler, and wires into no route or Instagram/provider adapter. Source change, tests, merge, Firebase deployment, provider configuration, production data, website publication, and live behavior remain separate states; #433 generates no real draft and proves none of the external or live states.

### 8.23 WhatsApp one-time verification-code issuance decision — SOURCE ONLY, UNUSED

WHATSAPP-002B [#435](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/435) builds the **issuance core** of parent WHATSAPP-002 [#87](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/87) (*Link a verified WhatsApp number and versioned consent to a member UID*): one unused pure contract for the scope line *"generate a one-time server code … make it short-lived, one-use, rate/attempt limited, and App Check protected"* — specifically the half the redemption reducer §8.16 leaves with #87: *whether the server may issue a new one-time code for one (member, phone) pair now, and whether doing so supersedes a still-live prior challenge.* It is a **non-throwing frozen-verdict** reducer in the idiom of the access-gate siblings (§8.15, §8.16, §8.22), here an issuance/anti-abuse gate that composes with — and is strictly distinct from — the §8.16 redemption reducer: §8.16 bounds how many *guesses* a single challenge permits, while this bounds how many *challenges* exist and how *fast* they may be minted for a pair. It **mints nothing and holds no code**: it carries no code, hash, or new challenge id — the code, its hash, the new challenge reference, the code lifetime, the attempt budget, and the next cooldown are all the caller's to apply after an `issue` verdict; the module deliberately contains no code, hash, secret, token, or bearer vocabulary at all, and a source-boundary test enforces that absence. The pure `classifyWhatsappVerificationIssuance(request, currentChallenge)` reads the issuance request `{ verificationIssuanceSchemaVersion, memberRef, phoneRef, actor, asOf }` and either a literal `null` (no prior challenge) or the issuance-relevant projection of the durable challenge `{ verificationIssuanceSchemaVersion, challengeRef, memberRef, phoneRef, status, expiresAt, reissueAfter }` — where `status ∈ { pending | verified | voided }`, `reissueAfter` is the caller-computed instant before which no new code may be minted, and `asOf` is the request instant — and returns one frozen verdict: an issue `{ decision: 'issue', reason, subject, supersededChallengeRef? }` whose `subject` is `{ memberRef, phoneRef }` and whose `reason` is `fresh` (nothing live to replace — no prior, or a terminal or expired one) or `superseding` (a still-live pending prior is being replaced, named as `supersededChallengeRef` for the caller to void); a refusal `{ decision: 'refuse', reason }` (`actor_not_owner` / `subject_mismatch` / `already_verified` / `cooldown_active`, all of which change nothing); or a denial `{ decision: 'denied', reason }` (`malformed_request` / `malformed_current_challenge`) — and **never throws**. It imports only `node:util`.

```mermaid
flowchart TD
I["request + currentChallenge<br/>(null or issuance projection)"] --> V{"request an exact,<br/>well-formed record?"}
V -- "No" --> D0["deny malformed_request"]
V -- "Yes" --> V2{"challenge null, or an exact<br/>well-formed projection?"}
V2 -- "No" --> D1["deny malformed_current_challenge"]
V2 -- "Yes" --> OW{"actor == memberRef?"}
OW -- "No" --> R0["refuse actor_not_owner<br/>(no change)"]
OW -- "Yes" --> HC{"challenge present?"}
HC -- "No" --> G0["issue fresh"]
HC -- "Yes" --> SB{"same (member, phone)?"}
SB -- "No" --> R1["refuse subject_mismatch<br/>(no change)"]
SB -- "Yes" --> VF{"status verified?"}
VF -- "Yes" --> R2["refuse already_verified<br/>(no change)"]
VF -- "No" --> CD{"asOf < reissueAfter?"}
CD -- "Yes" --> R3["refuse cooldown_active<br/>(no change)"]
CD -- "No" --> LV{"pending and<br/>asOf < expiresAt?"}
LV -- "Yes" --> G1["issue superseding<br/>(name prior to void)"]
LV -- "No" --> G2["issue fresh"]
```

Text alternative: a request that is not an exact, well-formed record is denied `malformed_request`; then a `currentChallenge` that is neither a literal `null` (the sole "no prior challenge" signal, which is not malformed) nor an exact well-formed projection is denied `malformed_current_challenge` (the request is read first, so a malformed request outranks). Authorization is the first semantic gate: an `actor` that is not the `memberRef` is `actor_not_owner` with no state change, 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 about the challenge. With no prior challenge the issue is `fresh`. Otherwise the supplied challenge must describe the same `(memberRef, phoneRef)` or it is `subject_mismatch` with no change — an issuance decision is never made from another pair's challenge. A `verified` pair is `already_verified` with no change (a proven pair never gets another code), checked before the cooldown. A request before `reissueAfter` is `cooldown_active` with no change — and because this applies to a `voided` prior as much as a `pending` one, exhausting §8.16's attempt budget then reissuing for a fresh budget is refused until the cooldown elapses. Otherwise the code may issue: a still-live `pending` prior (`asOf` before `expiresAt`, exclusive) is `superseding` and names that challenge for the caller to void, so at most one live challenge exists per pair; a terminal or already-expired prior leaves nothing live and the issue is `fresh`.

The headline safety invariant is **owner-bound issuance**: only the signed-in member may request a code for their own verification (`actor` must equal `memberRef`), so no actor can trigger a one-time-code send to a phone on another member's behalf — the enumeration / cross-account-abuse guard — and the gate is checked before any challenge state, so an unauthorized requester learns nothing. The second is **idempotent on verified**: a pair whose challenge is already `verified` never issues another code, so a proven number is never re-sent a code. The third is **cooldown-bounded, brute-force-loop-closing**: a request before `reissueAfter` issues nothing, and because the gate applies to a `voided` prior exactly as to a `pending` one, the exhaust-attempts → void → reissue loop that a pure per-challenge attempt bound (§8.16) cannot see is closed — §8.16's budget cannot be reset faster than the owner's cooldown. The fourth is **at-most-one-live-challenge**: issuing while a live `pending` challenge exists supersedes it and names it for the caller to void, so parallel live challenges — which would multiply the total guess budget for one pair — never accumulate. The fifth is that the contract **mints nothing and confers nothing**: an `issue` verdict authorizes a send and carries only the `subject` and, when superseding, an *existing* challenge ref to void — never a code, a hash, a new challenge id, or a policy value — and it grants no Firebase authentication, membership, discount, or role and does not itself prove control of the phone (that remains §8.16's job); the phone is a channel handle (a bare all-digit value is accepted) while the member and actor are letter-requiring identities, so a phone number can never masquerade as the member a code is for. The sixth is that it **invents no policy**: the cooldown duration, the code lifetime, and the attempt budget are owner policy; the caller supplies the already-computed `reissueAfter` and `expiresAt` instants and this reducer only enforces the gates by comparison, setting no threshold of its own (cf. the #114 owner-decision rule). The seventh is that it is **clockless**: the cooldown and liveness gates compare `asOf` against `reissueAfter`/`expiresAt` by lexical UTC string comparison, which for fixed-width UTC instants over real calendar dates agrees exactly with chronological order, so the reducer reads no clock and constructs no `Date`; the timestamp validator is calendar-aware and rejects impossible dates (a non-leap Feb 29, an Apr 31), which — since malformed only ever withholds — can never enable an issuance past a cooldown. Finally the contract is **hostile-input-safe**: every field of each record is read through an own-enumerable data descriptor with no getter ever invoked, and a proxy (including a revoked one), a non-`Object.prototype` prototype, an inherited, extra (enumerable or non-enumerable), missing, or symbol key, or an out-of-shape value denies as malformed rather than being partially interpreted or throwing.

This contract invents no policy and duplicates no sibling. It owns the *one-time-code issuance disposition* — may the server mint and send a new code for this pair now, and does that supersede a live prior challenge — and it is deliberately not any neighbour. §8.16 `classifyWhatsappVerificationRedemption` (#419) decides whether one *redemption attempt* proves channel control and how a single challenge's *attempt budget* advances; this issues the challenge that §8.16 later redeems, minting no code and comparing no hash, and bounds the *number and rate* of challenges §8.16 cannot see. §8.0e `classifyMembershipProviderLink` (#367) is the provider-neutral *link/collision* authorizer (whether linking this external account to this member collides with another owner, gated on consent); this triggers no link, resolves no collision, and reads no occupant list — it decides only whether a verification code may be sent. §8.0d `projectMembershipConsentState` (#370) owns the versioned *consent* state; this captures no consent and reads none. It runs no generic rate limiter (the Firestore-backed `rateLimit.js` mechanism is infrastructure, not this decision), generates and hashes no code, sends no template, makes no provider call, enforces no App Check, persists no challenge, normalizes no phone value, and resolves no multi-UID ownership — the caller runs its rate limiter and consent/link checks, projects the stored challenge into this reducer's shape, feeds it the request, and on an `issue` verdict mints+hashes+sends the code, voids any named superseded challenge, and persists the new challenge with the owner's lifetime, attempt budget, and next `reissueAfter`. The one-time code generation and templated send, the hashing and App Check, the durable challenge persistence, the versioned-consent capture, the phone normalization and silent-multi-UID-ownership prevention, and the opt-out, reassignment/recovery, deletion, and audited conflict handling all remain with #87 and its dependencies (#85's Cloud API selection; #81; coordinating AUTH-001 and ABUSE-001).

The module is imported by no runtime or Functions index and requires only `node:util`. It reads no clock, randomness, environment, network, Firestore, or provider service; is imported by no endpoint; stores and logs nothing; holds, reads, or compares no code, hash, or secret; mints no identifier; and grants, sends, or persists nothing beyond the returned verdict. It defines no Firestore schema or Rules, enforces no live rate limit or App Check, and wires into no route or WhatsApp/provider adapter. Source change, tests, merge, Firebase deployment, provider configuration, production data, and live behavior remain separate states; #435 issues no real code and proves none of the external or live states.

## 9. Consistency and failure model

Stripe and Firestore cannot share a distributed transaction. Checkout and refunds are therefore explicit sagas:
Expand Down
Loading