Skip to content

refactor(biometrics)!: replace the sentinel gate with the gate controller - #1344

Merged
juanky201271 merged 7 commits into
devfrom
feat/gate-controller
Aug 29, 2026
Merged

refactor(biometrics)!: replace the sentinel gate with the gate controller#1344
juanky201271 merged 7 commits into
devfrom
feat/gate-controller

Conversation

@zancas

@zancas zancas commented Aug 27, 2026

Copy link
Copy Markdown
Member

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.ts runs 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 one failedOpen ending, so GateAnswer has three arms: passed, declined, 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 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, one enactGateAnswer policy 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-stalled key 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

…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>
@zancas
zancas requested a review from juanky201271 August 27, 2026 04:09
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Visual review — ✅ matches baseline

Head vs visual/__baseline__. Open the review page — Current, Animations, and Image diffs tabs.

zancas and others added 4 commits August 26, 2026 21:40
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>
@juanky201271

Copy link
Copy Markdown
Contributor

Heads up on the premise: 11948fe4 drops the JS watchdogs on DeviceAuth's "guarantees settlement" claim, and that claim does not hold on Android. Four paths leave a promise unsettled — details in #1342 (review). Two of them (busy and the wedged slot) answer unavailable, which the controller cannot tell from a real platform refusal without sniffing code. Worth settling #1342 before this lands.

juanky201271 and others added 2 commits August 29, 2026 09:47
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>
Base automatically changed from feat/device-auth-module to dev August 29, 2026 16:39
@juanky201271
juanky201271 merged commit 5510993 into dev Aug 29, 2026
38 of 50 checks passed
@juanky201271
juanky201271 deleted the feat/gate-controller branch August 29, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants