Repository navigation
Conversation
f9f41f0 to
28c3c73
Compare
d47e7c2 to
2415a08
Compare
28c3c73 to
c1bfe83
Compare
2415a08 to
600aa3a
Compare
600aa3a to
c172562
Compare
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Two error-classification findings in identity recovery and backup contention.
c1bfe83 to
b353bdf
Compare
…icit lock release, and refused-recovery copy
A cross-host change reviewed as one contract: the native nsec backup
uploader becomes observable so a caller can follow up on a specific backup
pass, and its locks release correctly.
Rust (`src-tauri/src/nsec_backup.rs`):
- `BackupStatus { seq, pass, builderlab, status }`, `status` one of `idle`,
`running`, `complete`, `waiting { reason: no_consent | no_session |
not_linked }`, `failed { kind: busy | signed_out | not_offered |
other_key_linked | invalid_service | refused | unreachable | unconfirmed |
storage }`. `builderlab` is true when a storage root exists and the
Builderlab source is allowed for this build. Published under the state
lock so pushes leave in `seq` order: a spawned pass publishes `running`
then its outcome; a queued rerun publishes `running` under the latest
ticket; a null handoff that retires a running pass publishes
`waiting{no_session}` numbered as the latest ticket, so a queued rerun's
ticket is settled too; `document_load` turns a running pass into `idle`.
- `classify`: 401 ⇒ `signed_out`; 403 ⇒ `other_key_linked` on upload,
`not_offered` on enrollment and on the `current` read; 404 ⇒
`not_offered`; 429, 5xx and transport ⇒ `unreachable`; other 4xx ⇒
`refused`; enrollment 409 ⇒ `waiting{not_linked}`; an upload response that
is not a stored, matching receipt ⇒ `unconfirmed`; another pass holding
the consent lock or this key's record lock ⇒ `busy`; other local I/O ⇒
`storage`. No response body crosses IPC.
- `trigger`/`session`/`retry` return the ticket (`Option<u64>`) of the pass
that starts after the call; a wake during a pass gets a ticket covered by
the single queued rerun. `nsec_backup_state` returns the snapshot;
`nsec_backup_watch(Channel)` replaces the single watcher slot and refuses
any window but main. Both are registered in `lib.rs`, `build.rs`,
`capabilities/default.json` and `browser_permissions_tests.rs`.
- Recovery: a 403 on the `current` read (no `buzz_users` capability) or on
recovery itself (not a private org, or no Buzz user role) answers the new
`REFUSED` string "Backup recovery is not offered for this account."
instead of the generic retry string; 401 keeps it, since a new sign-in
may clear a rejected session.
- Lock fix (`nsec_backup_choice.rs`, `nsec_backup/persistence.rs`): the
consent and per-identity record locks relied on closing the file to
release the `flock`; a child spawned through fork/posix_spawn shares the
open file description until exec, so the next `try_lock` saw contention
and a pass reported a spurious `busy`/`storage`. A `Lease` now unlocks
explicitly in `Drop` (the pattern `crates/credential-store` already uses).
Probe, 3000 trials: close-only 29–57 spurious holds, unlock-then-close 0.
TypeScript consumer:
- `createBackupStatus` (`backup.ts`), exposed as `identity.backup`
(`service.ts`): registers nothing until the first subscribe/`ready()`,
watches before reading the snapshot, keeps the higher `seq`, re-registers
after a failed watch. It stays out of `IdentitySnapshot`.
`handoffBackupSession` resolves to the ticket or null and never rejects.
- `backUpWith(identity, pubkey, credential, opts)`: the waiter a first-run
step will use — `ready()`, consent on, hand the credential to the
`builderlab` slot, wait for the first status with `pass >= ticket` and not
`running`, hand the slot back on every exit; `complete` resolves,
`failed{kind}` ⇒ that kind, `waiting{not_linked}` ⇒ `not_linked`, other
waits/idle ⇒ `interrupted`, 60 s ⇒ `timeout`; renderer-side failures are
`unknown`/`unavailable`/`interrupted`. `BackupError(kind)` carries the
kind, the table's message and `retryable`.
- `backup-failure.ts` (new): `BACKUP_FAILURES`, one entry per kind with
first-run copy (cause + instruction) and whether Try again can help. The
Settings `cause` variant and the seed's kind guard arrive with their
callers (PRs 08 and 05).
- `RECOVERY_UNAVAILABLE`/`RECOVERY_REFUSED`/`recoveryOff()` in `service.ts`
match the two native strings exactly; `RestoreIdentity` hides *Try
recovery again* when recovery is off; its `expectedPubkey`/`onImport`
props arrive with their caller (`KeyRestore`, PR 06). In
`identity.test.tsx` the two UNAVAILABLE rows move from the Try-again
`it.each` into a new no-retry `it.each` that adds a REFUSED row.
Tests: Rust `nsec_backup/tests.rs` (+541: running-then-outcome under the
returned ticket, the full waiting/failure table incl. busy from a held
consent or record lock, rerun tickets, supersede by key install, a null
handoff settling a rerun ticket, a 403 on `current` refusing recovery,
`document_load` with stale and new watchers, retry after timeout, flat
serialization, permissions) and `nsec_backup_choice/tests.rs` (a released
lease is free while a dup'd descriptor still shares it; a `dup` stands in
for the spawned child). TS `backup.test.ts` (new, 347: the store's lazy
registration and seq rule; the waiter's invoke order watch → state →
set_choice → session → session(null), each status → kind, stale pass
ignored, deadline, `builderlab: false`, null ticket, no native text
reaching the page), `backup-choice.test.ts` (creating an identity never
invokes watch/state), and the recovery hunks of `identity.test.tsx`: the
3-row `it.each` "offers no Try recovery again when %s answers %s" and
"matches native recovery's unavailable and refused messages exactly",
which reads `nsec_backup.rs?raw`. docs/identity.md: the observable status
paragraph and the refused-recovery line.
Stack: PR 03 of the onboarding stack; depends on nothing. 05–08 build on
it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
b353bdf to
6546483
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.
No required changes found; one optional recovery-copy suggestion is inline. Reviewed head 6546483b07fd588ed2972beb47ab0029a6403836 against base 6cec9476b0761ac8dcb823abdfe4f656ceb8f580 (merge-base fcbb8347). Source-only: tests and app flows were not run, CI was not assessed, and native IPC/lock behavior and first-run integration remain unverified; backUpWith has no production caller in this layer.
…essage `recoveryOff()` already hides Try recovery again for this message, since no retry changes a build or tenant without recovery; the copy still told the user to try again later. The message is now "Backup recovery is unavailable." in native `UNAVAILABLE`, the renderer's `RECOVERY_UNAVAILABLE` (kept in lockstep by the exact-match contract test in `identity.test.tsx`), and the browser identity fixture that mirrors it. Back still leads to manual key entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account.
No further changes requested. The optional recovery-copy fix keeps native UNAVAILABLE, renderer RECOVERY_UNAVAILABLE, and the browser fixture aligned; the existing exact-match contract test covers this boundary.
Reviewed head 717f39eac30bb702e540ab0f2d5865dedff180ac, base 6cec9476b0761ac8dcb823abdfe4f656ceb8f580. Solo follow-up under the small-change exception: only these three strings changed since the prior four-Guardian review. Source-only; tests, app/focus flows, and CI were not verified.
Onboarding stack (merge in order, top to bottom):
Depends on: nothing; based on
main. PRs 05–08 build on it.Diff: production +636/−76; tests +951/−45; docs +12/−1
What this layer is
A cross-host change reviewed as one contract. Rust:
BackupStatus { seq, pass, builderlab, status }published under the state lock;nsec_backup_state/nsec_backup_watch(Channel)commands (registered inlib.rs,build.rs,capabilities/default.json,browser_permissions_tests.rs);trigger/session/retryreturn the ticket of the pass they start; theclassifytable (401 ⇒signed_out, 403/404 ⇒not_offered, 429/5xx/transport ⇒unreachable, a held consent or record lock ⇒busy, …); theREFUSEDrecovery string for a 403 on thecurrentread or on recovery itself (401 keeps the retry string, since a new sign-in may clear it); and theLeaseflock fix (nsec_backup_choice.rs,persistence.rs) so a spawned child cannot hold the consent or record lock. TS consumer:createBackupStatusexposed asidentity.backup(service.ts),handoffBackupSessionresolving to the ticket, thebackUpWithwaiter +BackupError, theBACKUP_FAILUREStable (backup-failure.ts),RECOVERY_UNAVAILABLE/RECOVERY_REFUSED/recoveryOff, andRestoreIdentityhiding Try recovery again when recovery is off.backup.test.tsover scriptedinvokeis the executable contract for the Rust status.Review focus
nsec_backup.rs: every publish point is under the state lock (seq order); a retired or closed pass publishes nothing; no response body crosses IPC. Theclassifytable is the contractbackup-failure.tscopies.Leasefix:flock(LOCK_UN)inDropreleases the lock for every descriptor sharing the open file description (a fork/posix_spawn child's inherited copy). Same pattern ascrates/credential-store.backup.ts: the store registers lazily, watches before reading the snapshot, keeps the higherseq; the waiter answers only a pass withpass >= ticket, so a stale failure from before sign-in never answers it, and hands the credential slot back on every exit.waiting{no_session}under the latest ticket, so a rerun queued during that pass gets its answer too.RestoreIdentity.tsxchanges only therecoveryOffcondition; itsexpectedPubkey/onImportprops moved to PR 06 withKeyRestore.backup-failure.tsholds the table andBackupFailureKind;isBackupFailureKindmoved to PR 05 with the seed guard and the Settingscausevariant to PR 08. Inidentity.test.tsxthe two UNAVAILABLE rows move from the Try-againit.eachinto a new no-retryit.eachthat adds a REFUSED row, and the raw-import test pins both native strings.Checks run on this branch's tree
cargo test nsec_backup49 passed (the four new or changed Rust tests fail on the previous head),cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings,tsc --noEmit,biome check,vitest run src/features/identity120 passed.--no-verifybecause the machine was overloaded; the hosted checks are the verification of the pushed tree.Native evidence
None on this tree. The native first-run recordings and the hands-on checks on the branch were made on the full tree, which only the DO-NOT-MERGE PR 09 reproduces (it adds the isolated debug Keychain slot). Treat every layer below 09 as unit/integration-tested only.
🤖 Generated with Claude Code