In-portal appeal flow - Manual Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | claude/sealed-identity-stage-c-safety (no PR yet) |
| Environment | local dev: web :5173 + admin :3001 -> auth-api :8084 (all hit LIVE lantern-app-dev Firebase) |
| Build flags / config | OTP_PROVIDER=prelude; APPEAL_SESSION_SECRET set in the worktree .env.local |
| Build (commit) | 10ed510f + this run's live verification (agent-driven in the browser) |
| Tester | Claude (agent-driven, in-browser) + Mechelle (OTP code) |
| Date | 2026-07-13 |
Guardrail: Prelude test number only = +16195550100, fixed test OTP 562461. Never a real number. One-line idea: a blocked person appeals inside the app by proving they hold the number (OTP); we keep only its fingerprint and a moderator sorts it out without ever seeing the number.
Summary โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | Phone-only appeal via login block (OTP 562461) | [x] pass [ ] fail [ ] blocked | agent, in-browser |
| 2 | Phone-only appeal via signup block | [x] pass [ ] fail [ ] blocked | 2026-07-14, in-browser (invite link; step-1 block + appeal) |
| 3 | Wrong / expired OTP | [x] pass [ ] fail [ ] blocked | agent, in-browser |
| 4 | Clean (un-banned) number -> refused | [x] pass [ ] fail [ ] blocked | logged-out + logged-in both refused (finding FIXED same-day) |
| 5 | Rate limit on the submit door | [x] pass [ ] fail [ ] blocked | agent, in-browser (429 on the 4th submit) |
| 6 | Come back to a pending appeal (same session) | [x] pass [ ] fail [ ] blocked | agent, in-browser (localStorage restore) |
| 7 | Moderator reply reaches the app | [x] pass [ ] fail [ ] blocked | earlier E2E, 2026-07-13 |
| 8 | Session-expiry behavior (known gap) | [ ] pass [ ] fail [ ] blocked | not run (30-min wait); dedup is a known gap |
| 9 | Case-linked appeal from a restriction notice | [x] resolved (obsolete) | restriction tier removed, commit 8c6e9b42 (see Notes) |
| 10 | "Your appeals" lists it in the Safety tab | [x] pass [ ] fail [ ] blocked | agent, in-browser (logged in as Onyx Ray) |
| 11 | Open thread + reply while pending; closed state | [x] pass [ ] fail [ ] blocked | agent, in-browser (reply posted) |
| 12 | Ownership: cannot read another user's appeal | [x] pass [ ] fail [ ] blocked | agent, /mine/:id non-owned -> 404 |
| 13 | Admin drawer case-linked context (case fallback) | [x] pass [ ] fail [ ] blocked | agent, in-browser (seeded case-linked appeal) |
| 14 | Appeals segment: count persists across filters | [x] pass [ ] fail [ ] blocked | agent, in-browser |
| 15 | Drawer number-blind + Conversation/Decision toggle | [x] pass [ ] fail [ ] blocked | agent, in-browser |
| 16 | Reclaim (standard) - phone axis only | [x] pass [ ] fail [ ] blocked | agent, in-browser |
| 17 | Reinstate - both axes | [x] pass [ ] fail [ ] blocked | earlier E2E, 2026-07-13 (app showed "Approved") |
| 18 | Deny - block stays | [x] pass [ ] fail [ ] blocked | agent, in-browser |
| 19 | Every action writes an adminActions row | [x] pass [ ] fail [ ] blocked | agent, Firestore read |
| 20 | Permanent-tier gate: Reclaim locked until proof | [x] pass [ ] fail [ ] blocked | agent, in-browser |
| 21 | Record Verified -> Reclaim enables | [x] pass [ ] fail [ ] blocked | agent, in-browser |
| 22 | No plaintext number stored / logged (sealed) | [x] pass [ ] fail [ ] blocked | agent, Firestore + log scan |
| 23 | appeals is client-read-none / write-none | [x] pass [ ] fail [ ] blocked | agent, in-browser (403 PERMISSION_DENIED) |
| 24 | Regression: non-banned number logs in / signs up | [x] pass [ ] fail [ ] blocked | agent, logged in with +16195550100 + PIN |
| 25 | Dormant when OTP_PROVIDER=firebase | [x] pass [ ] fail [ ] blocked | agent, in-browser + backend |
| 26 | Appellant uploads proof (permanent tier) | [x] pass [ ] fail [ ] blocked | slice 5, in-browser (POST /doc 201, "Document received") |
| 27 | Moderator reviews doc via signed URL | [x] pass [ ] fail [ ] blocked | slice 5, in-browser (inline render, number-blind) |
| 28 | Verified -> record + HARD-DELETE the object | [x] pass [ ] fail [ ] blocked | slice 5, in-browser + GCS exists() check (gone) |
Run tally (2026-07-13): 22 pass, 0 fail, 0 blocked, 3 not-run (S2 invite, S8 30-min wait, S9 design-dependent). 2 findings (S4 fixed, S9 open).
Slice-5 addendum (2026-07-14): +3 pass (S26-S28, permanent-tier doc upload -> signed-URL review -> hard-delete), all live-verified in the browser. S9 RESOLVED by removing the "restriction" notice tier (commit 8c6e9b42). S2 (signup-block appeal) also verified 2026-07-14 via the operator's invite link. Running tally: 26 pass, 0 fail, 0 open findings; only S8 (30-min token-expiry dedup) not run.
#659 addendum (2026-07-14): +3 pass (S29-S31, docRequested gating that fixes the auto-showing perma-tier doc upload), live-verified web + admin. Running tally: 29 pass, 0 fail, 0 open findings; only S8 not run. Detail in the "#659 verification" section at the bottom.
Setup / preconditions โ
- Services running: web dev
:5173, admin dev:3001, and a local auth-api on:8084built from this branch (cd services/api/auth && node --env-file=../../../.env.local src/index.js). The deployed dev Cloud Run has OLD code with no appeal routes; web + admin proxy/api/auth->:8084, so the local one is REQUIRED. Restart it after every code change (this run caught a stale build: the case-context fallback needed a restart to take effect - see Notes). - Config:
OTP_PROVIDER=preludeandAPPEAL_SESSION_SECRETpresent in the worktree.env.local. - Admin: signed in at
:3001/admin/moderationas an Administrator. - Test data:
npm run seed:appeals(1 standard + 1 permanent pending),npm run otp:test:ban(bans+16195550100). - Guardrail: only
+16195550100, fixed test code562461. - Verify data in: Firestore
lantern-app-dev:appeals(+messages),banned_accounts,adminActions; and the auth-api console. - Clean state:
npm run seed:appeals:clean,npm run otp:test:unban, clearlocalStorage['lantern.appealSession'].
Scenarios โ
1. Phone-only appeal via login block (happy path) โ
- Goal: A logged-out person blocked at sign-in can file an appeal by proving they hold the number.
- Steps: login
+16195550100+ any PIN -> "Appeal this decision" -> Send code -> enter562461+ message -> Submit. - Expected: Block copy "...you can appeal below" (NO email); modal shows the "fingerprint and discard" note; after submit the thread shows "Under review".
- Verify (data): a new
appeals/{id}status:'pending',phoneHashpresent (no plaintext), onemessagesdocauthorRole:'appellant'.
Result: [x] pass [ ] fail [ ] blocked
Actual: Block copy correct (no email). Modal prefilled the number + showed the sealed note. Submit -> thread "Under review" with the message + reply box.
Evidence: appeal filed; phoneHash v1:e788d2... (no plaintext); admin queue picked it up.
Follow-up:2. Phone-only appeal via signup block โ
- Goal: The recycled-number newcomer hitting a signup block gets the same appeal path.
- Steps: open signup with an invite ->
+16195550100-> observe the block on step 1 AND the final step -> "Appeal this decision". - Expected: Both errors show "Appeal this decision" (no email); the appeal completes as scenario 1.
Result: [x] pass [ ] fail [ ] blocked
Actual: 2026-07-14, in-browser via the operator's invite link (+16195550100 re-banned with `npm run otp:test:ban`). Invite link -> signup form; entered the number + a birthdate -> STEP-1 block: "This phone number cannot be used to create a Lantern account. If you believe this is a mistake, you can appeal below." + "Appeal this decision" (NO email). The step-1 ban check halts signup BEFORE the OTP send, so you never reach the final step (stronger than the design's step-1-AND-final; the final-step guard is defense-in-depth for a bypass). "Appeal this decision" -> modal opened number-prefilled -> Send code -> 562461 -> message -> Submit -> thread "Under review" (completes as scenario 1; came back permanent-tier since otp:test:ban is a permanent ban, so the "Proof this number is newly yours" upload affordance also showed - re-confirms slice 5 from the signup entry). NOTE: first submit hit OTP_EXPIRED, retry hit RATE_LIMITED (per-number cap 3/24h, exhausted by slice-5 testing on this one number; the rate check runs before checkOtp so the expired attempt still consumed a hit). Reset `_rateLimits/appeal-phone:+16195550100` and re-submitted -> success. Not an S2 defect.
Evidence: in-browser screenshots (signup step-1 block + appeal thread "Under review").
Follow-up:3. Wrong / expired OTP โ
- Goal: Bad codes are rejected with clear copy; no appeal is created.
- Steps: at the verify step, enter
000000+ message -> Submit. - Expected: "That code was not correct. Check it and try again." Modal stays on the verify step.
Result: [x] pass [ ] fail [ ] blocked
Actual: Wrong code 000000 -> red "That code was not correct. Check it and try again." No appeal created; entering 562461 next succeeded (scenario 1).
Evidence: in-browser screenshot.
Follow-up:4. Clean (un-banned) number -> refused โ
- Goal: You cannot open an appeal for a number with no active restriction (anti-spam).
- Steps: appeal a clean number -> Send code -> valid OTP + message -> Submit.
- Expected: "No active restriction was found for this number." (
NO_RESTRICTION, 400).
Result: [x] pass [ ] fail [ ] blocked
Actual: Logged-OUT (curl, no Authorization) with a valid OTP -> {"error":"NO_RESTRICTION","message":"No active restriction was found for this number."} HTTP 400. Refusal works.
FINDING (FIXED same-day): the original guard `if (!bans.length && !linked)` checked linkedUserId PRESENCE, so a LOGGED-IN non-banned user could file a case-linked appeal for a clean number (verified: browser with the lingering session 1af5b6a2 created an appeal). FIX: submit now only honors linkedUserId when that account is ACTUALLY under enforcement (isLinkedUserBanned -> checkLoginBan: users.banned or Auth-disabled). RE-VERIFIED in the browser post-fix: the SAME logged-in + clean-number submit now shows "We could not find an active restriction on this number." and creates NO appeal. auth suite 293 pass.
Evidence: curl NO_RESTRICTION 400 (logged-out); browser "We could not find an active restriction on this number" (logged-in, post-fix).
Follow-up: DONE (fix in services/api/auth/src/routes/appeals.js submitHandler).5. Rate limit on the submit door โ
- Goal: The public submit door is bounded (3/number/24h).
- Steps: file/attempt 4+ appeals for the same number.
- Expected: After the cap, "Too many attempts..." (429
RATE_LIMITED).
Result: [x] pass [ ] fail [ ] blocked
Actual: After 3 submits (E2E + wrong-code + correct), the 4th submit returned red "Too many attempts. Please wait a bit and try again." No appeal created for the capped attempt.
Evidence: in-browser screenshot; per-number durable cap.
Follow-up:6. Come back to a pending appeal (same session) โ
- Goal: Reopening returns to the SAME thread, not a duplicate (localStorage restore).
- Steps: close the modal; reopen from the block.
- Expected: Opens straight to the existing "Under review" thread, NOT the phone step; no new appeal.
Result: [x] pass [ ] fail [ ] blocked
Actual: Closed + reopened -> jumped straight to the "Under review" thread with prior messages. No phone-entry step, no duplicate.
Evidence: in-browser screenshot.
Follow-up:7. Moderator reply reaches the app โ
- Goal: Bidirectional thread across app <-> admin.
- Steps: moderator replies in admin -> app Refresh.
- Expected: The reply appears labeled "Safety team".
Result: [x] pass [ ] fail [ ] blocked
Actual: Verified in the earlier E2E (2026-07-13): admin reply "Thanks for reaching out..." appeared on the app as "Safety team" after Refresh.
Evidence: messages doc authorRole:'moderator'.
Follow-up:8. Session-expiry behavior (known gap) โ
- Goal: Document behavior after the ~30-min appeal-session token expires.
- Expected (current): Falls back to phone entry; re-verifying files a NEW appeal (dedup not built).
Result: [ ] pass [ ] fail [ ] blocked
Actual: NOT RUN (30-min wait). Behavior is the documented known gap.
Evidence:
Follow-up: track: dedup an existing pending appeal on resubmit9. Case-linked appeal from a restriction notice (logged in) โ
- Goal: A signed-in user's appeal ties to their account (
linkedUserId).
Result: [x] resolved (obsolete)
Actual: RESOLVED 2026-07-14 by removing the "restriction" notice tier entirely
(commit 8c6e9b42). The open question here was whether a restriction
should set an enforcement flag so it could be appealed in-app; the
operator decided graduated restrictions won't be built, so enforcement
is warning (advisory) or ban (login + phone axis). The linkedUserId
capture path stays proven by S10-S12 (logged-in appeals carry it); a
banned-but-signed-in user still reaches the appeal via isLinkedUserBanned.
Evidence: commit 8c6e9b42 (drop the "restriction" notice tier).
Follow-up:10. "Your appeals" lists it in the Safety tab โ
- Goal: The historical appeals surface shows the user's own appeals.
- Expected: The card lists the appeal with a status pill + filed date.
Result: [x] pass [ ] fail [ ] blocked
Actual: Logged in as Onyx Ray (uid 1af5b6a2, PIN 562461). Me tabs read Profile | Data | Safety | Settings (the "Data" rename confirmed live). Safety > "Your appeals" listed this user's 2 case-linked appeals, both "Under review" / "Filed Jul 13, 2026".
Evidence: in-browser; GET /mine returned exactly this uid's 2 appeals.
Follow-up:11. Open thread + reply while pending; closed state โ
- Goal: From "Your appeals", read the thread and reply while pending.
- Expected: Pending -> reply box; resolved -> "This appeal is closed."
Result: [x] pass [ ] fail [ ] blocked
Actual: Opened a pending appeal from "Your appeals" -> thread showed the prior message; typed + sent a reply -> it posted (both messages shown as "YOU"), reply box cleared. Authenticated POST /mine/:id/message works. (Closed-state variant not exercised - no resolved case-linked appeal for this user.)
Evidence: in-browser screenshot.
Follow-up:12. Ownership: cannot read another user's appeal โ
- Goal: One user can never see another's appeal.
- Expected:
GET/POST /mine/:idfor someone else's id -> 404.
Result: [x] pass [ ] fail [ ] blocked
Actual: As Onyx Ray (token read from localStorage), GET /mine/rPrA2UJNVEnvmlAvNywh (a phone-only appeal, linkedUserId=null, NOT owned) -> HTTP 404 NOT_FOUND. GET /mine -> 200 with exactly this user's 2 appeals (fAbMABPT, nEW6D1). Ownership enforced + list scoped.
Evidence: in-browser fetch via the app proxy; ownedAppealOr404 re-checks linkedUserId == uid.
Follow-up:13. Admin drawer case-linked context (case fallback) โ
- Goal: A case-linked appeal with no phone-ban row shows a real reason (from the case).
- Steps: open the case-linked appeal drawer.
- Expected: Origin "Account (case-linked)"; reason drawn from the latest report.
- Verify (data):
ban.source:'case'with areason+caseRef.
Result: [x] pass [ ] fail [ ] blocked
Actual: Seeded a case-linked appeal (linkedUserId set, bannedAccountId=null). Drawer showed Origin "Account (case-linked)" + Reason "Sent repeated unwanted messages after being asked to stop" (from the seeded report), Severity "account". NOTE: required an auth-api restart to pick up the case-context code (see Notes).
Evidence: in-browser screenshot.
Follow-up:14. Appeals segment: count persists across filters โ
- Goal: The Appeals count does not vanish across segments / filters.
Result: [x] pass [ ] fail [ ] blocked
Actual: "Appeals N" shown on the Reports segment throughout; never vanished on a status-filter change.
Evidence: in-browser (e.g. "Reports 0 | Appeals 5").
Follow-up:15. Drawer number-blind + Conversation/Decision toggle โ
- Goal: Moderator adjudicates on context, never the number; drawer toggles thread vs actions.
Result: [x] pass [ ] fail [ ] blocked
Actual: Drawer showed ban context (reason/severity/date) + Origin, NO number. Conversation = thread + reply; Decision = reclaim/reinstate/deny (+ perma gate). Toggle worked both ways.
Evidence: in-browser screenshots across several appeals.
Follow-up:16. Reclaim (standard) - phone axis only โ
- Goal: Reclaim frees a recycled number without touching any account.
Result: [x] pass [ ] fail [ ] blocked
Actual: Standard "Harassment report" appeal -> Decision -> Reclaim -> status RECLAIMED; Reclaimed count 0->1, Pending decremented. adminActions appealReclaim bansCleared=1.
Evidence: in-browser + Firestore.
Follow-up:17. Reinstate - both axes โ
- Goal: Reinstate returns the same person (userId ban if case-linked, plus the phone ban).
Result: [x] pass [ ] fail [ ] blocked
Actual: Verified in the earlier E2E (2026-07-13): reinstated a phone-only appeal -> status Reinstated -> the app showed "Approved" + closed; the test number was unbanned. adminActions appealReinstate present.
Evidence: in-browser + adminActions.
Follow-up:18. Deny - block stays โ
- Goal: Deny records the outcome and leaves the block in place.
Result: [x] pass [ ] fail [ ] blocked
Actual: Case-linked appeal -> Decision -> Deny -> status DENIED (red badge); Denied count 0->1. No bans changed. adminActions appealDeny present.
Evidence: in-browser + Firestore.
Follow-up:19. Every action writes an adminActions row โ
- Goal: The audit trail records every adjudication.
Result: [x] pass [ ] fail [ ] blocked
Actual: adminActions read: appealReclaim x3, appealReinstate x1, appealDeny x1, appealDocReview x1 (result=verified) - each with appealId + performedBy (the admin uid).
Evidence: scripts/_inspect-appeals.mjs output (temp script, removed).
Follow-up:20. Permanent-tier gate: Reclaim locked until proof โ
- Goal: A permanent-block reclaim is gated on a recorded document result.
Result: [x] pass [ ] fail [ ] blocked
Actual: Permanent "Recycled-number block" appeal -> Decision -> "PERMANENT BLOCK - RECLAIM NEEDS PROOF" panel; Reclaim disabled/dimmed; Reinstate + Deny available.
Evidence: in-browser screenshot.
Follow-up:21. Record Verified -> Reclaim enables โ
- Goal: Recording a verified result unlocks Reclaim.
Result: [x] pass [ ] fail [ ] blocked
Actual: Clicked "Verified" -> "recorded: verified" + Reclaim enabled -> Reclaim -> status RECLAIMED. adminActions appealDocReview result=verified then appealReclaim. As of slice 5 (S26-S28) there is now a real document behind this gate.
Evidence: in-browser + Firestore (docProof=verified on the appeal).
Follow-up:22. No plaintext number stored / logged (sealed) โ
- Goal: The number is never persisted or logged - only its hash.
Result: [x] pass [ ] fail [ ] blocked
Actual: Scanned every appeals doc + messages + appeal adminActions: only phoneHash=v1:<hex>. The one regex hit was a UUID (linkedUserId 1af5b6a2-...), not a phone. auth-api log: 0 occurrences of the plaintext number (log references appeals 31x, so it is live).
Evidence: _inspect-appeals.mjs scan + grep of the auth-api log.
Follow-up:23. appeals is client-read-none / write-none โ
- Goal: Clients cannot read/enumerate appeals directly.
Result: [x] pass [ ] fail [ ] blocked
Actual: A direct client Firestore REST read of `appeals` from the web app returned HTTP 403 PERMISSION_DENIED "Missing or insufficient permissions" (readable:false). Backed by the appeals block in firestore.rules.test.js (allow read, write: if false).
Evidence: in-browser fetch to firestore.googleapis.com.
Follow-up:24. Regression: non-banned number logs in / signs up โ
- Goal: The appeal wiring did not touch the happy path.
Result: [x] pass [ ] fail [ ] blocked
Actual: Signed in with the (un-banned) +16195550100 + PIN 562461 -> biometric-enrol prompt -> lock screen ("Welcome back, Onyx Ray") -> unlock -> full dashboard (Places Nearby). Normal login works; no appeal UI, no block copy.
Evidence: in-browser; reached the logged-in dashboard + Me page.
Follow-up:25. Dormant when OTP_PROVIDER=firebase โ
- Goal: With the external OTP path off, appeals are cleanly dormant.
Result: [x] pass [ ] fail [ ] blocked
Actual: Flipped OTP_PROVIDER=firebase + restarted: curl send-otp -> 503 OTP_PROVIDER_DISABLED. In the browser, "Send code" showed "Appeals are not available right now. Please try again later." (no crash). Restored OTP_PROVIDER=prelude + restarted after.
Evidence: curl 503 + in-browser screenshot.
Follow-up:Slice 5 - permanent-tier ephemeral document upload (added 2026-07-14) โ
Built + live-verified end-to-end in the browser (dev, OTP_PROVIDER=prelude). Setup: manual-banned +16195550100 permanent (phone axis) -> logged-out login-block appeal -> permanent-tier thread. Feature commit bddad951.
26. Appellant uploads proof (permanent tier only) โ
- Goal: A permanent-tier pending appeal shows the upload affordance; a file uploads to ephemeral storage.
Result: [x] pass [ ] fail [ ] blocked
Actual: Permanent-tier appeal thread showed the "Proof this number is newly yours" card + "Upload document" (this card renders ONLY for tier=permanent + pending). Uploaded a PNG -> POST /api/auth/phone/appeal/doc 201 -> thread refreshed to "Document received - the safety team will review it." + button flipped to "Replace document". Sent as a raw body (Content-Type: image/png), not multipart.
Evidence: in-browser screenshots + network 201; app upload UI accept="image/jpeg,image/png,image/webp,application/pdf".
Follow-up:27. Moderator reviews the document via signed URL โ
- Goal: The pending doc is reachable to the moderator only through a short-lived signed URL, rendered inline, never listed/public.
Result: [x] pass [ ] fail [ ] blocked
Actual: Admin Appeals -> the permanent appeal showed docPending -> Decision tab -> "Review document" -> GET /auth/moderation/appeals/:id/doc returned a V4 signed URL and the image rendered inline with "open in new tab" + "Link expires shortly; deleted when you record a result." Appeal was number-blind (Origin "Number only", no plaintext number).
Evidence: in-browser screenshot (inline render of the uploaded proof).
Follow-up:28. Verified -> record + HARD-DELETE the object in the same call โ
- Goal: Recording a result destroys the document; only the yes/no persists.
Result: [x] pass [ ] fail [ ] blocked
Actual: Clicked "Verified" -> panel showed "No document pending (the reviewed file was deleted)." + "recorded: verified". Confirmed the GCS object appeal-docs/<id>/proof is GONE via an Admin-SDK exists() check (exists:false, 0 objects under the appeal prefix). Reclaim gate then opened -> Reclaim -> RECLAIMED (which also lifted the phone ban). No console errors on either app or admin.
Evidence: in-browser + node @google-cloud/storage exists() check against lantern-app-dev.firebasestorage.app.
Follow-up:Deferred / NOT yet built (out of scope for this run) โ
- Inline "appealed" indicator on the Case detail. The appeal is visible in the Appeals segment; surfacing it on the parent case is a convenience, not built.
- Session-expiry dedup (scenario 8). Re-verifying after the ~30-min token expiry files a new appeal instead of returning the existing pending one.
Prod prerequisites (when promoting - not now) โ
APPEAL_SESSION_SECRETcreated in the prod project's Secret Manager (its own value) + added to the auth-api deploy--update-secretsline (alongsideOTP_PROOF_SECRET/PHONE_CONTEXT_SECRET). The runtime SA already has project-widesecretAccessor.OTP_PROVIDER=preludein prod (appeals depend on the external-OTP possession path).- Slice 5: nothing new to configure (verified against deployed dev 2026-07-14). The bucket resolves with no env:
APPEAL_DOC_BUCKEToverride ->GOOGLE_CLOUD_PROJECT-> ADC/metadata via google-auth-librarygetProjectId()(hardened 2026-07-14 precisely becauseGOOGLE_CLOUD_PROJECTis NOT in the deployed auth-api env and Cloud Run doesn't guarantee it). V4 signed URLs already work: the dev runtime SA531553779372-compute@already holdsroles/iam.serviceAccountTokenCreatoron itself (checked live). Only optional item: a Storage lifecycle rule expiringappeal-docs/after ~7 days (TTL backstop; hard-delete on doc-review is primary). Everything else (APPEAL_SESSION_SECRET,OTP_PROVIDER=prelude) is the appeal flow's own go-live gate above, not slice-5-specific.
Notes / issues found during the run โ
- Fixed test OTP. The Prelude test number
+16195550100accepts the fixed code562461. This unblocked full agent-driven browser testing (no human needed per submit). - Stale local auth-api caught a real gotcha. The case-linked context (
appealContextFor/caseContextFor, commita1b95d8f) did NOT show until the:8084auth-api was restarted - the running process predated that commit. Takeaway for the setup: always restart the local auth-api after a code change; a "wrong" result may just be stale code. - Residual session -> case-linked phone appeal (not a bug). One phone-appeal (
nEW6D1...) came out withlinkedUserIdset (Origin "Account") because the web app still had an in-memory Firebase session (the sealed account1af5b6a2-...for the test number). This is correct behavior: a logged-in appellant's own uid is captured. Worth knowing when reading the queue. - S4 unblocked by resetting the durable rate-limit counters (
_rateLimits/appeal-phone:+16195550100,appeal-ip:*,send-otp:*) + unbanning, then submitting logged-out. A second Prelude test number would avoid the reset. - FINDING (S9) - RESOLVED 2026-07-14. Surfaced by the S4 fix: a "restriction" notice showed an in-app appeal button but set NO enforcement flag, so a restricted-but-not-banned user appealing in-app hit
NO_RESTRICTION. Rather than turn restrictions into an enforcement record, the operator decided graduated restrictions won't be built, so the "restriction" notice tier was removed entirely (commit8c6e9b42): enforcement is now warning (advisory) or ban (login + phone axis). A banned-but-signed-in user still reaches the appeal viaisLinkedUserBanned. No open decision remains. - Logged-in testing unlocked by the account PIN (562461). The test account is "Onyx Ray" (uid 1af5b6a2). Signed in with +16195550100 + PIN 562461; that made scenarios 9-12 + 24 runnable. The Firebase session lives in
localStorage(firebase:authUser:...), not IndexedDB. - FINDING (S4) - FIXED same-day. The original submit guard
if (!bans.length && !linked)checked linkedUserId PRESENCE, so a signed-in non-banned user could file a case-linked appeal for a clean number (this also explains why the first browser attempt created an appeal - the app had a lingering session1af5b6a2). FIX:submitHandlernow honors linkedUserId only when that account is ACTUALLY under enforcement (isLinkedUserBanned->checkLoginBan: users.banned or Auth-disabled). Re-verified in the browser: the same logged-in + clean-number submit now returns "We could not find an active restriction on this number." with no appeal created. Deliberate design: a logged-in user with a PHONE ban (but no account ban) still appeals via the phone axis as "Number only" - linkedUserId is only attached for a real account ban. - Cleanup done: temp seeders removed; case-linked fake data cleaned;
OTP_PROVIDERrestored topreludeand the auth-api restarted. Seeded standard/permanent appeals + the app-created test appeals were left as the run record (npm run seed:appeals:clean+npm run otp:test:unbanto fully reset). - Slice-5 run cleanup (2026-07-14): the permanent phone ban placed on
+16195550100for S26-S28 was lifted by the S28 Reclaim itself (reclaim overturns the number's phone bans), so the test number is clean again. The web app's Onyx Ray session was backed up before the logged-out flow and restored after (no re-login needed). The S26 appealhB1FWQwbC6Wyaz8IodDiwas left RECLAIMED as the run record; its uploaded proof object was hard-deleted (S28).
#659 verification (2026-07-14): docRequested gating (the auto-show bug) โ
The bug (operator-reported): the permanent-tier "Proof this number is newly yours" upload auto-showed on EVERY permanent appeal (gated on tier alone, AppealFlow.jsx), so a same-person reinstatement appellant was wrongly asked to prove the number is newly theirs (a reclaim demand). The fix (#659): a moderator-set docRequested flag. The app upload card and the public /doc endpoint key off docRequested; a moderator solicits proof via a "Request proof of new number" button only when treating the appeal as a permanent-tier reclaim; a reinstatement never requests it. Live-verified end to end (web :5173 + admin :3001 -> local :8084 auth-api on branch code, OTP_PROVIDER=prelude).
| # | Scenario | Result | Notes |
|---|---|---|---|
| 29 | Admin: permanent appeal Decision shows a Request-proof button, NOT auto doc-review | [x] pass [ ] fail [ ] blocked | seeded 8v1iyz... + app-created Y1lJ...; "Request proof of new number" + skip-for-reinstatement copy |
| 30 | Admin: Request-proof sets docRequested (doc-review UI appears) + drops a thread note | [x] pass [ ] fail [ ] blocked | Conversation 1 -> 2; "The safety team asked for proof..." note; adminActions{appealRequestDoc} |
| 31 | App: permanent appeal shows NO upload card by default; it appears only after proof is requested | [x] pass [ ] fail [ ] blocked | full E2E below |
29-31 detail โ
Result: [x] pass [ ] fail [ ] blocked
Actual: S29/S30 (admin): opened a permanent-tier pending appeal (Decision tab) -> the panel
showed "PERMANENT BLOCK - RECLAIM NEEDS PROOF" with a "Request proof of new number"
button and copy telling the moderator to skip it for a reinstatement (NOT the old auto
doc-review UI). Clicking it flipped to the doc-review UI ("Waiting for the appellant to
upload proof" + Not verified / Verified), the Conversation count went 1 -> 2, and the
thread gained a SAFETY TEAM note ("The safety team asked for proof this number is newly
yours ... reviewed once and never stored"). Drawer stayed number-blind (Origin "Number only").
S31 (app E2E): banned +16195550100 permanent (otp:test:ban) -> web login block -> "Appeal
this decision" -> OTP 562461 -> submit -> permanent-tier thread with the reply box and
NO "Proof this number is newly yours" card (the bug is gone). Moderator then clicked
Request-proof in admin -> app Refresh -> the "Proof this number is newly yours" card +
"Upload document" button appeared, with the SAFETY TEAM note in the thread. Bidirectional.
Evidence: in-browser screenshots (app + admin, both tabs); auth suite 314 pass; new setDocRequested
+ POST /auth/moderation/appeals/:id/request-doc; openapi.json updated.
Follow-up:Cleanup (2026-07-14): npm run seed:appeals:clean (removed the 2 seeded appeals + ban rows), npm run otp:test:unban (+16195550100 clean), appeal-session cleared from the web app localStorage. The app-created appeal Y1lJCFNPqvrUtGcP2PyS was left as the run record (number-blind). Local :8084 auth-api left running on branch code.
#660 aging floor: recycled-number reclaim gate (2026-07-14) โ
Hard gate (operator-approved): a recycled-number RECLAIM within the ~45-day aging floor is auto-denied (reclamation spec section 3), because a carrier cannot reassign a line that fast, so a fresh ban plus a new-owner claim is the banned person. Reinstate + deny stay open. Backend: the reclaim endpoint returns 409 AGING_FLOOR when the matched ban is younger than 45 days, via the withinReclaimAgingFloor helper (unit-tested). The admin drawer labels the ban age and disables Reclaim within the floor.
| # | Scenario | Result | Notes |
|---|---|---|---|
| 32 | Fresh permanent appeal (banned today): aging-floor note + "(today)" age + Reclaim disabled; Reinstate/Deny enabled | [x] pass [ ] fail [ ] blocked | live in admin: "WITHIN THE AGING FLOOR" note, "Blocked ... 2:27 PM (today)", Reclaim dimmed/disabled, Reinstate + Deny bright/enabled |
Backend 409 verified transitively (the live note renders from banContextFor().withinAgingFloor === true, the exact value the reclaim handler gates on) plus the withinReclaimAgingFloor unit tests (3d/44d within; 45d/90d past; null + unparseable false). Auth suite 318 pass.