Skip to content

Device test suite — consumer auth, onboarding and the app lock ​

⛔ SUPERSEDED 2026-09-07 — THE SUITE IS NOW /qa/test-suite

This page is kept as the record of the 2026-09-03 device round and its root causes, which is still the reason several current cases exist. It is no longer the suite.

⚠⚠ AND IT NEVER FULLY WAS, WHICH IS THE POINT. Its own banner below claims "This page is the suite. Add cases here, never in a message." — while only cases 53-66 were ever transcribed. Cases 1-52 were left in the chat transcript and are gone. A page that declares itself the source of truth while holding 14 of 66 cases is worse than an empty one, because it reads as complete. The replacement is generated from tools/qa/cases-*.mjs and gated by npm run check:qa-suite, so the same drift cannot recur silently.

THE ORIGINAL BANNER, KEPT FOR THE RECORD

52 device cases were delivered in conversation on 2026-09-03 and project-state.md records them as "52 cases delivered in chat". A suite that lives in a chat transcript cannot be updated, diffed, or re-run after a context reset — the owner's own findings had to be transcribed from a screenshot to be analysed. This page is the suite. Add cases here, never in a message.

Build under test: arm64 release APK, 2026-09-03 12:09, Dev-targeting (dyhjofjjuazhyqcvlrkx). Reported by: the product owner, physical Android device. Numbering: cases 1-52 keep the numbers from the delivered list. New cases start at 53.

✅ ALL FOUR FINDINGS ARE FIXED IN CODE AS OF 2026-09-03 — AND NOT YET PROVEN ON A DEVICE

QRS-1001 (the launch gate) · QRS-1002 (the gate that could not see it) · QRS-1003 (the impure updater) · QRS-1004 (the invisible confirm step), plus QRS-976 which turned out to have been fixed already and recorded nowhere.

⚠ "Fixed in code" is not "verified". expo-secure-store and expo-local-authentication are native modules, so no automated layer in this repo can reach any of this — the web export does not even have a PIN gate (isPinSupported() is false with no OS keystore). Cases 53-66 are the gate, on hardware, on both platforms. Nothing below should be read as passing until they run.

1 · Findings from the 2026-09-03 device run ​

Every row was root-caused by reading the implementation, not by inference from the symptom. Where a cause could not be established, the row says so rather than guessing.

Case 26 — PIN survives kill → 🔴 FAIL · genuine defect · P0 ​

Observed"NO it dosent asks for PIN" — relaunching the app after a kill goes straight to the home surface.
ExpectedRelaunch → Enter your PIN; 5 wrong → 15-minute lock; Forgot PIN? → OTP → create a new PIN.
VerdictGenuine defect. Not expected by design at any level: the design draws it, the domain implements it, the copy exists in all three languages, and the parity contract enumerates it.
Root causeThe launch-time gate is never mounted. PinGateScreen has exactly one product call site — apps/mobile/src/app/(user)/phone-sign-in.tsx:139, with entry="signin". entry="open" appears nowhere in the app; its only occurrences are eight call sites inside PinGateScreen.test.tsx. resolveEntryRoute returns four destinations and none of them is a gate, so app/index.tsx redirects a rehydrated session straight to /dashboard or /consumer.
LayerNavigation / app shell. Not secure storage, not session handling, not backend. The secret, the ladder, the lockout and the biometric prompt are all present and correct — nothing calls them.
Blast radiusopeningStage(entry:'open'), the whole enter stage, the 5-attempt ladder, the 15-minute lockout, Forgot PIN?, and biometric unlock are all unreachable in the shipped app. expo-local-authentication (QRS-949, an approved size callout) currently runs only on the enrolment screen and never on an unlock.
Security impactA lost or borrowed unlocked phone gives a stranger the full account — including, once plan 1.7 lands, the marriage biodata. The PIN is the only device-level control the product has, and it is inert.
MitigationMount the gate in a host. See §3.
✅ FixedAppLockGate mounts PinGateScreen entry="open" over the app when a signed-in launch finds a device PIN, hosted by app/(user)/_layout.tsx and the new app/consumer/_layout.tsx. Locks on cold start and on foreground past a 30-second grace window. Sign-out clears the device PIN. Awaiting a device pass.
Regression coverageToday: 20 green unit tests that instantiate the component directly. That is what let this ship — none asserts a route renders it. New coverage owed: a route-level test that a rehydrated session with a device PIN renders pin-gate-enter before any home screen, plus device cases 53-60.

