Reinstate-at-login: scenario matrix โ
Built with the scenario-matrix skill (decide every case before coding, because this ships dormant behind the Stage B flags and cannot be smoke-tested live until the flip). This is the working record for the reinstate-at-login enhancement; the signed-off feature design is SEALED_BAN_APPEAL_DESIGN.md, the axis model is SEALED_BAN_RECLAMATION_SPEC.md ยง4, and the Option A/B reinstate model is SEALED_BAN_RECONCILIATION_SPEC.md ยง6 (whose EC-1..EC-17 cover arming, not the marker).
Status: DESIGN (decisions being locked). Ships dormant behind the Stage B flags.
The one paragraph that dissolves the confusion โ
A ban has two independent switches: the phone axis (a banned_accounts row keyed by phoneHash) and the userId axis (users/{uid}.banned + Auth disabled). A moderator reviewing a number-blind appeal (the logged-out appellant proved possession by OTP, so we hold the phoneHash but never the number and never the account) can reach the phone switch directly (unban-phone by hash) but cannot reach the userId switch, because the seal means there is no phoneHash -> userId link to follow. The only moment userId and phoneHash are co-held is at the owner's next /token login: the PIN proves the account, the context token proves the phoneHash pairing. So a number-blind reinstate of a sealed userId ban cannot happen at the moderator's click; the moderator can only approve it (write a marker keyed by phoneHash), and the userId switch actually flips at the owner's next proven login. That is "reinstate-at-login," and it is the exact mirror of harvest-at-login (which arms a ban at that same co-holding moment; this lifts one).
Everything confusing about reinstate is a consequence of that: the marker is phoneHash-keyed and fires at a future login, so it inherits every "what if the future is weird" question (owner never returns, number recycled, same-request timing).
When the marker is even needed (this shrinks the scary part) โ
| Appeal shape | Phone axis | userId axis | Marker needed? |
|---|---|---|---|
Deliberate ban-phone, no account behind it | moderator clears directly (unban-phone) | none exists | No. The phone clear IS the whole reinstate. |
Case-linked (appellant logged in, linkedUserId known) | moderator clears directly | moderator clears directly (/unban on the known uid) | No. Option A works fully; both switches reachable. |
Number-blind, sealed userId ban (login-harvest origin) | moderator clears directly | unreachable at review time (sealed) | Yes. This is the only case the marker exists for. |
So the marker is a narrow tool for exactly one shape: a number-blind reinstate of a sealed userId ban. Reclaim never needs it (reclaim leaves the userId axis alone by definition). Case-linked reinstate never needs it (the uid is in hand).
Axes โ
- A. Origin / what is behind the block:
ban-phone(phone only, no account) |login-harvest(sealed userId ban armed a phone row) | case-linked (uid known) - B. Claim: reclaim (new owner) | reinstate (same person) | uphold
- C. Tier: standard | permanent
- D. Owner behavior after approval: returns and logs in | never returns, number recycled, a new person seals then is later banned
- E. In-request condition at the firing login: the account snapshot read at the top of
/tokenvs the post-lift state (an intra-request ordering hazard, treated as an invariant, not a cell)
Struck cells (considered, ruled out, with the reason) โ
- reinstate x recycled-newcomer actor -> struck: incoherent. Reinstate is the same person forgiven; a newcomer on a recycled line is a reclaim. There is no "reinstate a newcomer."
- marker x case-linked (uid known) -> struck: no marker. The moderator lifts the userId axis directly at review (
/unbanonlinkedUserId); nothing waits for login. - marker x deliberate
ban-phone(no account) -> struck: no marker. There is no userId axis to reach;unban-phoneat review is the complete action. - reclaim x userId axis -> struck by construction (reclamation ยง4.1): reclaim clears the phone lease only and must never read or write the old user doc. Not a reinstate concern; listed so no one wires it later.
Surviving cells (the marker's real behavior) โ
| # | Origin | Tier | Timing | Decision (outcome) | Mechanism / guard | Assumption checked |
|---|---|---|---|---|---|---|
| R1 | login-harvest (sealed userId ban) | standard | owner returns | Lift both axes at the owner's next proven login | Moderator approve -> phoneHash-keyed marker; at /token, on a confirmed pair, lift users/{uid}.banned=false and clear the phone row in the same call | The marker does NOT rely on Option B to clear the phone row (Option B only runs when users.banned===false, the opposite of this moment). Confirmed: marker clears phone axis itself. (landmine #3) |
| R2 | login-harvest | permanent | owner returns | Same as R1, plus a required audited moderator rationale | Same marker; doc gate does NOT apply. No document. A required free-text rationale persists to adminActions on the approve action | The permanent-tier document proves the number is newly issued (a reclaim question). Reinstate asserts this is the same, forgiven person, so newness proof is not just unnecessary, it is incoherent (a returning owner's line predates the ban). (resolves the open doc-gate decision) |
| R3 | login-harvest | either | owner never returns, number recycled, new person seals then is later banned | Stale marker must NOT fire for the new owner | Stamp the marker with a fingerprint of the approved account's sealed record (e.g. auth_lookup.authProofHash); at fire, require the fingerprint to still match. On recycle, auth_lookup/{phoneHash} repoints to the new owner, so the fingerprint no longer matches and the marker is inert; also delete-on-recycle + TTL | Assumed "phoneHash key is enough to target the right person" -> FALSE across a recycle. The seal stops account takeover (different PIN, pairing fails) but a naive marker is still a ban-evasion hole. (landmine #1) |
| R4 | login-harvest | either | the firing login itself (intra-request) | The lift must not reverse itself inside the same request | After lifting the ban in the DB, flip the in-memory account snapshot /token read at the top, so the subsequent ban re-check sees banned=false and does not re-arm/re-block | Assumed "lift the DB row and the request is done." FALSE: /token works off a snapshot read at the top; a stale snapshot re-blocks and re-arms, silently undoing the reinstate. (landmine #2) |
| R5 | login-harvest | either | owner returns after the ~30-min appeal-session token expired | Marker still fires | The marker is durable server state keyed by phoneHash + fingerprint, independent of the appeal-session token. Appeal-session expiry (a known dedup gap) does not touch the marker | Assumed the appeal session and the reinstate approval share a lifetime. FALSE and fine: approval outlives the session by design; the owner can return days later. |
| R6 | login-harvest | either | marker already fired once, owner logs in again | No double-lift, no re-arm | Marker is single-use: consumed (deleted) on the firing that lifts the ban. A second login sees no marker and the normal (now-unbanned) path | Assumed "leave the marker; it is idempotent." Safer to consume: a lingering marker is a standing auto-unban primitive on that number. Single-fire + delete. |
| C1 | ban-phone (no account) | either | reinstate approved | Clear the phone row at review; no marker | unban-phone by hash at the moderator click. Permanent tier: audited rationale, no document (same reasoning as R2) | Confirmed there is no userId axis behind a pure ban-phone; nothing to defer to login. |
| C2 | case-linked (uid known) | either | reinstate approved | Clear both axes at review; no marker | /unban on linkedUserId + unban-phone by hash, both at the click (Option A, already built) | Confirmed linkedUserId is only set when the appellant authenticated as that user, so the uid is trustworthy to unban directly. |
| C3 | any | either | uphold / deny | Nothing changes | No marker, no axis touched; record the decision to adminActions | Deny must be inert on both axes. |
Invariants (must hold in every surviving cell) โ
| # | Invariant | Where it could break | Held by |
|---|---|---|---|
| I1 | Seal intact: the marker stores no phoneHash -> userId link at rest. The userId is only ever supplied by the owner's own PIN at the firing login | R1, R2 | Marker is phoneHash-keyed only, same class as banned_accounts; the fingerprint is authProofHash, not a uid |
| I2 | Possession + account both proven before any lift | R1..R6 | OTP possession at appeal (to write the marker) AND PIN pairing at /token (to fire it). Neither alone lifts |
| I3 | No wrong-person fire across a recycle | R3 | Seal-fingerprint match at fire; delete-on-recycle; TTL |
| I4 | No self-undo within the firing request | R4 | Local snapshot flip after the DB lift |
| I5 | Bounded and single-fire: the marker is not a standing auto-unban primitive | R3, R6 | Single-use consume-on-fire; TTL; scoped to the exact ban appealed |
| I6 | Phone axis cleared by the marker itself, not by assuming Option B runs | R1, R2 | The fire path calls unban-phone directly; does not depend on users.banned===false pre-state |
| I7 | Deny/uphold is inert on both axes | C3 | No writes on a deny |
Guards / implementation checklist (extracted) โ
- G1 seal-fingerprint stamp. Marker carries a fingerprint of the approved account's sealed record (
auth_lookup.authProofHashat approval). Fire requires a current match; a recycle repointsauth_lookupand voids it. (I1, I3) - G2 local-snapshot flip. After the in-request DB lift, mutate the account object
/tokenre-checks so the ban gate seesbanned=false. (I4) - G3 marker clears the phone axis. The fire path runs
unban-phoneby hash itself; it does not defer to the Option B self-clear (which only runs post-unban). (I6) - G4 scope + lifecycle. Marker keyed to the exact ban appealed (
banRecordId+ fingerprint), single-use (consume on fire), TTL backstop, delete-on-recycle. (I5, I3) - G5 permanent-tier reinstate: rationale, not document. No document upload for reinstate. A required moderator rationale persists to
adminActionson approve; the reclaimdocgate stays reclaim-only. (R2, C1)
Open decisions โ
- Reinstate doc gate: LOCKED (operator, 2026-07-14) as R2 -> no document; require an audited rationale. Implementing the required rationale (G5) is the one remaining #658 checklist item.
- Marker TTL length: LOCKED at 90 days (delete-on-recycle is the real safety, not the clock). Implemented in
reinstateMarkers.service.js(REINSTATE_MARKER_TTL_DAYS). - BUILT (2026-07-14, DORMANT): slices A-D shipped (store + rules + marker approval + fire + delete-on-recycle + 16 unit tests). See design doc section 6a.
After the flag flips (hand-off to test-plan) โ
Each surviving cell (R1..R6, C1..C3) becomes a test-plan scenario; each invariant (I1..I7) becomes a data-verification line (Firestore doc + field, adminActions action, the absence of a phoneHash->uid link). The blind matrix becomes the live test plan on the Stage B flip.
Live verification (2026-07-14): DONE locally (Stage B on in .env.local) โ
Because STAGE_B_SEALED_USERID_ENABLED is ON in the local .env.local, the marker was driven END TO END live, not just unit-tested. On the sealed test account "Onyx Ray" (+16195550100, PIN 562461, authProofHash 5fb12afb...) carrying a permanent SEALED userId ban:
- Web login blocked (userId ban) -> harvest-at-login armed a
login-harvestphone row. - Number-blind appeal (logged out, OTP) -> admin Reinstate (permanent tier, required rationale) -> marker created:
reinstate_markers/{phoneHash}withsealFingerprint == 5fb12afb..., 90-day expiry. userId axis still banned (a number-blind reinstate cannot lift it). - Re-login FIRED the marker:
users.banned -> false+ Auth re-enabled (G2, and the login SUCCEEDED to the dashboard, no self-undo), phone axis cleared to 0 active bans (G3), marker consumed/gone (G4),adminActions{reinstateAtLoginFired, performedBy: system:reinstate-at-login}logged. G1 held (fingerprint matched).
So R1 is verified live end to end; G1-G4 are confirmed by both the live run and the 16 unit tests.
Test-infra gotcha: the local :8084 auth-api must be restarted after the #658 build. The first reinstate ran a stale process (built before slice B/G5) and silently created no marker + dropped the rationale; restarting onto branch code fixed it. Always confirm the running auth-api postdates the code under test.