refactor(biometrics)!: replace the sentinel gate with the gate controller - #1344
Merged
Conversation
…ller ADR 0007 ratified the gate as a privacy shutter, and this commit is the rewrite it prescribes. The new app/gateController.ts asks the DeviceAuth native module directly: one ceremony at a time shared by every concurrent trigger, a fifteen-second freshness window in place of the purpose axis and the holds, and a uniform fail-open answer with a notice for every way the gate cannot run. Its GateAnswer union carries three endings only: passed, declined, and failedOpen. The deletion is the point. simpleBiometrics.ts goes whole: the keychain sentinel and its v1 migration, the GatePurpose axis, the unanswered verdict and both re-ask holds, the per-platform stall policy record with its veto and grace, the GatePhase rebuild table, and the error-message scrape that classified declines from prose. The stall handling that remains is two plain windows: ten seconds for the availability probe, and a wide user-paced window for the ceremony itself, each settling failedOpen. Callers shrink to the union. The screen hook loses its unanswered loop and teardown grace. LoadedApp and LoadingApp ask the controller and notice on failedOpen. The locked screen's retry now runs its own ceremony unconditionally, so the security toggles enable triggers and never bypass a retry, and a pass re-enters the boot path inside the freshness window without a second prompt. Settings probes device security through the same controller. The keychainOptions INTERACTIVE_AUTH profile is now unused and marked for its own removal. The old gate suite retires with the machinery it pinned. The new controller suite covers the three endings, the freshness window and its expiry, the shared ceremony, both stall windows, the Android blanking events, and the catalog parity of the four failure keys; the hook suite covers the three-way answer and cancellation on unmount. All 505 tests pass, with tsc, eslint, and prettier clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Visual review — ✅ matches baselineHead vs |
The pre-review of this PR confirmed ten findings; eleven red tests forced the behavior changes before any fix landed. The controller now always answers. runCeremony wraps its body and maps any rejection, a missing native module included, to a fail-open answer with a notice, and probeDeviceSecurity does the same, so no caller can be parked on a promise that never settles. The ceremony's stall countdown runs only while the app is observed 'active': leaving pauses it, returning re-arms it in full, and a fire over a provably-away app re-parks, so a stall is never declared over a provable live prompt. ADR 0007's accepted consequence still covers the Android biometric sheet, which does not leave 'active' and offers no proof. A ceremony ended by leaving the app is an answer, not a broken gate: iOS systemCancel and Android's no-resumed-activity token now lock like the ERROR_CANCELED that Android already reports for the same gesture, closing the ungated re-entry the review demonstrated. A stalled ceremony's native call is kept rather than abandoned, and the next trigger adopts it instead of colliding into a cancel that read as a decline; a late settlement arms the freshness window. The retry's answer now rides into the boot path as data through resolveTriggerGate, so the startApp trigger consumes it instead of running a second ceremony behind a clock coupling, and the fail-open notice shows once. The lock-notice-proceed policy lives in one enactGateAnswer helper that all four call sites share. The probe carries the platform code that refused to secure, and resetGateController gives tests sharing a module registry a seam out of the process-wide freshness memory. The tenth finding, dangling ADR 0007 citations, is a merge-order fact: the PR body now states that #1341 merges first. All 516 tests pass, with tsc, eslint, and prettier clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The re-review of this PR confirmed ten findings. Eight are fixed here, each behind a test that failed before its fix, and two are adjudicated against decisions ADR 0007 ratifies rather than changed. The controller's last unbounded waits are gone. The epilogue's background-flag restore is stall-bounded, so a wedged AsyncStorage queue can no longer hang askGate and trap the boot without a lock screen. A stalled ceremony's native call now has one adoption life: a call that stalls a second adopted window is declared dead and dropped, so one leaked promise cannot wedge the gate for the rest of the process. The frame yield guards a throwing requestAnimationFrame, and the outcome switch gains a default arm, so a native binary answering outside the union still fails open instead of resolving undefined. The blanking overlay's back button becomes an escape hatch that dismisses a stranded overlay. The locked screen's retry is wrapped in dropWhileInFlight for its whole flight, ceremony and boot, so a double tap cannot run two concurrent boots. The screen hook keeps one live gate body at a time: a foreground epoch re-fire supersedes the pending run, so two effect invocations sharing one ceremony never both act on its decline. Boot now retires both shipped sentinel keychain services, bounded and idempotent, closing the stale-entry reuse hazard the sentinel rename convention used to carry. Two findings stand as ratified behavior. A shared ceremony's decline answering both the screen hook and the foreground trigger is the single-controller design: the ADR deleted the purpose axis, and the foreground trigger's lock on that decline is correct. The Android biometric sheet keeping the activity 'active' leaves its stall unprovable, and the ADR's accepted consequence covers waiting out a wedged gate; the fail-closed Android arm was rejected in the grilling. The confirmed cleanup items ride along: ceremonyBody now asks probeDeviceSecurity instead of duplicating it, LoadedApp uses resolveTriggerGate instead of restating it, and the hook's doc comment drops its ADR reference. All 524 tests pass, with tsc, eslint, and prettier clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DeviceAuth module now guarantees settlement and classifies leaving-the-app endings as declined (zingolabs/feat/device-auth-module 768fba3, merged in here), so the controller's remaining machinery compensated for a distrust that no longer exists. Two red tests forced the behavior changes: the epilogue's storage write must not delay the answer at all, and every unavailable code fails open uniformly because interruption classification lives in the native modules. The collapse removes both stall watchdogs, the STALLED symbol, the AppState pause-and-veto listener, the pending-ceremony adoption and its two-strikes counter, and the JS interrupted-code list. The prompt ends exactly when the OS ends it, so a stall over a live prompt is unrepresentable rather than guarded against. The epilogue's background-flag write floats with a swallowed failure, and the sentinel cleanup is plain best-effort awaits. With no stall verdicts left, the biometrics-failure-stalled key retires from GateFailureKey, from all five catalogs, and from the parity list. renderGateFailure loses its one param-appending branch and always renders the translated entry alone, and the Settings probe latch drops its stalled special case. The six tests that pinned the watchdog machinery retire with it. The controller drops from 397 lines and four module variables to 266 lines and two, with zero timers beyond the cosmetic paint budget. All 517 tests pass, with tsc, eslint, and prettier clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
|
Heads up on the premise: |
Carries the settlement-review fixes on the DeviceAuth module (ceremony identity, joining concurrent calls, the saved-state and reload settle paths, BIOMETRIC_STRONG, the retained LAContext, the inverted iOS error mapping) so this branch's diff against its base stays additive rather than reverting them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The freshness window compared `Date.now() - lastPassedAt` against the window without a floor, so a clock moved backwards — a timezone or NTP correction, a manual change — made the elapsed time negative and read as inside the window for as long as the gap lasted, holding the shutter open across it. A pass from the future is no pass at all. The test moves the clock an hour back after a pass and holds that the next trigger runs its own ceremony; it fails against the unfloored comparison. `biometrics-message-ios` has no reader: the controller asks for `biometrics-message` on both platforms, and the key was already unused on dev before this branch. It retires from all five catalogs rather than sitting there as copy nobody can reach. Verification: jest 491 pass, one more than the branch point and the same 29 pre-existing snapshot failures on this machine; tsc, eslint and prettier clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
juanky201271
added a commit
that referenced
this pull request
Aug 29, 2026
CONTEXT.md cited `app/simpleBiometrics.ts` as the home of these surfaces, and #1344 deletes that file whole. The vocabulary this section defines belongs to the gate controller the ADR prescribes, so the pointer follows it there, and "callers" becomes "triggers" to match the word the section itself introduces two entries down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
juanky201271
approved these changes
Aug 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The rewrite ADR 0007 prescribes (docs in #1341), stacked on the DeviceAuth native module (#1342). Merge order: #1341 first, then #1342, then this PR, so the ADR 0007 citations in the code resolve to a ratified document. The new
app/gateController.tsruns one device-auth ceremony at a time, shared by every concurrent trigger. A fifteen-second freshness window replaces the purpose axis and the re-ask holds. Every way the gate cannot run fails open with a notice under onefailedOpenending, soGateAnswerhas three arms: passed, declined, failedOpen.The deletion is the point.
simpleBiometrics.tsgoes whole: the keychain sentinel and its v1 migration, the GatePurpose axis, theunansweredverdict and both holds, the per-platform stall policy record, the GatePhase rebuild table, and the message-text scrape that classified declines. Two review rounds then hardened the controller behind tests that failed first (always answers, one adoption life, carried retry answers, double-tap guard, one act per shared decline, back-button escape hatch, sentinel cleanup at boot, oneenactGateAnswerpolicy helper for all four call sites).The final commit removes the last compensations. #1342 now guarantees settlement natively (terminal callback, host-destroy hook, busy answer) and classifies leaving-the-app endings as declined before they cross the bridge, so the JS watchdogs, the AppState pause-and-veto, the pending-ceremony adoption, and the interrupted-code list are deleted rather than maintained: the prompt ends exactly when the OS ends it, and a stall over a live prompt is unrepresentable. The
biometrics-failure-stalledkey retires from the union and all five catalogs. The controller ends at 266 lines with two module variables and no timers beyond the cosmetic paint budget.Two earlier review findings stand as ratified behavior rather than bugs: a shared ceremony's decline answers the screen hook and the foreground trigger together (the single-controller design; the ADR deleted the purpose axis), and a person deliberating past any wait at a live Android biometric sheet falls under the ADR's accepted wait-out consequence.
The keychainOptions INTERACTIVE_AUTH profile is now unused; its removal is a follow-up. Verification: 517 of 517 tests pass, with tsc, eslint, and prettier clean; the native halves compile in CI.
🤖 Generated with Claude Code