🔎 THE GENERALISABLE DEFECT — a component-level pass was read as a product-level claim

pin-gate.json marks entry_open_with_pin_enters as pass, evidence pin-gate-enter. The verdict is true of the component and false of the product. The contract's own notAssessed section says exactly what a pass means: "a pass here means the evidence string exists in this screen's source." So the contract cannot distinguish implemented from reachable, and 31/33 green rows sat on top of a feature no user could ever see.

This is CLAUDE.md's own rule landing on its own artifact: completion of parts never bounds the whole. Tracked as QRS-1002 with a proposed reachability rule.

Cases 14 and 15 — wrong code / 3 wrong codes → ⚪ BLOCKED, not failed ​

Observed"As test case #26 didn't produced the screen for on relaunch to enter the pin/faceid hence this test could not be tested."
Expected14: Incorrect code… N attempts left, never "expired". 15: locked 5 minutes, minutes counting down.
VerdictNot a defect in the code under test. Marked Fail on the sheet; the remark says untested. Blocked by case 26, and the dependency is real rather than incidental.
Root causeThe in-app route to a code-entry state without burning a fresh WhatsApp OTP is Forgot PIN? → Send code, which is reachable only from the gate's enter stage — the stage case 26 shows is never mounted. With the gate absent, re-reaching code entry means signing out and requesting a real OTP, which competes with the rate limit the owner had already hit.
State of the codeThe OTP ladder itself is implemented in PhoneAuthScreen: CodeState carries wrong / expired / locked / network, MAX_WRONG_ATTEMPTS drives attemptsLeft, a lifted lock resets the counter, and the "never say expired inside the code's lifetime" rule is explicit at index.tsx:331-343. None of it is proven on a device.
MitigationRe-run after case 26 is fixed, through Forgot PIN? — no OTP budget spent on reaching the screen, and it exercises the same verify path.
✅ UnblockedThe enter stage is now reachable, so Forgot PIN? is too. These are testable without signing out.
Regression coverageUnit coverage exists for the ladder. Device coverage is owed and is now cases 57-58.

Case 22 — PIN offer / confirm step → 🟠 PARTIAL · one real UX defect, one design question ​

