Skip to content

Sealed-ban appeal flow: implementation design โ€‹

Status: DESIGN, awaiting sign-off. No code yet. This turns the conceptual appeal flow already specced in SEALED_BAN_RECONCILIATION_SPEC.md ยง6.1 and SEALED_BAN_RECLAMATION_SPEC.md ยง4 into concrete collections, endpoints, and screens. It is the last real feature gap in Stage C: today the appeal path is a mailto:appeals@ourlantern.app stub.

How to read this: each section opens with an ELI5. The one-line idea: a blocked person appeals inside the app by proving they hold the number (OTP), we keep only the fingerprint of that number, and a moderator sorts it out without ever seeing the number.


0. Why this exists (the one paragraph) โ€‹

A phone number in an inbox is exactly the plaintext paper trail the sealed model exists to avoid. So the appeal is an in-product flow, not an email: the appellant proves possession by OTP, the server hashes the number and discards the plaintext, and a hash-only appeal record lands in the moderation queue. The moderator adjudicates on case context (why the number was blocked), never the number itself, and one action reinstates. The same front door serves the innocent recycled-number newcomer (reclaim) and the forgiven owner (reinstate); they differ only in which ban axis the moderator lifts (reclamation ยง4.1).


1. What replaces the mailto: stub โ€‹

Current appeal touchpoints, all pointing at appeals@ourlantern.app, that this flow replaces:

SurfaceFileContext
In-app moderation noticeModerationNoticeModal.jsx (APPEAL_EMAIL, ~L40/207)User banned/limited, opens notice from inbox
Signup blockedPhonePinSignup.jsx (~L247/689)Phone-ban hit at registration (the recycled-number case)
Login blockedPhonePinLogin.jsx (~L40)Phone-ban hit at login
Notice copy templatesCaseDetailPanel.jsx (~L82/88)Moderator-sent notice text tells the user to email

Each becomes an "Appeal this" affordance that opens the in-portal appeal screen instead of a mail client.


2. Data model: the appeals collection โ€‹

ELI5: One nameless card per appeal. It holds the number's fingerprint, the person's message, and where the appeal is in its life. It never holds the number itself, and it holds a userId only if the appeal is already tied to an account ban.

A new top-level Firestore collection appeals/{appealId}:

FieldTypeNotes
phoneHashstringThe phoneHash of the appealed number. The join key to banned_accounts. Never the plaintext.
messagestringThe appellant's free-text case, capped (e.g. 1000 chars).
statusenumpending | reclaimed | reinstated | denied | withdrawn.
tierenumstandard | permanent, copied from the matched ban at submit time, so the queue can show the reclaim bar (ยง4.2) without re-deriving it.
linkedUserIdstring | nullSet only when the appeal is opened from a userId-ban notice (the notice already carries the case/userId context). Null for a bare signup/login phone-block appeal.
bannedAccountIdstring | nullThe matched banned_accounts doc id at submit time (may be null if no active phone ban matched, e.g. a userId-only ban).
possessionVerifiedAttimestampWhen the OTP possession proof passed. An appeal cannot be created without it.
createdAttimestamp
resolvedAttimestamp | null
resolvedBystring | nullAdmin uid who adjudicated.
docProofenum | nullPermanent tier only: verified | not_verified, or null if not required / not yet reviewed. No document is ever stored (ยง7).

Invariants (enforced server-side):

  • No plaintext phone number field exists. Ever.
  • linkedUserId is the only userId that can appear, and only when the appeal originated from an authenticated userId-ban notice. A phone-block appeal (unauthenticated) carries none, preserving the seal.
  • Client cannot write this collection directly (rules deny client writes); every write goes through the endpoints below, which do the hashing + plaintext discard.

2a. Thread model (full conversation) โ€‹

ELI5: The appeal is a chat, not a single note. Each side can post; a moderator can ask for more, the appellant can answer.

appeals/{appealId}/messages/{messageId}:

FieldTypeNotes
bodystringMessage text, capped.
authorRoleenumappellant | moderator.
authorIdstring | nullModerator uid for moderator messages. Null for appellant (sealed: the appellant is number-only, never a stored identity).
createdAttimestamp

The first appellant message is the original appeal text. appeals.status still drives the lifecycle; the thread is the human back-and-forth underneath it.

