feat(iroh): complete release closeout and recovery hardening - #9071
Merged
Merged
Conversation
The active-binding slot was keyed on app_instance_id with a unique index, so a reinstall, sign-out/in, or key rotation produced a fresh app instance that collided with its own past self and got a 409 binding_replacement_requires_revocation. That stranded the App Store review Mac behind a stale non-revoked binding for 17h with no client-side recovery. Re-key the slot to (user_id, device_uuid, tag), partial-unique where revoked_at is null. A registration for an existing slot now overwrites it in place (newest authenticated registration wins) and preserves the binding row id so existing pair grants keep resolving. No generation gate: a reinstall resets identity_generation to 1, and gating on it would reintroduce the wedge. The endpoint id stays globally unique, re-checked excluding self so a slot can rotate its own key. Drop the per-device (8) and per-account (32) binding caps, the stale-binding recycler, and the bindingQuota plumbing; the challenge-issuance quota is kept. Advisory locks move from iroh:app:<appInstance> to iroh:slot:<user>:<device>:<tag> so same-slot registrations serialize. Migration collapses any duplicate active (user, device, tag) rows (keep most recently seen, soft-revoke the rest, revoke their pair grants, bump LAN discovery generation), drops active_app_instance_unique, and adds active_slot_unique. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client complement to the broker binding re-key (#8883), which changes the iroh binding slot from unique(app_instance_id) to unique(user_id, device_uuid, tag) and replaces the 409 binding_replacement_requires_revocation with a newest-authenticated-wins in-place UPDATE. Two changes make the phone cooperate with that slot: 1. Stable device id across reinstall. The iOS device-registry id moves from UserDefaults (erased on delete/reinstall) to a device-only Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1, AfterFirstUnlockThisDeviceOnly). A returning phone now presents the same device_uuid and overwrites its own binding in place instead of stranding a fresh one. Keychain is authoritative; a pre-Keychain UserDefaults id is migrated on first read, and the generated id is mirrored back to UserDefaults for downgrade safety. This service is distinct from the iroh endpoint-identity store that sign-out/reinstall wipes, so forgetting the endpoint identity does not churn the slot key. 2. Forget a hidden computer. The per-phone Hidden Computers list gains a destructive Forget action (swipe + context menu, both gated behind a confirmation dialog, mirroring MacComputerRow's Hide) that revokes the Mac's account binding through the user-ownership-scoped broker endpoint. It resolves the binding id at action time via a fresh broker.discover() (so an offline Mac's binding is still listed and revocable), matches by canonical device id plus exact tag when known, revokes each match, then clears the local hidden marker and paired-Mac row. A still-online Mac re-registers and reappears on its next connect. Failure keeps the row and surfaces a toast. New narrow capability MobileIrohMacForgetting keeps the shell store's dependency minimal; en+ja localization added for the Forget copy.
…nity cap Address the two P1 review findings on the re-key branch. Finding 1 (ABA wedge): register reused the same binding id when an existing slot re-registered with a rotated endpoint key. A peer host that had denied the OLD endpoint tuple keeps the denial keyed on binding id, so the rotated device was permanently denied behind its own past self. Now a same-endpoint registration is treated as a heartbeat and updates in place (stable id, no ABA), while a rotated endpoint on an existing slot soft-revokes the old row (revokedReason "slot_reincarnated", cleared ports/path hints) and inserts a NEW binding id, carrying live pair grants (initiator + acceptor) onto the new id so pairings follow the device without a re-pair. No lanDiscoveryGeneration bump: a device rotating its own key is not an account-wide trust revocation. Finding 2 (unbounded growth): under unique(user, device, tag) a stuck client spamming fresh tuples could grow the active row set without bound. Add IROH_ACTIVE_BINDING_SANITY_CAP (512) enforced only on the genuinely-new-slot path, evicting the oldest-seen bindings (LRU by lastSeenAt) with reason "active_binding_cap_evicted". No-op for every normal account (a handful of bindings; heavy multi-tag dev at most low hundreds). Tests: reinstall now asserts new-id semantics + retired-row reason; added grant-carry and cap-eviction coverage. 33 DB-behavior tests and 26 route-layer tests pass against isolated Postgres; typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n account Address the four P1 review findings on the iroh re-key iOS client branch. Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an optional, collapsing "no id yet" and "Keychain locked before first unlock" into nil. A background launch before first unlock therefore looked like a fresh install and minted a NEW id, stranding the phone's existing (user, device, tag) binding. read() now returns DeviceIdentityReadResult (.found/.absent/ .unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses the legacy UserDefaults mirror if readable, else a per-process ephemeral id that is never persisted, so the durable id is adopted once the store unlocks. A .found id is re-mirrored to UserDefaults (only when it differs) for downgrade safety; a present-but-blank/corrupt item is treated as .absent and re-minted. Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow can't revoke a binding under the wrong account (MobileIrohForgetError. accountChanged). Finding 3 (Forget ordering): MobileShellComposite forget removes the row before clearing the hidden marker and returns Bool so a failed broker revoke surfaces instead of silently dropping the row. Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a toast) on Forget failure, so the error surfaces even with the Toasts beta flag off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok localized en+ja. CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition transitively need GhosttyKit, so they compile only in the fleet iOS build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-transient broker rejection (401/403/404/409, invalid response) tears CmxIrohHostRuntime down into a terminal .failed phase. That fail-closed teardown is deliberate, but nothing ever rebuilt the runtime: MobileHostIrohRuntime.retryIfNeeded() only re-synced LAN publication while it held a runtime reference, and no timer retried a failed activation. A Mac whose registration was rejected once stayed unregistered until sign-out/sign-in, a Settings-triggered restart, or an app relaunch (the 17-hour App Store review 409 wedge). Recovery is now owned by the macOS composition root, level-triggered through the existing reconcile path: - Every failed activation and every runtime self-teardown into .failed (reported through the existing handleDeactivation callback, filtered by lifecycle revision so deliberate stops are ignored) arms one pending rebuild with bounded exponential backoff (30s doubling to a 1h cap, jittered, via CmxIrohRetrySchedule and an injected clock). - retryIfNeeded() now rebuilds a .failed runtime immediately on any external wake signal (network path change, app-level retry) and resets the backoff ladder, instead of only re-syncing LAN state. - Each reconcile cancels the pending attempt and re-derives recovery from its own outcome: success resets the ladder, failure re-arms it, sign-out/deactivation ends it. The new package test pins the contract this depends on: a rejected registration refresh fails closed (endpoint torn down, deactivation notified) and the same runtime accepts start() again once the broker allows registration. The two-commit red/green structure does not apply because the wedge lives in app-target singleton wiring that has no practical automated harness; the package test guards the enabling semantics instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… churn Address review findings on the slot re-key path: - Heartbeat-in-place now requires every signed grant-identity field (endpoint id, platform, identity generation) to be unchanged, not just the endpoint id. Overwriting platform/generation on a live binding id would let a still-valid grant signed against the old value mismatch the current binding, so a host records this id in its permanent denial set — the exact ABA wedge the fresh-id path exists to prevent. Any divergence now falls through to reincarnation and mints a fresh id. - Reincarnation retires the old slot through revokeActiveBindings instead of a bespoke soft-revoke. That rotates lanDiscoveryGeneration (so a displaced install can no longer derive future LAN rendezvous aliases) and marks the retired binding's pair grants revoked. - Drop the pair-grant foreign-key carry-over. iroh_pair_grant_issuances is an audit-only ledger of compact JWS tokens already returned to clients; reassigning the FK cannot rewrite a held token, and re-keying forces a re-pair anyway because the token names the dead endpoint. Carrying the FK only made the JTI audit point at a binding it was never signed for. - Sanity cap now rejects a genuinely-new slot at the cap (IrohQuotaExceededError code active_binding_limit) instead of evicting the oldest-seen binding, so a stuck client spamming fresh device/tag tuples can no longer shed the account's real, older hosts and phones. Update iroh-db-behavior and iroh-trust-broker tests to the corrected contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address the P1 findings from review of the iroh re-key client changes. Finding 1 (composition-half): re-resolve the durable device id at each activation via DeviceRegistryService.durableDeviceID(defaults:) instead of capturing it once at root init. A value captured while the durable identity store was unavailable (Keychain locked before first unlock, or a persistent write failure) is an ephemeral throwaway id; registering a binding under it would orphan the retained (user, device, tag) binding. When the durable id is nil, activation now defers (throws .inactive) and retries on the next reconcile once the store becomes readable. The injected resolver is @mainactor () -> String? so it can capture UserDefaults, which is not Sendable under Swift 6. Finding 2: forgetComputer now pins the revoke to one atomic AuthenticatedSessionSnapshot (session generation + account id + both tokens) captured from a single auth-session generation, and the caller passes the row's captured expectedAccountID. Reading the observed identity and the live tokens separately let a lagging observed id authorize a revoke that then ran with a different account's freshly-stored tokens. The broker token source and every mid-flight re-check now require BOTH the generation and the account id to be unchanged, so a sign-out/sign-in (even as the same user) aborts safely. Finding 4: clear the captured scope's durable row and hidden marker unconditionally after a successful revoke. removeStoredPairedMacRow targets the CAPTURED scope, so it cannot touch another account's data; skipping it on a mid-flight scope flip reported success while the row survived, so returning to the old scope showed the supposedly forgotten computer. Tests: activationDefersWhenDurableDeviceIDUnavailable proves no endpoint binds and the retained binding survives when the durable id is unavailable; forgetRemovesCapturedScopeRowEvenWhenScopeFlipsMidRevoke proves the captured account is forwarded and the row is removed on a mid-revoke scope flip; DeviceRegistryRouteSelectionTests cover the durable-id defer/mirror/adopt paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-revoke switch The forget-hidden-computer flow snapshots its owner scope before the async iroh revoke, then deletes the stored row. When the captured scope is team-less (no team selected) and the user switches into a team while the revoke is in flight, local cleanup goes through the team-scoping decorator's plain remove, which substitutes a nil teamID with the now-current team. It deletes that team's row and leaves the forgotten team-less computer behind, so it reappears on returning to no-team. This commit adds only the failing regression test (drives forgetHiddenComputer through a TeamScoped-wrapped store with a mid-revoke team flip); the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add removeExactScope to MobilePairedMacStoring: same shape as remove but it never substitutes a nil teamID with the currently-selected team. The team-scope decorator (TeamScopedPairedMacStore) and the backup mirror (BackingUpPairedMacStore) override it to forward the captured teamID verbatim; the base SQLite store, MobileMacCompatible, and IOSBuildScoped decorators inherit the default forward (none of them substitute, so plain remove and removeExactScope are equivalent there). forgetHiddenComputer captures its owner scope before the async iroh revoke, so removeStoredPairedMacRow now deletes via removeExactScope — a mid-revoke team switch can no longer retarget a team-less forget onto the freshly-selected team. Also call clearSavedMacHintWhenNoStoredMacsRemainIfNeeded() on the forget path after reloading, matching the hide path, so forgetting the last stored Mac drops the saved-Mac hint instead of leaving a dangling reference. Makes the prior commit's regression test pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… transition Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd first and, on errSecDuplicateItem, adopts the value already stored, so two launches racing to mint an id converge on one instead of overwriting each other and registering two device rows against the broker. The UserDefaults mirror is reconciled to the winning id; Keychain stays authoritative and survives app reinstalls so the broker binding is not orphaned. Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires !sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token rotation cannot hand back a half-swapped session that would drive a redundant re-register. Adds convergence coverage in DeviceRegistryRouteSelectionTests (createOrAdopt adopts the concurrent winner rather than minting a second id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…deviceUuid contract Review round 2 for the binding re-key. - Sanity cap: keep the reject-not-evict semantics (over-cap registrations throw IrohQuotaExceededError so a churning client can never shed real hosts) and hold the value at 512, well above any legitimate multi-tag developer's low-hundreds active-slot count. (An earlier draft lowered it to 256 citing an iOS 'maximumBindingCount' wire limit; no such constant exists — the only 256 in the client is MobileSyncFrameCodec's per-read frame cap on the terminal RPC transport, unrelated to iroh discovery responses. Dropped that false rationale.) - Challenge-freshness gate: reject a registration whose challenge was minted before the slot's current registeredAt. Registrations for one slot serialize under the slot advisory lock; without this, a delayed/replayed older challenge could land second and overwrite or reincarnate away the newer incarnation, an out-of-order wedge. A live heartbeat's own challenge is always newer, so it passes; registeredAt only advances on insert/reincarnation, so it is the right high-water mark. - schema: document that deviceUuid MUST be stable across reinstalls or a reinstall orphans the old active slot; the client owns this (iOS now derives it from a Keychain identity that survives reinstall), the DB cannot enforce it. - test: the mac->ios platform change on one slot reincarnates (revoke old id + mint new) instead of overwriting in place, so a still-valid grant signed against the old platform can't ABA-wedge into the host's permanent denial set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
databaseConflict only mapped the endpoint-unique index (23505 -> endpoint_already_bound); a violation on the new (user, device, tag) active-slot partial unique index fell through to a raw IrohDatabaseError (HTTP 500). The slot advisory lock serializes same-slot registrations so this is unreachable in practice, but map it defensively to a typed 409 (slot_registration_superseded) so a concurrent newest-wins race surfaces as a retryable conflict instead of a 500. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ke team flip The committed version of this test asserted contradictory post-conditions, so it did not actually prove removeExactScope deleted the right row. Rewrite it to load the base store once and partition rows by each row's own stamped teamID (loadAll(teamID: nil) returns every team's rows, and loadAll(teamID:) also returns team-less rows, so the returned set must be filtered by teamID to prove which row was deleted). This version is red against the current visibleScope-based removeExactScope: it deletes the flipped team-b row and the team-less row survives, failing at the team-b assertion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-derivation removeExactScope forwarded through visibleScope/visibleMac, which call inner.loadAll(teamID:): a nil team returns every team's rows and a set team also returns team-less rows, ordered by lastSeenAt descending, so .first could resolve a DIFFERENT team's row than the scope captured before the async revoke and delete that row instead. When the user switches into a team mid-revoke, the team-less forget then deleted the freshly-selected team's row and left the forgotten team-less computer behind. Make removeExactScope a pure pass-through to inner.removeExactScope, honoring the exact (stackUserID, teamID, instanceTag) owner key verbatim; the layers below do not substitute the team. Turns the regression test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e to tests createOrAdopt, on errSecDuplicateItem, reads the item to converge racing callers on one id. But read() maps a present-but-undecodable item to .absent (so a fresh caller re-mints over garbage), which created a deadlock: a corrupt Keychain item made every SecItemAdd return errSecDuplicateItem while read() kept returning .absent, so the device could never mint a device-registry id and iroh activation stayed permanently disabled. On .absent after a duplicate, overwrite the corrupt item via SecItemUpdate and return desired, or nil (retry a clean add) if a concurrent delete raced it to errSecItemNotFound. .unavailable still defers so a locked-before-first-unlock item is never clobbered. Also relocate the InMemoryDeviceIdentityStore test double out of the production target into the test target; nothing in production or the app referenced it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unhide Button's ProgressView keyed off forgetTask, so it never spun during an actual unhide and could spin during an unrelated forget. performUnhide sets actionTask; key the unhide spinner off actionTask. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two heartbeats for one live slot, minted older-then-newer, completing in reverse: the newer lands first and takes the slot, then the delayed older challenge lands second. Without a registration high-water mark that advances on the in-place heartbeat update, the older challenge passes the staleness gate and clobbers the newer incarnation's mutable fields (appInstanceId here) back to a stale value until the next heartbeat self-heals. This commit adds only the failing test; the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ap to client wire limit Finding 3 (reversed heartbeat completion): the in-place heartbeat update left registeredAt frozen at the slot's original insert time, so two reversed heartbeats both cleared the staleness gate and the later-landing OLDER challenge clobbered the newer refresh. Stamp registeredAt to the applied challenge's createdAt on the heartbeat path too, making it a true monotonic high-water mark of the newest challenge that has landed (the gate already guarantees challenge.createdAt >= registeredAt, so it only moves forward). Turns the added reversed-completion regression test from red to green. Finding 1 (cap above client wire limit): lower IROH_ACTIVE_BINDING_SANITY_CAP from 512 to 256 to match the iOS discovery decoder's maximumBindingCount. The broker's discoverySnapshot returns every active binding uncapped, and the client rejects any snapshot carrying more than 256 bindings; admitting a 257th active slot would make the account's own discovery response undecodable on every device. The existing sanity-cap test references the constant symbolically, so it tracks the new value automatically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the empty-slot ordering case the heartbeat test does not: two challenges minted older->newer for a slot that does not exist yet, the older landing first through the insert path. The genuinely newer registration, landing second, must refresh the slot rather than be rejected as superseded. Fails on current code because the insert stamps registeredAt with its own landing time instead of the challenge mint time, setting the high-water mark above the newer challenge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The staleness gate treats registeredAt as the mint time of the newest challenge that has landed, and the heartbeat path already stamps challenge.createdAt. The insert/reincarnation path still stamped the register-request landing time, so an older challenge that created the slot could set the high-water mark above a newer outstanding challenge's mint time and get it wrongly rejected as challenge_superseded, stranding the older registration. Stamp challenge.createdAt on insert too, making registeredAt an ordering-consistent high-water mark on every write path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two regression tests, RED before the fix (commit adds tests only): - Finding 2 (release-reachable): a team-less pairing shown under a selected team (legacy visibility) is forgotten; the forget captures the LIVE display scope and deletes with it, so removeExactScope(teamID: "team-a") misses the team-less row, the hidden marker is cleared, and the row resurfaces as a normal computer on returning to no-team. - Finding 3 (dev/tagged builds): removeExactScope falls back to the protocol-default remove through MobileMacCompatiblePairedMacStore over IOSBuildScopedPairedMacStore, so an exact-scope team removal also deletes the co-located team-less build-scope fallback row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…isplay scope The forget flow captured the live display scope and deleted with it, so a team-less paired-Mac row shown under a selected team (fetchAllMacs legacy visibility) was missed by removeExactScope(teamID: "team-a"); the hidden marker cleared and the row resurfaced (Finding 2, release-reachable). Plumb each row's own stackUserID/teamID through MobileHiddenComputer and delete with the row's own scope. Keep exact-scope removal exact through both store decorators: add removeExactScope overrides to MobileMacCompatiblePairedMacStore and IOSBuildScopedPairedMacStore so the call no longer falls back to the protocol default remove, which over-deleted the team-less build-scope fallback via scopedTeamID(nil) on dev/tagged builds (Finding 3). The pre-existing flip regression test seeded team-less then team-b for the same device+instanceTag, but base upsert claims the team-less row into team-b (moveMacRowScope), collapsing both into one team-b row, so the old assertions passed vacuously (forget deleted a nonexistent owner_key). Reorder the seed (team row first, which a later team-less upsert never claims) so two genuinely independent rows exist, and forget the team-less one explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oker credential pairing Three autoreview findings on the forget/revoke path, each with a failing regression test. This commit adds only the tests plus the inert API surface they reference; the behavior fixes land in the next commit so CI goes red then green. A. removeExactScope reuses the nil local team for the backup tombstone, so a team-less row forgotten under a selected team routes its backup delete to whatever team is selected at flush time (can wipe the wrong team's backup). New removeExactScope(...backupTeamID:) surface (default forwards to the 4-arg, so behavior is unchanged until BackingUp overrides it next commit). B. forgetHiddenComputer pins the revoke to the LIVE session account instead of the row's owning account, so a row left on screen after an account switch can revoke the new account's binding. Test only; the fix is a one-line arg change. C. The broker reads access and refresh tokens through two independent snapshot calls; a force refresh between them pairs a stale access token with a rotated refresh token. New CmxIrohBrokerCredentials + credentialPair surface (unused by performRequest until next commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…redential pairing Behavior fixes for the three autoreview findings; the failing tests from the prior commit now pass (CI red -> green). A. BackingUpPairedMacStore.removeMirroring now takes a separate `backupTeam` scope: the local row still deletes under `team` (nil stays nil), but the backup tombstone routes to `backupTeam`. The new removeExactScope(...backupTeamID:) override supplies the captured display team, and MobileShellComposite's forget passes `displayScope.teamID`, so a team-less row forgotten under a selected team tombstones the right per-team Durable Object instead of whatever team is selected at flush time. B. forgetHiddenComputer pins the revoke to `computer.stackUserID ?? scope.userID` (the row's owning account) instead of the live session, so the runtime forget's generation/account check fails closed when a stale row is forgotten after an account switch, rather than revoking the new account's binding. C. CmxIrohTrustBrokerClient.performRequest prefers tokenSource.credentialPair (both tokens from one snapshot) over the two independent closures, and MobileIrohRuntimeComposition supplies a credentialPair closure that captures one authenticatedSessionSnapshot under the same generation/account pinning. A force refresh mid-request can no longer pair a stale access token with a rotated refresh token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eat-iroh-release-closeout
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
This was referenced Jul 29, 2026
azooz2003-bit
added a commit
that referenced
this pull request
Jul 30, 2026
…l-order challenge mints (#9196) Two verified findings from the structured review of 1472990 (#9071): 1. Upgrade-path device identity rotation (iOS). resolveDurableDeviceID's .absent branch deleted the legacy UserDefaults device-id mirror and minted a fresh id. On an in-place upgrade from a pre-Keychain build the mirror IS the id of the phone's active iroh binding and the endpoint identity survives the upgrade, so registration targeted a new (user, device, tag) slot while the endpoint still owned the old one -> endpoint_already_bound, iroh disabled for every upgrading install. The mirror could not be adopted blindly because encrypted backups restore UserDefaults onto different hardware. Disambiguate with ThisDeviceOnly evidence: the iroh endpoint-identity Keychain item (kSecAttrAccessibleAfterFirstUnlock- ThisDeviceOnly, non-synchronizable) cannot cross hardware, so its presence proves same-device continuation -> adopt the mirror via createOrAdopt; absence -> mint as before; unreadable (locked) -> fail closed and defer, mirroring the store's own .unavailable behavior. New SameDeviceEvidence probe + full matrix tests. 2. Challenge ordering tie (web). The register gate rejects only strictly-older challenges (createdAt < registeredAt) and createdAt is a millisecond wall clock, so serialized mints could tie; a delayed older twin then passed the gate and could clobber newer state. issueChallenge now assigns each challenge a createdAt strictly above the slot's latest prior challenge (mints serialize under the per-user advisory lock), making the strict gate exact. Regression test covers the equal-millisecond reversal. Verification: CmuxMobileShell DeviceRegistry suites 41/41 (full suite has 2 pre-existing failures on main, unrelated: terminalReplay/staleReplay); web typecheck clean; iroh-route-handler + trust-broker suites 41/41; the new db regression test runs under CMUX_DB_TEST=1 in CI. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 31, 2026
Merged
This was referenced Jul 31, 2026
Merged
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.
Combines the verified Iroh release-closeout stack from PRs 8781, 8819, 8883, 8888, and 8930.
Includes:
Verification completed locally:
Pending before merge:
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Completes the Iroh release closeout: re‑keys server slots to (user, device, tag), adds close‑cause and path diagnostics, ships durable iOS device IDs with “Forget computer,” hardens reconnects and macOS host recovery, pins the release gate to the foreground Mac with stable readiness and long‑run workspace re‑acquire, keeps authenticated discovery available during optional firewall outages, supports exact Simulator targeting by identifier, isolates the release‑gate terminal observation so it no longer displaces the mounted terminal sink, and suspends all automatic relay renewals during the gate probe.
New Features
CmxIrohBrokerCredentialsto avoid mixed‑token races.UserDefaults, defers activation when unavailable, converges under races, repairs corrupt items, and ignores restored legacy mirrors over the Keychain id; “Forget computer” revokes by captured owner scope, routes the backup tombstone to the captured display team, preserves exact backup scope across mid‑revoke team switches, pins to the row’s owning account, and alerts on failure; Simulator launch seeds a deterministic durable device id; durable identityUserDefaultsaccess is isolated behind a sendable owner; reconnects reject stale clients before any mutation.CmuxIrohTransportandCMUXMobileCore.Migration
Written for commit 2f2ed90. Summary will update on new commits.
Summary by CodeRabbit