Observed"confirm pin screen didn't appeared and directly took the pin confirmation, this should be user to confirm set pin click along with confirm screen" · "biometric set up is working as expected and no change is required" · "it never appeared on relaunch the app".
ExpectedThe offer appears after sign-in even when a PIN already exists (this part passed).
VerdictThree separate things, and they must not be merged: (a) the confirm step exists and is correct; (b) it is visually indistinguishable from the step before it — a real UX defect; (c) an explicit confirm button is a design change, not a bug fix; (d) the relaunch clause is case 26.
What the code doesusePinGate.settleSet sets pending.current, clears the draft and moves to confirm; settleConfirm compares and only then writes the PIN. The copy differs — Create a PIN / Only you can open this on this phone. versus Confirm your PIN / Enter it once more. — in all three languages. So the second entry is required.
Root cause (b)set and confirm render the same component with the same layout: same shell, same dot row, same keypad, same back chevron, no transition animation, and the fourth digit auto-advances instantly. The only signal that the stage changed is two lines of text above the thumb. On a device this reads as "I typed my PIN and it accepted it", which is exactly how it was reported.
On (c)four_digits_auto_advance is a pass against the design — the design deliberately has no submit button ("a PIN pad with a Continue button is a PIN pad nobody finishes with one thumb"). Adding one diverges from an approved design and needs either a Claude Design round or a drift-ledger row. Not a change to make silently.
⚠ Not reproducedA stricter reading — that the confirm stage was skipped entirely — could not be reproduced. All 20 PinGateScreen tests pass, including the weak-PIN and mismatch cases. One latent impurity is recorded as QRS-1003: usePinGate.tapDigit calls settle() inside a setDraft updater (usePinGate.ts:275-280), and React requires updaters to be pure. Whether React evaluates that updater eagerly or during render changes the order in which setError lands, and that can differ between jest and a concurrent release build. Stated as a risk, not as the cause — it is the only mechanism found by which the device could behave differently from the tests.
Mitigation(b) is fixable inside the screen with no design change: distinct stage entrance, a step indicator, or a medallion swap. (c) needs an owner decision.
✅ Fixed, and NO design change was neededRe-fetching PinGate.dc.html settled it: the design carries setTimeout(() => this.onPinComplete(draft), 160) — a 160ms dwell we had dropped, so the fourth dot never painted and the screen swapped under the thumb on the same frame as the tap. The dwell IS the affordance, because set and confirm share a shell and the design has no submit button by design. Settling from an effect instead of inside the setDraft updater also closes (d) — QRS-1003 — and makes the timer cancellable, so backspacing inside the dwell takes the entry back.
🔎 The lessonBefore proposing a design change, re-fetch the design. The requested remedy (an explicit confirm button) would have diverged from an approved design that already held the right answer.
Regression coverageCovered as behaviour; not covered as perceptibility, which no unit test can assert. New device case 61.

Cases 18/19 — airplane mode → 🟢 PASS on the specified behaviour · the second half is EXPECTED ​

Observed"It doesn't send message but it not restoring the entered number after if i kill the app"
Expected (as written)Offline state, send disabled; restoring keeps the typed number.
VerdictPass, plus an ambiguity in the case wording. "Restoring" meant restoring connectivity, not relaunching after a kill.
What the code doesOffline is detected and shown (phone-auth-offline), and the send button is disabled while offline — disabled={!numberValid || busy || isOffline} with the comment "a tap that cannot possibly send must not look like one that might." The typed number is component state, so it survives a connectivity change (the screen never unmounts) and does not survive process death.
Is losing it after a kill a defect?No — expected by design, and deliberately so. A phone number typed but not submitted is unverified input. Persisting it would mean writing a personal identifier to device storage before the person has committed to anything, which is the wrong default under DPDP and buys very little: the number is 10 digits and the keyboard opens on the field.
MitigationNone to the code. Split the case, so the two behaviours are tested separately and neither can hide the other.
Regression coverageCase 18 rewritten (connectivity), new case 62 added (process death, expected loss).

Case 27 — reinstall → ⚪ BLOCKED ​

✅ Unblocked — the gate now runs, so this is observable. Originally untestable: "no PIN after reinstall" can only be observed on a screen that asks for one. The behaviour is correct by construction — pinVault writes with WHEN_UNLOCKED_THIS_DEVICE_ONLY, so the digest cannot ride a cloud backup onto a new device — but construction is not evidence. Re-run after case 26.

Cases 12, 20, 21 — no result recorded ​

Blank on the sheet. Carried forward unchanged; no analysis is possible from a blank cell and none is offered.

2 · Verdict summary ​

#CaseSheetTrue verdictWhy
12Business framing—not runno result recorded
14Wrong codeFailBlockedroute to code entry is behind case 26
153 wrong codesFailBlockedsame
18Wait >10 min, old code—not run
19Airplane mode—Pass (+ wording split)offline state and disabled send both correct
20Existing number, other fork—not run
21New consumer—not run
22PIN: offerremarkPartialoffer correct; confirm step imperceptible; button is a design ask
26PIN survives killFail🔴 Fail — P0the gate is not mounted
27Reinstall—Blockedneeds a gate to observe