How a logged-out appellant returns to their thread (the sealed part): possession is the key. Submitting mints a short-lived appeal-session token (bearer, ~30 min) tied to the phoneHash, authorizing read + reply on that appeal only. To return later, the appellant re-verifies the number by OTP and gets a fresh token. No account, no stored identity, no plaintext number: holding the line is what proves it is your appeal. (The authenticated userId-ban path instead uses the user's normal session, scoped to linkedUserId.)


3. Backend endpoints โ€‹

ELI5: Two doors. A public door where a blocked person proves they hold the number and files the appeal (no login needed, because a signup-blocked newcomer has no account). An admin door where a moderator reads the queue and clicks reclaim or reinstate.

3a. Public submit door (App Check gated, NOT admin, NOT necessarily logged in) โ€‹

Mounted alongside the existing OTP routes under /auth/phone (or a new /auth/appeal), using verifyAppCheck without requireAdmin / verifyFirebaseToken, because the appellant may be a logged-out signup-blocked newcomer. Possession, not session, is the gate.

  • POST /auth/phone/appeal/start -> body { phone }. Reuses the existing phoneOtp.js send-otp machinery to send a possession OTP. (May just reuse POST /auth/phone/send-otp directly.)
  • POST /auth/phone/appeal/submit -> body { phone, otpCode, message, linkedUserId? }. Server: verify the OTP (possession proof) -> compute phoneHash -> look up the matching active banned_accounts row via listActivePhoneBans(phoneHash) to stamp tier / bannedAccountId -> write the appeals record -> discard the plaintext number. Returns { appealId, status: 'pending' }. Rate-limited (public write surface; see ยง8).

linkedUserId is accepted only when the caller presents a valid session for that user (i.e. an authenticated banned user appealing their own account); otherwise it is ignored.

3b. Admin adjudication door (/auth/moderation, admin-gated) โ€‹

Added to moderation.js (already behind verifyFirebaseToken + requireAdmin), so it composes with the existing ban/unban/ban-phone/unban-phone actions:

  • GET /auth/moderation/appeals?status=pending -> the queue (number-blind: each item joins its phoneHash to the banned_accounts reason/severity/date, never the number).
  • POST /auth/moderation/appeals/:id/reclaim -> phone axis only. Calls the existing unban-phone path (overturns the banned_accounts row by hash). Does not touch linkedUserId's user doc. Sets status: 'reclaimed' (reclamation ยง4.1).
  • POST /auth/moderation/appeals/:id/reinstate -> both axes. Calls unban on linkedUserId (if present) plus unban-phone (Option A, ยง6). Sets status: 'reinstated'.
  • POST /auth/moderation/appeals/:id/deny -> body { reason }. Sets status: 'denied'.
  • POST /auth/moderation/appeals/:id/doc-review -> body { result: 'verified'|'not_verified' }. Permanent tier only; records the transient-doc outcome (ยง7) without storing a document.

The admin actions reuse the existing service functions (bannedAccounts.service.js: overturnBan, and the userId /unban path), so reclaim vs reinstate is purely which existing action(s) fire. That is the whole safety argument: the axes are already independent records, so "reclaim can never reinstate an account" holds by construction (reclamation ยง4.1).

3c. Thread + document endpoints โ€‹

  • POST /auth/phone/appeal/message (appellant, appeal-session-token gated) -> body { appealId, body }. Posts an appellant message. Rate-limited on the per-number budget.
  • GET /auth/phone/appeal/thread (appellant, token gated) -> the appeal status + messages.
  • POST /auth/moderation/appeals/:id/message (admin) -> posts a moderator message.
  • POST /auth/phone/appeal/doc (appellant, token gated) -> ephemeral raw single-file upload of the permanent-tier proof (the file's own MIME type is the Content-Type; JPEG/PNG/WebP/PDF, <= 8MB - not multipart, since the logged-out appellant sends exactly one file). Stores to a temporary, non-public object keyed to appealId with a short TTL; sets a docPending flag. See ยง7.
  • GET /auth/moderation/appeals/:id/doc (admin) -> a short-lived signed URL to review the pending document.
  • POST /auth/moderation/appeals/:id/doc-review (admin) -> body { result }; records docProof and hard-deletes the stored object in the same call.

4. App (web) UI โ€‹

ELI5: The "contact us by email" line becomes an "Appeal this" button that opens a short form: enter your number, get a code, write why, submit. Then a status screen.

  • Appeal screen (new, e.g. apps/web/src/screens/auth/AppealFlow.jsx): number input -> OTP verify (reuses the existing OTP component) -> message box -> submit -> confirmation with the returned appealId. Same possession UX as signup, so it is familiar and the recycled- number newcomer travels an identical path.
  • Entry points: swap the four mailto: affordances (ยง1) to open this screen. For the authenticated banned-user path (ModerationNoticeModal), pass linkedUserId through so the appeal ties to the case. For the logged-out signup/login-block path, no userId is passed.
  • Status view: a banned user opening a later notice can see their appeal's status (pending / resolved). Read via a thin authenticated GET scoped to their linkedUserId, or by appealId returned at submit.

5. Admin (moderation) UI โ€‹

ELI5: A new "Appeals" tab next to Enforcement / Cases. Each row shows why the number was blocked (never the number) and two buttons: reclaim the number, or reinstate the account.

  • Appeals live inside Cases, not a separate top-level tab (decided 2026-07-13). Appeals are moderation-queue work like cases, so ModerationCases.jsx gets a Reports | Appeals segment toggle inside the existing Cases view; the top-level VIEW_TABS stay Enforcement / Cases / Manual ban / Manual unban.
  • Two kinds of appeal, one queue. Case-linked appeals (linkedUserId set, from a userId ban) and phone-only appeals (recycled-number signup/login block, no report and therefore no case) both list in the Appeals segment. Phone-only ones are exactly why appeals cannot be pure children of a case: there is no parent case to nest them under.
  • Inline on the parent case. A case-linked appeal also surfaces as a panel on its case detail ("This decision was appealed - <status>"), so a moderator working the case sees it in context. The panel and the segment row open the same drawer.
  • Queue (mirrors the Enforcement table + drawer we just built): rows show ban reason, severity, tier, date, and the appellant message. Row click opens a drawer.
  • Appeal drawer (mirrors EnforcementDetailPanel.jsx): the number-blind context + the message + adjudication buttons:
    • Reclaim number (new owner / recycled line) -> reclaim. Copy makes explicit the old account stays banned.
    • Reinstate account (same person, overturned) -> reinstate.
    • Deny -> deny with a reason.
    • For tier: permanent, a documentary-proof step gates Reclaim (ยง7 / reclamation ยง4.2): the moderator records verified / not-verified before the reclaim button enables.
  • New client functions in moderationApi.js: listAppeals, reclaimAppeal, reinstateAppeal, denyAppeal, reviewAppealDoc.

6. Reclaim vs reinstate: the axis wiring (the safety core) โ€‹

OutcomePhone axis (banned_accounts by hash)userId axis (users/{uid} + Auth disabled)Who returns
Reclaim (recycled, NEW owner)Cleared (unban-phone)UntouchedThe newcomer registers; the old banned account stays locked
Reinstate (SAME person, forgiven)Cleared (unban-phone / self-clear)Cleared (/unban)The original owner returns
DenyUntouchedUntouchedNo one

The two controls are independent records, so the phone-axis clear is scoped to the phoneHash (the lease) and never reads or writes the old user doc (the identity). This is the same decoupling that lets a sealed userId-ban arm a hash-only phone row with no back-link (reclamation ยง4.1). The old account cannot re-grab a reclaimed number either: once the newcomer seals, auth_lookup/{phoneHash} repoints to them and the old user's proof no longer pairs (reclamation ยง4.1).


6a. Reinstate-at-login: the marker (number-blind reinstate of a sealed userId ban) โ€‹

ELI5: On a number-blind appeal the moderator can flip the phone switch (they hold its fingerprint) but not the account switch (the seal hides which account it is). The only moment both are reachable is the owner's next login, where the PIN proves the account and the context token proves the fingerprint. So the moderator does not reinstate at the click; they APPROVE it (write a marker), and the account switch flips at that next proven login. This is the mirror of harvest-at-login: harvest arms a ban at that keyhole, this lifts one.

Needed for exactly one appeal shape. A login-harvest-origin phone block sits on top of a sealed userId ban whose uid the moderator cannot see. Reinstating (not reclaiming) that person requires lifting the userId axis, which is unreachable number-blind. The marker bridges it. It is NOT used for a reclaim (reclaim leaves the userId axis alone, ยง6) nor for a case-linked appeal (the uid is in hand, so /unban runs at the click, Option A).

Fires at /token on a confirmed pair, carrying five guards (full cell-by-cell in the scenario matrix, docs/planning/plans/2026-07-14_reinstate-at-login_scenario-matrix.md):

  1. Seal-fingerprint stamp. The marker carries a fingerprint of the approved account's sealed record (auth_lookup.authProofHash); fire requires a current match. A recycle repoints auth_lookup, voiding the marker, so it can never fire on a new owner of the recycled number.
  2. Local-snapshot flip. After the in-request DB lift, mutate the account object /token re-checks, so the ban gate does not re-arm and silently undo the reinstate within the same request.
  3. Marker clears the phone axis itself (unban-phone by hash). It does NOT defer to the Option B self-clear, which only runs when users.banned === false (the opposite of this moment).
  4. Scope + lifecycle. Keyed to the exact ban appealed, single-use (consumed on fire), delete-on-recycle, plus a TTL backstop.
  5. Permanent tier = rationale, not document. A permanent reinstate does NOT use the ยง7 document (that proves the number is newly issued, a reclaim question). Instead a required moderator rationale persists to adminActions.

Seal preserved: the marker stores no phoneHash -> userId link; the uid is only ever supplied by the owner's own PIN at the firing login. Same class as a banned_accounts row.

Decisions (2026-07-14): permanent reinstate = audited rationale, no document (locked). Marker TTL = ~90 days with delete-on-recycle as the real safety, not the clock. Ships dormant behind the Stage B flags with the rest of the sealed core; not live-testable until the flip, which is why the scenario matrix stands in for a live test.

BUILT (2026-07-14, DORMANT behind STAGE_B_SEALED_USERID_ENABLED): reinstate_markers/{phoneHash} (server-only, firestore.rules) + services/reinstateMarkers.service.js (create / findActive / consume / deleteForHash, TTL 90d) + services/reinstateAtLogin.service.js (fireReinstateAtLogin, guards G1 fingerprint / G3 phone-axis clear / G4 consume), wired into routes/phone.js tokenHandler (fire + the G2 in-request snapshot flip), routes/moderation.js reinstate (approve the marker on a number-blind sealed reinstate), and routes/phoneCreateUser.js (delete-on-recycle at a new seal). 16 unit tests (11 store + 5 fire). G5 (require an audited rationale on a permanent-tier reinstate) is the one remaining checklist item.


7. Permanent-tier documentary proof (the one open sub-problem) โ€‹

ELI5: For a forever-ban with no expiry safety net, "I hold the number now" is not enough; the newcomer must show the line is newly theirs. We look at that proof, decide yes/no, and keep only the yes/no, never the document.

Per reclamation ยง4.2: standard-tier reclaim is light (OTP + the ~45-day aging gate + judgment); permanent-tier reclaim additionally requires proof the number was newly issued to the appellant (service-start after the ban date). The proof is a transient artifact: reviewed by a human, then discarded, exactly like the plaintext number. Only docProof: verified | not_verified persists on the hash-keyed record. No name, address, or number is kept.

Mechanism (DECIDED: ephemeral upload, ยง11.1): the appellant uploads the new-issuance proof via POST /auth/phone/appeal/doc. The server writes it to a temporary, non-public object (e.g. a dedicated GCS prefix) keyed to appealId, with a short TTL lifecycle rule as a backstop. The moderator reviews it through a short-lived signed URL (GET /auth/moderation/appeals/:id/doc). The instant the moderator records verified / not-verified (doc-review), the object is hard-deleted in the same call. Only docProof: verified | not_verified persists on the hash-keyed record: no name, address, or number is kept, and the document never becomes durable state. Auto-redaction stays rejected as a fragile stopgap (reclamation ยง4.2); review-then-hard-delete is the cleaner path.


8. Security properties this must preserve (checklist) โ€‹

  • No plaintext number is stored, logged, or emailed. It transits once over TLS to be hashed, then is discarded (matches the ยง8 log rule in the reconciliation spec).
  • Public submit door is App-Check gated, OTP-gated (possession), and rate-limited per number/IP. It is a write surface reachable by logged-out users, so it is the highest-risk new endpoint; abuse budget must be explicit (no silent unbounded appeals).
  • No new third party. Possession uses the OTP provider already in the loop; no reassignment vendor is added (reclamation ยง5).
  • Seal intact: a phone-block appeal carries no userId; only a userId-ban-notice appeal carries linkedUserId, and only when the caller authenticates as that user.
  • Moderator is number-blind: the queue joins hash -> ban context, never hash -> number.
  • Rules: appeals is client-read-none / client-write-none (server-only); admin-readable via the admin SDK path, exactly like banned_accounts.

9. Firestore rules โ€‹

match /appeals/{appealId} {
  allow read, write: if false;  // server-only, like banned_accounts
}

All reads/writes flow through the Cloud Run auth-api (admin SDK). The app never touches the collection directly; it calls the submit endpoint. The admin portal never touches it directly; it calls the admin endpoints.


10. Proposed build slices โ€‹

  1. Design scaffold (this step). Storybook stories / HTML mock of both surfaces (appellant flow + admin appeals drawer with thread) for a visual review before component work.
  2. Backend core. appeals collection + messages subcollection + submit door (OTP + hash
    • discard) + appeal-session token + admin list/reclaim/reinstate/deny + thread message endpoints. Unit + route tests. Dormant (no UI yet).
  3. Admin UI. Reports | Appeals segment inside the Cases view + the appeal drawer (thread view + reclaim/reinstate/deny), reusing the Enforcement table/drawer pattern, plus the inline "appealed" panel on a case-linked case detail.
  4. App UI. Appeal screen (number -> OTP -> message -> thread) + swap the four mailto: entry points; return-to-thread via OTP re-verify.
  5. Notice-copy update. Change the CaseDetailPanel notice templates + auth-screen copy from "email us" to "appeal in-app."
  6. Permanent-tier ephemeral upload (ยง7). [BUILT 2026-07-13] Upload endpoint (POST /auth/phone/appeal/doc, appeal-session-token gated, raw single-file body) + signed-URL review (GET /auth/moderation/appeals/:id/doc) + hard-delete on doc-review; perma-tier Reclaim button already gated on docProof === 'verified'. App AppealFlow shows the upload affordance in the thread; admin AppealDetailPanel renders the doc inline for review. Ops (verified against deployed dev 2026-07-14): NO new deploy config is needed for slice 5 specifically. The bucket resolves with zero env: APPEAL_DOC_BUCKET override, else ${GOOGLE_CLOUD_PROJECT}, else ADC/metadata via google-auth-library getProjectId() (so it works on Cloud Run even though GOOGLE_CLOUD_PROJECT is not in the service env). V4 signed URLs already work: the dev runtime SA (...-compute@) already holds roles/iam.serviceAccountTokenCreator on itself. Optional hardening only: a Storage lifecycle rule deleting appeal-docs/ objects after ~7 days (a TTL backstop; the hard-delete on review is the primary reclaim). The appeal flow's OWN go-live gates (not slice-5-specific) still apply: APPEAL_SESSION_SECRET on the deployed auth-api + OTP_PROVIDER=prelude + the deployed firestore/storage rules.

Live-verify each slice in the dev admin/app tabs, push-as-we-go on claude/sealed-identity-stage-c-safety (no PR), same as the rest of C3.


11. Decisions (RESOLVED 2026-07-13) โ€‹

  1. Permanent-tier document handling (ยง7): EPHEMERAL UPLOAD. The appellant uploads the new-issuance proof; the moderator reviews it via a short-lived signed URL; the object is hard-deleted the moment the moderator records verified / not-verified, with a storage TTL lifecycle rule as a backstop. Only the yes/no persists on the hash-keyed record. See ยง7.
  2. Appeal shape: FULL THREAD. Not one-shot. The appeal is a back-and-forth conversation (appeals/{id}/messages), so a moderator can ask for clarification and the appellant can respond. See ยง2a (thread model) and ยง3c (thread endpoints). This is the "conversation thread" ยง6.1 calls for.
  3. Submit endpoint home: EXTEND /auth/phone. Reuses the existing OTP mounting + App Check rather than a new router.
  4. Rate-limit budget (proposed default, adjustable): the public submit door is capped at 3 appeals per number per 24h and 10 per IP per 24h, keyed on phoneHash (post-hash) and request IP. Thread replies from the appellant reuse the same per-number budget. Tune before ship; stated here so the endpoint is never unbounded.

Built with VitePress