Skip to content

fix(mobile): give the recovery-blocked state an exit so no device stays unable to start - #514

Merged
krishagel merged 1 commit into
mainfrom
fix/no-absorbing-block
Sep 16, 2026
Merged

krishagel merged 1 commit into
mainfrom
fix/no-absorbing-block

Conversation

@krishagel

Copy link
Copy Markdown
Member

The sweep

After #512 and #513 closed the two unresolved fences, this audits every remaining client state that can refuse start and join, applying one rule: no retained state on the device may permanently prevent raising an emergency.

State Could it trap a device? Action
unresolved (owner's own) Yes — fixed in #512
unresolved-other-session Yes — fixed in #513
recovery-blocked Yes — no exit at all Fixed here
pending-other-session No — transient Audited, left alone
checking-recovery No — resolves in the commit phase Audited, left alone

recovery-blocked

Entered when the encrypted store throws on a read, write, or clear — four call sites. Nothing left it. The screen carried no controls, every start and join stayed refused, and the only guidance was to restart the app and contact district technology support. A keychain that keeps failing produced a device that could never raise an emergency again.

continueWithoutRecovery lets the operator carry on with no durable recovery for the rest of the process, returning to idle so start and join work.

Deliberate choices:

  • Suspension never deletes. A store that cannot be trusted to read or write is not trusted to erase either, so whatever is stored is left untouched.
  • The copy states the real consequence — a request interrupted by the app closing will not be recoverable on this device — rather than implying the problem is gone.
  • Disabled while offline, matching the other two screens.
  • Requires the live signed-in owner, so it is a human decision, not an automatic downgrade.

pending-other-session, audited and left

Reachable only while another session's request is in flight inside this process. An owner change quarantines that pending state and it settles; a relaunch turns it into the unresolved record, which #513 made clearable. Transient, not absorbing — no change needed.

Coverage

  • A store failing every write blocks the first submission, then after the operator continues, admits the next start through to success.
  • The choice is refused when the state is not blocked, and when the caller is not the signed-in owner.
  • The blocked screen exposes the control, fires it once, and says both that nothing stored is deleted and that an interrupted request will not be recoverable.

Gate

Prettier, ESLint, typecheck clean repo-wide. 6/6 shards green; 61 mobile native tests pass.

Platform parity

Client-only logic and shared React Native UI. Needs verifying on iOS and Android.

…ys unable to start

`recovery-blocked` is entered when the encrypted store throws on a read, a
write, or a clear. Nothing left it. The snapshot had no controls, every start
and join stayed refused, and the only advice was to restart the app and
contact district technology support. A keychain that keeps failing therefore
produced a device that could never raise an emergency again, which completes
the same class of defect as the two unresolved fences: retained client state
that permanently disables the one action the app exists to perform.

The operator can now choose to carry on without durable recovery.
`continueWithoutRecovery` suspends the store for the rest of the process and
returns to idle, so start and join work. Suspension never rewrites or deletes
what is already stored: a store that cannot be trusted to read or write is
also not trusted to erase. The copy states plainly that a request interrupted
by the app closing will not be recoverable on this device, which is the real
consequence of proceeding, and the control is disabled while offline.

`pending-other-session` was audited in the same pass and left alone. It is
reachable only while another session's request is in flight inside this
process; an owner change quarantines that pending state and it settles, and a
relaunch turns it into the unresolved record, which is now clearable. It is
transient rather than absorbing.

Coverage: a store that fails every write blocks the first submission and then,
after the operator continues, admits the next start through to success; the
choice is refused when the state is not blocked and when the caller is not the
signed-in owner; the blocked screen exposes the control, fires it once, and
says both that nothing stored is deleted and that an interrupted request will
not be recoverable.

Gate: Prettier, ESLint and typecheck clean across the repository; 6/6 test
shards green and 61 mobile native tests pass.
Copilot AI lite review requested due to automatic review settings September 16, 2026 15:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review

Reviewed the diff against packages/mobile/src/lib/start/start-mutation-coordinator.ts, start-mutation-provider.tsx, start-mutation-attention.tsx, and the two screen wire-ups in app/index.tsx / app/start/index.tsx.

Summary: clean. No IMPORTANT findings.

The continueWithoutRecovery flow is correctly implemented against the stated design:

  • The private store getter (start-mutation-coordinator.ts:502-504) cleanly gates every read/write/clear call site (persistCurrentStateOrBlock, clearDurableRecordOrBlock, hydrate, submit) behind persistenceSuspended, so once suspended, storage really is never touched again — matching the PR's "suspension never deletes" claim.
  • continueWithoutRecovery (:923-935) correctly requires phase === 'recovery-blocked' and sameOwner(this.onlineOwner, owner), mirroring the existing acknowledgeUnresolved pattern (live signed-in owner required, not an automatic downgrade).
  • Provider wiring (start-mutation-provider.tsx:323-328, 392-394) reuses exactLiveOwner() and the checking-recovery guard exactly as acknowledgeUnresolved does — no new pattern introduced.
  • online={state.phase === 'online'} wiring in both screens (app/index.tsx:222, app/start/index.tsx:509) is consistent with every other online prop in these files.
  • New coordinator tests (start-mutation-coordinator.test.ts:1073-1112) actually exercise the interesting cases: a blocked write followed by a successful post-continue submission, and rejection when not blocked or when the caller isn't the live owner (continueWithoutRecovery(OTHER_OWNER) while blocked → false). These assert real state transitions, not just return values.
  • The new attention-component test asserts the actual button press wires to the callback and that both required copy fragments ("nothing already stored is deleted", "will not be recoverable here") render.

Two minor observations, not blocking:

  • NIT: No test at the app/index.tsx / app/start/index.tsx level exercises the new online/onContinueWithoutRecovery prop wiring directly (only the lower-level coordinator and attention-component tests cover the behavior). Pre-existing pattern though — neither screen file has any test coverage at all today, so this isn't a regression introduced by this PR.
  • NIT: persistenceSuspended is a permanent-for-process one-way flip with no reset on reconcile (sign-out/sign-in). That matches the documented intent ("lasts for this process only") and the store failure is hardware/keychain-level rather than owner-specific, so this looks intentional rather than an oversight — flagging only so it's a conscious choice, not an accident.

No FERPA/PII, secrets, or injection concerns — this is client-only mutation-state plumbing with no new data leaving the device. No tests were removed or skipped.

@krishagel
krishagel merged commit c5b9035 into main Sep 16, 2026
12 checks passed
@krishagel
krishagel deleted the fix/no-absorbing-block branch September 17, 2026 02:37
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