One defect, one UX defect, one design question, two blocked-by-the-defect, one pass. The single fix at §3 unblocks 14, 15 and 27.

✅ Status after the 2026-09-03 fixes ​

#Now
14, 15, 27Unblockedthe enter stage and Forgot PIN? are reachable
19Pass, case rewordedsplit into 18 (connectivity) and 62 (process death)
22Fixedthe 160ms dwell restored from the design; no design change needed
26Fixed in code, unverified on hardwareAppLockGate — cases 53-66 are the proof

⚠ The design question is CLOSED and the answer was "no change". The explicit confirm button is not needed: the design's own dwell is the affordance, and adding a button would have diverged from an approved design for a problem it had already solved.

Does the architecture support it naturally? ​

Yes, and it did — this shipped with no redesign. Every piece existed and was tested; the missing part was a host.

NeededStatus
openingStage({ entry:'open', deviceHasPin }) → enter✅ packages/domain/src/auth/pin.ts
Persisted attempt ladder that survives a force-quit✅ pinVault.readAttemptState / writeAttemptState, in SecureStore
Biometric auto-prompt on enter✅ usePinGate fires tryBiometric when stage === 'enter'
Forgot → OTP → set a new PIN✅ usePinGate.submitCode
The phone number needed by Forgot, after a cold start✅ sessionStore persists phone
Web divergence handled✅ isPinSupported() is false on web; the gate skips rather than faking
A host that renders it at app open✅ AppLockGate (was the entire gap)

The industry-standard shape, and the one part that is easy to get wrong ​

A production app lock re-challenges on cold start and on return from background after a grace period — not cold start alone, or backgrounding the app becomes the bypass.

⚠ THE GRACE PERIOD IS LOAD-BEARING HERE, NOT A COMFORT SETTING

The Forgot PIN? path sends a code over WhatsApp. The person must leave the app to read it. A zero-second foreground lock would re-lock the screen they left, on the way to the code that unlocks it — an unresolvable loop, and it would be introduced by the fix rather than found by it. A short grace window (order of 30 seconds, owner's call) is what makes the OTP path survivable, and it is also the standard behaviour of every banking app for the same reason.

Two further constraints already written into the repo:

  • app/consumer/index.tsx carries "NO SESSION GATE, and none may be added." Anonymous-first browse is a platform rule (CLAUDE.md, three user categories). The lock wraps identity surfaces only — account, chats, notifications, order code, and the merchant tree — never public browse.
  • The gate must never become a wall. never_blocks_entry is a contract row: someone who declined a PIN has nothing to enter and must pass straight through.

4 · Files that would change — scoped ​

✅ Delivered. Both out-of-feature changes were approved by the owner before being written.

In scope (the auth feature and its route hosts):

FileChange
apps/mobile/src/tiers/user/features/auth/components/AppLockGate.tsxnew — reads hasPin(), renders PinGateScreen entry="open" until satisfied, holds the unlocked flag, listens to AppState
apps/mobile/src/app/(user)/_layout.tsxwrap the guarded subtree in AppLockGate
apps/mobile/src/app/consumer/_layout.tsxnew — the consumer identity layout plan item 11 called for and which was never created; wraps identity routes only, leaves index ungated
apps/mobile/src/tiers/user/features/auth/index.tsexport the new component
documentation/portal/design-system/parity-contracts/pin-gate.jsonre-verdict the two entry_open_* rows against reachability

⚠ TWO CHANGES FALL OUTSIDE THE AUTH FEATURE — approval owed before either is written

1. packages/domain/src/auth/pin.ts — add shouldLockOnForeground(lastActiveAt, now, graceMs) · Why: the grace-period decision is a pure time comparison, and every other rule on this screen already lives in domain so it can be proven with node --test and no device. Putting it in the component would be the only untested rule in the gate. · Impact: one added pure export, additive, no existing signature changes. · Risk: low. packages/domain is a leaf; nothing else consumes the auth namespace.

2. apps/mobile/src/stores/ — where the "unlocked this session" flag lives · Why: it must be readable by two layouts ((user) and consumer) and must not persist — if it survived a kill the lock would never fire again, which is the current bug wearing a different hat. A store is the existing idiom; the alternative is React context in app/_layout.tsx, which is also outside the feature. · Impact: either a small new appLockStore, or three non-persisted fields on sessionStore. · Risk: medium, and it is worth naming. sessionStore is the file I truncated to zero bytes on 2026-09-03, and it is on the path of every launch. A separate store is the safer choice — it cannot regress session rehydration, which is the one thing on this path that must never break.

Neither is written until the owner approves the placement.

Explicitly NOT in scope, and not to be touched while fixing this: resolveEntryRoute (the gate is orthogonal to where a launch lands), the sign-in spine, pinVault, the OTP ladder, the (auth)/(app) route-group split (QRS-999 — correct, and not to be bundled into an auth fix the owner is about to test).

5 · Test cases ​

Changed ​

#CaseChange
18Airplane modeNarrowed to connectivity: offline caption shown, send disabled, restoring the network keeps the typed number and re-enables send. Process death is no longer part of this case.
22PIN: offerSplit. 22 keeps "the offer appears after sign-in even if a PIN already exists" (passing). The confirm step becomes case 61.
26PIN survives killSplit into 53-56 so a failure names which half broke.

New ​

#CaseExpected result
53Cold start with a PINKill the app, relaunch. After the splash: Enter your PIN, keypad, Forgot PIN?. No home surface renders first, not even for a frame.
54Cold start with no PINSomeone who tapped Not now: relaunch goes straight to home. No gate, no empty frame, no flash of a PIN screen.
55Background and return, inside the grace windowSwitch to another app, return within the grace period. No PIN prompt. This is the WhatsApp-OTP case.
56Background and return, past the grace windowLeave the app past the grace period, return. Enter your PIN.
57Wrong PIN ladder1st-4th wrong: N attempts left, counting down, dots clear. 5th: locked, "Too many attempts. Wait 15 minutes…", keypad disabled but still on screen.
58Lockout survives a killGet locked, force-quit, relaunch. Still locked, with the remaining time honoured — not reset by the restart.
59Forgot PIN → OTP → new PINFrom the locked or enter state: Forgot PIN? → the number is shown → Send code → the code arrives on WhatsApp → entering it lands on Create a PIN, not on home. The person leaves with a PIN.
60Biometric unlock on relaunchWith Face ID / fingerprint enabled: relaunch prompts the OS sheet automatically. Success → home. Cancel → falls back to the keypad and does not consume a PIN attempt.
61Set → confirm is perceptibleTyping the first PIN visibly moves to a second, clearly different step before the PIN is stored. Typing a different second PIN reports the mismatch and returns to the first step.
62Typed number after a killType a number without sending, force-quit, relaunch. The field is empty — this is expected. Fails only if the number reappears.
63Consumer browse is never gatedWith a PIN set, open a vendor card / browse from a cold start. Browse works with no PIN prompt. Opening Account or Order code does prompt.
64Sign out clears the lockSign out, sign in as a different person on the same device. The new session is not challenged for the previous person's PIN.
65ReinstallUninstall, reinstall, sign in. No PIN is asked for on the device; the offer appears at the end of sign-in as if new.
66Second deviceSign in on a second phone. No PIN there until one is set on that phone. A PIN is per device, never account state.

⚠ WHAT NO AUTOMATED LAYER CAN COVER HERE

expo-secure-store and expo-local-authentication are both native modules, and the Playwright suite runs the web export where isPinSupported() is false — so the gate does not exist there at all. The pin-gate contract says the same thing in its own notAssessed: this is "the highest-priority screen in this journey for a device pass." Cases 53-66 are the gate. There is no substitute for running them on hardware, on both Android and iOS.