Skip to content

Release v1.13.0 - #3

Merged
lawrencecchen merged 1 commit into
mainfrom
release/v1.13.0
Jan 30, 2026
Merged

lawrencecchen merged 1 commit into
mainfrom
release/v1.13.0

Conversation

@lawrencecchen

Copy link
Copy Markdown
Contributor

Summary

  • Bump version to 1.13.0

Changelog

Added

  • Customizable keyboard shortcuts via Settings

Fixed

  • Find panel focus and search alignment with Ghostty behavior

Changed

  • Sentry environment now distinguishes between production and dev builds

@vercel

vercel Bot commented Jan 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmuxterm-docs Ready Ready Preview, Comment Jan 30, 2026 5:39am

@lawrencecchen
lawrencecchen merged commit 46eefc7 into main Jan 30, 2026
4 checks passed
@lawrencecchen
lawrencecchen deleted the release/v1.13.0 branch January 30, 2026 05:40
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
lawrencecchen added a commit that referenced this pull request May 14, 2026
macOS 26.2 SDK marks NSAccessibilityBlockQuoteLevelAttribute and
NSAccessibilityVisitedAttribute as introduced in macOS 26.0. Chromium's
deployment target is macOS 11.0, so they fire
-Werror,-Wunguarded-availability-new in ax_inspect_utils_mac.mm's
IsValidAXAttribute(), which builds an NSSet of all known accessibility
attribute names at file scope.

@available cannot gate entries inside an array literal in Objective-C++,
so the pragmatic fix is a local clang diagnostic suppression around the
function. This code is dev-tools / inspection plumbing on the
content_shell path, not on cmux's critical path.

Wired into apply-patches in scripts/chromium-build-host.sh; the patch
file documents the source-of-truth diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
hhsw2015 pushed a commit to hhsw2015/cmux that referenced this pull request May 22, 2026
…-ai#2 manaflow-ai#3 + tests)

Three real bugs from the review of bc76ffe, all fixed in one
go since they touch the same per-binding state machine:

manaflow-ai#1 — HerdrTabRegistry.remove now calls
HerdrInboundLayoutSync.forgetBinding (and HerdrDividerSync.reset).
The throttle/suppress commit added three static maps keyed by
binding.rootCmuxPaneId (lastDividerApplyAt, pendingDividerSpec,
dividerApplyScheduled, plus the new pendingResizeRetries / per-
binding suppression state) — none of them were cleared on
teardown. A future binding reusing the same UUID inherited stale
throttle timestamps and could have applied dividers from a tree
that no longer existed.

manaflow-ai#3 — Suppression is now per-binding instead of one global static.
inboundApplyActiveByBinding + inboundApplySuppressUntilByBinding
are keyed by binding.rootCmuxPaneId; shouldSuppressOutboundResize
takes an optional binding key (nil falls back to the global
aggregate, used by the legacy getter and the unresolved-binding
defensive path in forwardPanelSize). HerdrPanelOpener.
forwardPanelSize resolves the binding key for the panel via
HerdrTabRegistry.binding(forCmuxPaneId:) and consults the
per-binding state. A drag in workspace A no longer suppresses
legitimate user-driven cmux window resizes in workspace B.

manaflow-ai#2 — When forwardPanelSize gets suppressed, it now hands a retry
closure to markPendingResize. After the trailing window closes we
re-fire the latest pending retry per panel so the user-driven
resize is not silently dropped. Each markPendingResize schedules a
flush Task that waits inboundApplySuppressTrailingMs + 50ms; if
the suppression window has been re-armed by another apply, the
flush no-ops and the next apply schedules another. forgetBinding
also drains the pendingResizeRetries dict so a torn-down panel
doesn't leak its closure.

Tests:

- testPerBindingSuppressionDoesNotBleedToOtherBindings — locks
  contract that workspace A's drag doesn't suppress workspace B.
- testForgetBindingClearsSuppressionState — locks the teardown
  cleanup that manaflow-ai#1 was missing.
- testPendingResizeFiresAfterSuppressionReleases — locks that a
  suppressed user resize is re-fired exactly once after the
  trailing window expires.

Test seam `_withInboundApplyActiveForTesting` now accepts an
optional bindingKey so the per-binding-scoping test can drive
both the global and per-binding state from one helper.

Open items from the review NOT addressed in this commit:

- Throttle still has no test (reviewer manaflow-ai#7). Next.
- 33ms/250ms hardcoded constants undocumented (reviewer manaflow-ai#10).
- The trailing window deadline can shrink across re-entrant apply
  boundaries on wall-clock backwards step (reviewer manaflow-ai#5). Use a
  monotonic clock; deferred.
- Bandwidth premise still unproven (reviewer A). Need
  instrumentation pass to confirm SSH-master is actually the
  bottleneck before claiming this commit "fixes" the symptom.
hhsw2015 pushed a commit to hhsw2015/cmux that referenced this pull request May 27, 2026
- mirror() failure paths past tab.create now fire tab.close to roll
  back the orphan daemon Tab. Covers panes.list / wirePanel / missing
  binary / workspace-deallocated branches. (CRITICAL manaflow-ai#1)
- closeMirroredLayoutTab populates HerdrCloseHandler.suppressNextCloseFor
  for every herdr pane in the binding before firing tab.close, so the
  surrounding synchronous removeTopLevelLayoutTab pane closes don't
  echo pane.close RPCs against an already-killed window. (CRITICAL manaflow-ai#2)
- mirror() captures workspace weakly across Task.detached + MainActor
  hops; bails with workspaceDeallocated if the workspace died mid-RPC.
  workspaceUUID kept by-value for log lines. (HIGH manaflow-ai#3)
- closeMirroredLayoutTab clears lastSentTabRenames for the binding
  key so the rename throttle cache can't grow unbounded across long
  sessions. (HIGH manaflow-ai#4)
- mirror() retries panes.list up to 5 times with backoff so we don't
  race tmux's async pane materialization right after new-window.
  (MEDIUM manaflow-ai#7)
- translate_tab_create switches -d insertion from positional insert(1)
  to push() — tmux accepts flags in any order, and push() is robust
  against future argv-prefix reordering. (MEDIUM manaflow-ai#6)
- New Rust unit tests cover tab.create default focus, focus=false,
  name/cwd args, tab.close kill-window, tab.rename. (MEDIUM manaflow-ai#8)

Build OK, ForkRegressionTests 9/9 pass, Rust tab_translator_tests
5/5 pass.
austinywang added a commit that referenced this pull request May 31, 2026
Running the dev build (the unit tests never instantiate the editor, so CI
never caught these) surfaced three distinct crashes opening a code file in
the TreeSitter-highlighted preview on macOS 26:

1. HighlightedEditorBridge.prepareCoordinator force-unwrapped
   controller.scrollView, but TextViewController only assigns that IUO in
   loadView(); prepareCoordinator runs from inside init, so it was nil ->
   crash. Defer the event-monitor install to the next runloop tick.

2. makeSyntaxTheme handed CodeEditSourceEditor catalog/dynamic/.clear colors
   (.textColor/.textBackgroundColor). MinimapView.setTheme /
   ReformattingGuideView read theme.background.brightnessComponent without a
   colorspace conversion, which traps for those colors -> crash. Resolve
   theme colors to concrete sRGB up front (extracted resolvedThemeBackground
   for test coverage).

3. CodeEditSourceEditor's LineFoldCalculator built `lowerBound..<upperBound`
   ranges where upperBound < lowerBound (fold positions desynced from the
   document length), trapping on the invalid Range. Fixed in a fork
   (manaflow-ai/CodeEditSourceEditor @ c8bcf197, clamps the upper bound) and
   repointed the SwiftPM dependency; can't be disabled via editor config.

Verified at runtime: the tagged dev build now opens .swift/.py/.md previews
without crashing. Adds a regression test for the theme-color (brightness)
safety; #1/#3 are guarded by the deferred install and the forked clamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Jun 14, 2026
… polish

- #4 (toggle for agents launched mid-session): the Mac now adopts a detected
  agent the instant its terminal title becomes the agent's (AgentChatTranscriptService
  observes .ghosttyDidSetTitle and calls TerminalController.adoptDetectedAgentSessions),
  so the session registers and pushes to the phone live, not only on next open.
- #1 (scroll-to-bottom no longer dismisses keyboard): the dismiss tap excludes
  the button's frame (ChatScrollButtonFramePreferenceKey + excludedRegion).
- #3 (smooth scroll): the button does a single animated proxy.scrollTo to the
  bottom anchor instead of stacked non-animated jumps.
- #5 (toggle eases in): the session-list update is wrapped in withAnimation.
- #2 (cramped grouping): intraGroupSpacing 2 -> 5 so a code block isn't flush
  against the next message bubble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Jun 21, 2026
* Fix QuickLook preview crash on deactivated QLPreviewView

QLPreviewView aborts the process when a non-nil preview item is assigned
after AppKit has deactivated the view (it leaves the window hierarchy):

    [QL] -[QLPreviewView setPreviewItem:blockingUntilLoading:timeoutDate:transition:]:
    item == nil || _reserved->internalState != QLPreviewDeactivatedInternalState

SwiftUI keeps the representable's NSView mounted across tab switches,
visibility toggles, and panel reuse, then re-runs configure() ->
previewView.previewItem = ... on a view AppKit already deactivated. This
is the still-recurring crash from #4453: dropping close() and adding
dismantleNSView (its fix directions #2/#3) prevented cmux-initiated reuse
but not system-initiated deactivation, so the abort still fires on
macOS 26 (Tahoe).

Implements fix direction #1: host the QLPreviewView inside a stable
container view (matching the PDF/image session pattern) and swap in a
fresh preview view once the previous instance has detached from its
window, so a non-nil item is never assigned to a deactivated view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Fu21KvnjR3dAe6sjKaVUw

* Update Quick Look session tests for container view

* Extract Quick Look preview host views

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Aziz Albahar <abdulaziz@albahar.net>
arzafran referenced this pull request in darkroomengineering/programa Jun 23, 2026
Decision #3 flip: we fork ghostty to land engine-side fixes (6/8 triaged
fixes are in ghostty/src). Submodule stays pinned at the same SHA; only the
origin URL moves to our fork (forks share objects, so the pin resolves).
ejc3 added a commit to ejc3/cmux that referenced this pull request Jul 15, 2026
…aflow-ai#3)

On a scale change (e.g. dragging a mirror pane between a 1x and a 2x
display) the reported cell metrics are still at the OLD backing scale —
set_content_scale runs later in updateSize. Pinning the old cell px
pinned ~half the columns on a 1x->2x move, and forcing wpx to the old
backing width made sizeChanged false, defeating deferScaleUntilResized so
the grid collapsed when the bigger cell landed over the un-resized screen.
Project the reported cell and pad to the scale this resize is about to
apply; the ratio is 1 when the scale is unchanged, so it is a no-op then.
azooz2003-bit added a commit that referenced this pull request Jul 26, 2026
… 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>
azooz2003-bit added a commit that referenced this pull request Jul 29, 2026
* test(iroh): expose reconnect outage gaps

* fix(iroh): keep reconnects alive through outages

* fix(ios): signal reconnect deadlines without sleeping

* test(iroh): cover close attribution diagnostics

* feat(iroh): attribute connection closes and path events

* iroh: re-key binding slot to (user, device, tag), newest-auth-wins

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>

* iOS: stable Keychain device id + Forget computer (iroh re-key client)

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.

* iroh: mint new binding id on endpoint rotation, add active-binding sanity 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>

* iOS: fail closed on unreadable device id, alert on Forget failure, pin 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>

* Recover the Mac iroh host runtime from terminal failure without relaunch

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>

* iroh: harden binding re-key against ABA wedge, LAN staleness, and cap 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>

* iOS: harden iroh re-key client per review (device-id, session snapshot)

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>

* iOS: failing test — forget of team-less Mac deletes wrong team on mid-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>

* iOS: forget deletes the exact captured scope, not the live team

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>

* iOS: converge device identity under races, gate snapshot during token 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>

* iroh: reject over-cap registrations, gate stale challenges, document 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>

* iroh: map active-slot unique violation to typed 409

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>

* iOS: correct forget-scope regression test to genuinely catch mid-revoke 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>

* iOS: forget deletes the exact captured team scope, no visibleScope re-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>

* iOS: break corrupt-Keychain mint deadlock; move in-memory device store 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>

* iOS: hidden-computer unhide spinner tracks its own task, not forget's

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>

* iroh: add failing test for reversed heartbeat completion

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>

* iroh: advance registration high-water mark on heartbeat; pin sanity cap 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>

* iroh: add failing test for reversed challenge completion on a fresh slot

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>

* iroh: seed insert high-water mark from challenge mint time

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>

* iOS: failing tests for forget deleting wrong paired-Mac scope

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>

* iOS: forget deletes each pairing's own captured scope, not the live display 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>

* iOS: failing tests for forget backup-team routing, revoke pinning, broker 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>

* iOS: fix forget backup-team routing, revoke account pinning, broker credential 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>

* fix(iroh): harden lifecycle and close attribution

* test(iroh): decode Effect failures through public API

* test(ios): require stable simulator device identity

* fix(ios): seed simulator Iroh device identity

* test(iroh): accept unscoped workspace events in rollover gate

* fix(iroh): validate fresh rollover events by topic

* test(iroh): cover release closeout regressions

* fix(iroh): preserve trusted connection recovery

* test(iroh): cover redaction and binding cap semantics

* fix(iroh): harden release lifecycle boundaries

* fix(iroh): clear retry inspection on scope exit

* test(iroh): reproduce multi-Mac release gate targeting

* fix(iroh): pin release gate to foreground Mac

* fix(ios): isolate durable identity defaults safely

* test(iroh): reproduce release gate readiness race

* fix(iroh): require stable gate readiness

* test(ios): reproduce stale reconnect client clobber

* fix(ios): reject stale reconnect before client mutation

* test(ios): reproduce restored identity and backup scope leaks

* fix(ios): preserve device and backup scope identity

* chore(iroh): adopt continuous relay token handoff

* test(ios): cover exact release-gate simulator targeting

* fix(ios): target release gate simulator by identifier

* test(ios): reproduce release gate output sink displacement

* fix(ios): isolate release gate terminal observation

* test(ios): reproduce stale release gate workspace identity

* fix(ios): reacquire long-lived release gate workspace

* test(ios): cover complete relay refresh suspension

* fix(ios): suspend every automatic relay renewal lane

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Jul 30, 2026
…#8888)

* iOS: stable Keychain device id + Forget computer (iroh re-key client)

Client complement to the broker binding re-key (manaflow-ai/cmux#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.

* iOS: fail closed on unreadable device id, alert on Forget failure, pin 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>

* iOS: harden iroh re-key client per review (device-id, session snapshot)

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>

* iOS: failing test — forget of team-less Mac deletes wrong team on mid-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>

* iOS: forget deletes the exact captured scope, not the live team

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>

* iOS: converge device identity under races, gate snapshot during token 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>

* iOS: correct forget-scope regression test to genuinely catch mid-revoke 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>

* iOS: forget deletes the exact captured team scope, no visibleScope re-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>

* iOS: break corrupt-Keychain mint deadlock; move in-memory device store 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>

* iOS: hidden-computer unhide spinner tracks its own task, not forget's

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>

* iOS: failing tests for forget deleting wrong paired-Mac scope

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>

* iOS: forget deletes each pairing's own captured scope, not the live display 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>

* iOS: failing tests for forget backup-team routing, revoke pinning, broker 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>

* iOS: fix forget backup-team routing, revoke account pinning, broker credential 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>

* iOS: failing test — session snapshot pairs stale access with rotated refresh

authenticatedSessionSnapshot() reads the access and refresh tokens through
two separate awaits (currentTokens()), so a concurrent force refresh can
rotate the pair between them and hand the broker an old access token with a
new refresh token. Neither snapshot guard trips on a plain token rotation.
The test scripts that torn store state and asserts the snapshot returns the
access minted for the captured refresh, not the stale stored access.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* iOS: session snapshot derives access from the captured refresh token

authenticatedSessionSnapshot() now reads both tokens through consistentTokenPair(),
which captures the refresh token once and mints the access token FOR that exact
refresh via freshAccessToken(accessToken: nil, refreshToken:). The returned access
always belongs to the returned refresh, so a concurrent forceRefreshAccessToken()
can no longer hand the iroh broker an old access token paired with a rotated
refresh token. currentTokens() is unchanged for its broader callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* iOS: failing test — forget routes backup tombstone to display team

A team-less row's backup was uploaded under the row's own (nil) team scope,
but forgetting it routes the tombstone to whatever team it happened to be
displayed under. The tombstone lands in the wrong per-team backup scope: the
row's real backup survives (and a restore under the row's own scope can
resurrect the forgotten row), while a same-device record in the displayed
team's backup can be wrongly deleted.

Replaces the previous test, which asserted the display-team routing as the
desired behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: route forget backup tombstone to the row's own team scope

The forget path routed the backup delete to the team the row was displayed
under. For a team-less row that team is arbitrary (legacy visibility shows it
under every selected team), while upsert stamps the row and uploads its backup
under one resolved team, so the row's own team_id is the only client-side value
tied to where the backup lives. Display-team routing also split the pending-
delete lifecycle across two scopes: the tombstone was written and flushed under
the display team's outbox scope, but a restore under the row's own (team-less)
scope never saw it and could resurrect the forgotten row locally.

Route the tombstone to the row's own captured team, the same scope the backup
was uploaded under, keeping outbox key, local apply, flush, and restore-
suppression on one scope. This removes the removeExactScope(backupTeamID:)
variant entirely; the 4-arg exact-scope delete already carries the row's own
team.

Residual: a row uploaded while no team was selected client-side had its backup
scope resolved server-side, and that resolution is not echoed back or persisted,
so no client-only routing can name that scope with certainty. The symmetric nil
route re-resolves through the same server path as the upload. Persisting a
server-echoed backup team is a cross-stack follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing test — pending-delete replay deletes a surviving sibling row

A forget whose backup upload fails leaves its tombstone in the outbox; the
next read replays it through the broad remove path. TeamScopedPairedMacStore's
remove re-resolves the device under the scope's team, which also returns
team-less legacy rows, so with the exact row already deleted locally the
replay resolves a SURVIVING unrelated alias of the same device and deletes
it — the exact over-deletion the exact-scope forget path exists to prevent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: replay pending backup tombstones through the exact-scope delete

A pending tombstone names one exact pairing and its outbox scope key pins the
exact (account, team) it was deleted under, so the replay's only job is to
finish or confirm that one deletion. Replaying through the broad remove
re-resolved visibility on the way down: TeamScopedPairedMacStore looks the
device up under the scope's team (which also returns team-less legacy rows)
and the build-scope decorator's broad remove drops its team-less fallback
alias. In the common failed-upload case the exact row is already deleted, so
the broad replay resolved a surviving unrelated alias of the same device and
deleted it.

Replaying via removeExactScope is a no-op there and, after a crash between
the tombstone write and the local delete, removes exactly the named row.
Residual: a crash-interrupted BROAD remove now replays exact too, so a
team-less build-fallback alias can outlive that narrow window in dev builds;
it resurfaces visibly and the next hide drops it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing test — wildcard forget leaves the device's sibling rows saved

A row with no instance tag cannot name its broker binding, so forgetting it
revokes EVERY binding for the device. The local cleanup deleted only the
exact nil-tag row, leaving the device's coexisting tagged rows saved locally
while their bindings were just revoked: dead entries that resurface in the
computer list until the Mac happens to re-register. A tag-known forget stays
narrow on both sides (second test, passing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: match wildcard forget's local cleanup to its revoke breadth

A tag-less row cannot name its own broker binding, so forgetting it revokes
every binding of the device for the pinned account. Local cleanup deleted
only the exact nil-tag row, stranding the device's coexisting tagged rows as
dead entries whose bindings were just revoked. After the wildcard revoke the
forget now also deletes the device's tagged sibling rows visible in the
captured display scope and owned by the pinned account, each through the same
exact-scope removal as the primary row. Tag-known forgets stay narrow on both
sides. Rows in other teams' scopes are not enumerable through the scoped
store rail and self-heal when the Mac re-registers; rows owned by other
accounts keep their live bindings and survive.

Closes https://github.com/manaflow-ai/cmux/issues/9078.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing test — forget mints a Stack token for every broker leg

The forget flow captures one coherent session snapshot up front, but the
broker token source re-snapshots on every request, and each snapshot now
mints a fresh access token over the network. Discovery plus every sequential
revoke each add a Stack round-trip, so forgetting a computer with many
bindings can stall for minutes and fail during a Stack outage even though
the pinned credentials in hand are valid. The test drives a forget across
four broker legs through a broker fake that fetches one credential pair per
request, exactly like the real client, and expects a single mint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: reuse the forget's pinned credential pair for every broker leg

The forget captures one coherent session snapshot up front; the broker token
source now returns that pinned pair after only the cheap local session check
(generation + account), instead of re-capturing a snapshot per request. Each
snapshot performs a network token mint, so the old path added a Stack
round-trip for the discovery and for every sequential revoke: forgetting a
computer with many bindings could stall for minutes and fail during a Stack
outage despite holding valid credentials. The pinned pair is coherent by
construction, and the access token always travels with its refresh token, so
the server can re-mint server-side if it expires mid-operation. A mid-forget
sign-out or account switch still fails the check and yields nil, so the
revoke fails closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing test — tombstone ignores the server-reported backup team

A team-less row uploads with a nil team and the SERVER resolves which
per-team Durable Object stores it; that resolution is not derivable
client-side and can drift by the time the row is forgotten. The new
uploadReportingResolvedTeam seam (default: echo unknown) lets a transport
report the verified team an upload was stored under; the failing test shows
the backing-up store discards the echo and re-resolves nil at delete time, so
the tombstone can land in a different team's backup than the record it is
meant to delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: route delete tombstones to the server-reported backup team

A team-less row uploads with a nil team and the presence worker resolves which
per-team Durable Object stores it. That resolution is not derivable
client-side and can drift by the time the row is forgotten, so re-resolving
nil at delete time could send the tombstone to a different team's backup: the
forgotten Mac's record survived and restored later, and a same-device record
in the wrong team could be deleted.

The worker now echoes its verified resolved team in the backup POST and GET
responses (from the DO, which receives the verified value). The client
persists the echo per pairing in a UserDefaults-backed map owned by the
backing-up store, and the tombstone flush groups pending deletes by each
pairing's persisted backup team (falling back to the scope's own team when no
echo was ever seen), uploading each group to the backup its records actually
live in. A flushed pairing's mapping is dropped with its backup record.
Legacy rows converge on their next successful upload; restores still fetch
the live scope (read-path residual, benign).

Closes https://github.com/manaflow-ai/cmux/issues/9076.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — restore drops the backup-team echo; wildcard forget refreshes per sibling

Two gaps in the round-4 fixes. Restored rows never pass through the upload
path, so the reinstall case (empty mapping store, rows arriving via restore)
loses the server's statement of where their backups live: a later forget
re-resolves nil and the wrong-backup deletion returns for exactly the restored
rows. The snapshot now carries the worker's echoed resolved team so the
restore can persist it. And the wildcard forget's cleanup refreshes the paired
list per deleted sibling, re-running the backup restore fetch each time — up
to the 256-binding snapshot limit of sequential round-trips for one tap; the
new test pins the whole cleanup to at most one refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: persist the restore snapshot's backup team; batch wildcard cleanup

The restore path now records the worker's echoed resolved team for EVERY live
record in the snapshot (not just locally-written ones — each record lives in
that team's backup regardless of the local merge outcome), so a row restored
after a reinstall and forgotten later routes its delete tombstone to the
backup it actually lives in instead of re-resolving nil at delete time.

The wildcard forget now deletes all of the device's rows first and runs ONE
refresh (paired list + registry + reconnect hint) after the batch, instead of
reloading per deleted sibling — each per-row reload also re-ran the backup
restore fetch because the removal clears the restore memo, so a forget
covering many bindings issued that many sequential network round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iroh: make the coherent credential pair the broker token source's only input

CmxIrohBrokerTokenSource previously accepted independent access and refresh
closures with the coherent pair optional. Several production constructions
(iOS reconcile/quarantine paths, macOS host activation) omitted the pair, and
their two closures each called auth.currentTokens() separately, so a session
transition between the two reads could assemble one session's access token
with another's refresh token and fail registration, discovery, or revocation.

The pair closure is now the ONLY construction input, so a two-source token
assembly is no longer expressible; the single-token accessors are derived from
the pair. Every construction site provides a coherent capture: pinned-session
pairs for the forget flow, pairs captured together up front for sign-out
revokes, and a single currentTokens() call per fetch for the runtime paths.
The performRequest legacy two-closure branch is gone. No new regression test:
the removed hazard is inexpressible at compile time, and
CmxIrohBrokerCredentialPairTests keeps asserting each request performs exactly
one atomic capture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-5 review findings

A wildcard forget must delete the device's same-account rows in OTHER teams
(their bindings were revoked account-wide and an offline Mac cannot re-register
to self-heal); the activation broker's credentials must fail closed after an
account switch instead of vending the new session's tokens against the old
activation; and a legacy device-id whose Keychain migration cannot persist is
NOT durable (a reinstall wipes the only copy and strands the slot). Supersedes
the adopt-legacy-despite-failed-persist test and the scope-flip test's
sibling-survives assertion, both of which pinned the rejected contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: pin activation credentials; cross-team wildcard cleanup; defer non-durable legacy id

Round-5 review fixes. The activation path now captures one coherent session
snapshot, verifies it belongs to the activating account, and pins the broker
token source to it (same helper as the forget path): a mid-activation account
switch makes every later leg fail closed instead of mutating the new account's
broker state against the old activation's endpoint identity.

Wildcard forget cleanup now enumerates the device through a new cross-team
loadAllInstances seam on the paired-Mac store rail — the team-scoping decorator
forwards it verbatim (its live-team substitution is exactly what the cleanup
must see past), the build-scope decorator bounds it to its own build scope, and
the backup decorator forwards without triggering a restore. Every same-account
row of the device is deleted by its own exact scope, matching the account-wide
revoke.

DeviceRegistryService no longer reports a legacy UserDefaults id as durable
when the Keychain migration write fails: the store was readable (id absent) but
nothing durable holds the id, so binding activation defers and retries instead
of registering a slot a reinstall would strand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-6 review findings

A valid stored access token must be reusable without a network mint (forcing a
mint made the session snapshot, and with it broker activation, fail offline
despite a usable stored pair); and the persisted backup-team echo must be keyed
by the row's own team — the local store deliberately allows the same (account,
device, tag) pairing under several teams, so a team-agnostic key let team B's
upload overwrite team A's destination and route A's tombstone into B's backup.
Fixture fakes gain the SDK's likely-valid reuse semantics; the forget test's
mint expectation drops to zero accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iroh: store-level coherent pair, per-request pinned activation source, keyed echo, forget deadline

Round-6 review fixes, one architectural piece plus three scoped ones.

coherentTokenPair() replaces the always-minting snapshot read: capture the
refresh token, resolve a usable access token FOR it (the SDK reuses a valid
stored access without the network and mints only otherwise), then re-read the
refresh — an unchanged refresh proves no rotation crossed the window, a changed
one retries. It runs inside the coordinator's bounded token-touching phase.
The session snapshot, the iOS quarantine-recovery source, and the macOS host
activation source all read through it, so no torn two-await assembly remains
and an offline launch with a valid stored pair succeeds.

Activation no longer freezes an activation-time pair for the runtime's
lifetime (ordinary force-refresh rotation does not bump the session
generation, so a frozen pair went stale and stranded relay refresh and
discovery until an unrelated reconcile). The activation gate is now a cheap
local identity check — no token read, so offline activation still reaches the
cached relay/offline-policy recovery — and every broker request re-checks the
account/generation pin and re-reads a coherent pair from the store.

The backup-team echo mapping key now includes the row's own team, and the
forget revoke loop gets a 60-second operation deadline (deadlineExceeded
surfaces the failure; applied revokes stand and a retry re-discovers what
remains) instead of up to 256 sequential broker timeouts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-7 review findings

An ordinary same-account foreground revalidation must not advance the session
generation (every generation-pinned broker source would starve after the first
foreground), and a UserDefaults device-id mirror must never be adopted when the
Keychain authoritatively reports the id absent — the mirror travels in device
backups onto NEW phones while the ThisDeviceOnly Keychain item does not, so
adoption would make two physical devices fight over one (user, device, tag)
slot on every phone upgrade. Also pins persist-and-reuse of refreshed access
tokens across repeated coherent captures (contract coverage: the ephemeral
side-store defect is not expressible through the fake), and reworks the fakes
to model the live store's stale-refresh-persist semantics. Supersedes the
legacy-mirror-adoption migration test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iroh: round-7 identity and credential lifecycle fixes

Same-account revalidation no longer bumps the session generation: the bump
now happens only on a genuine transition (signed-out -> signed-in, or a
different account), so generation-pinned broker sources survive ordinary
foreground returns while sign-out/sign-in still fences stale flows.

The device id is minted fresh when the Keychain authoritatively reports it
absent, never adopted from the UserDefaults mirror (which migrates in phone
backups and would collide two physical devices onto one binding slot); the
mirror remains trusted only while the Keychain is temporarily unreadable.
This deliberately drops the seamless pre-Keychain upgrade migration — a
one-time re-pair for existing installs — to prevent a permanent cross-device
identity collision on every phone upgrade.

The coherent pair now resolves the access token through the LIVE store inside
the refresh bracket, so a stale token is refreshed once, persisted, and
deduplicated by the SDK instead of re-minted per capture through an ephemeral
side store. The long-lived activation source reads a full authenticated
snapshot per request (atomic identity+credential capture, transition-checked)
validated against the activation pin, closing the check-then-read race. Both
credential containers get redacted descriptions so reflection cannot copy
live tokens into logs or crash reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-8 review findings

An in-place upgrade (Keychain absent, mirror holding the id the live binding
already uses, no witness recorded) must ADOPT the mirror — minting there
changes every existing installation's identity once and strands all of their
bindings. A mirror whose recorded device witness belongs to ANOTHER phone (a
restored backup) must still mint fresh, and a witness matching this phone
adopts. These pin the provenance mechanism that separates the two cases the
last two rounds traded against each other. (The tests reference the new
witness parameter, so this commit is red at compile time without the fix.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iroh: device-witness provenance for the id mirror; pin the macOS broker source

The UserDefaults device-id mirror now carries a per-device witness
(identifierForVendor — a value a restored phone does not inherit), written on
every mirror update. On authoritative Keychain absence the mirror is adopted
only when the witness proves it was recorded on THIS device or predates the
mechanism (the in-place upgrade population, whose mirror holds the id their
live binding already uses); a mismatched witness means a backup restored onto
another phone, which mints fresh so two physical devices never share one
(user, device, tag) slot. The locked-Keychain fallback applies the same test.
Residual: restoring a PRE-witness backup onto a new phone is indistinguishable
from an upgrade and adopts — bounded to backups taken before this ships.

The macOS host runtime's broker source now mirrors the iOS one: activation
verifies the live account, captures the generation, and every request reads an
atomic authenticated snapshot validated against that pin, so an A-to-B account
switch fails the old runtime's requests closed instead of registering B's
credentials against A's endpoint state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-9 review findings

A wildcard forget's tombstones must travel in ONE request per destination (a
device can carry 256 bindings, and per-row flushes each burn a request
timeout); a pending tombstone must be visible to restores of its DESTINATION
scope, which must both suppress the deleted record and retry the flush; an
unmapped team-less tombstone must PARK instead of shipping with a guessed nil
team the server would re-resolve from current account state; and a failed
cross-team sibling enumeration is a cleanup failure, not silent success.
Legacy tests that modeled the pre-echo worker now arm the echo; the nil-team
routing test is superseded by the parked contract, and the crash-intent test
becomes the mapping-recovery test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iroh: destination-keyed tombstone outbox, batched wildcard flush, propagated enumeration failure

Round-9 review fixes.

Pending backup tombstones are now keyed by their DESTINATION scope — the team
whose Durable Object actually holds the record (the persisted echo, else the
row's own concrete team) — with the row's LOCAL team encoded in each record
for exact local replay. A restore of the destination therefore both suppresses
the deleted record while its upload is pending and retries the flush, closing
the resurrect-and-never-retry gap of local-scope keying. A team-less row with
NO verified destination is parked under the nil-team scope and never uploaded
with a guessed nil team; parked intents migrate to their destination and flush
once a restore's echo recovers the verified mapping. Legacy single-field
records decode as local==scope, preserving old outboxes. Residual, documented
in code: while parked, a restore of a different team's scope cannot see the
intent and may resurrect the record there; re-forgetting that row routes
exactly, which is recoverable — unlike a misrouted destructive delete.

removeExactScopes batches several rows: local deletes and outbox writes first,
then ONE tombstone flush per destination, replacing the per-row flush that
gave a wildcard forget up to one network round-trip per row. The composite
deletes the primary and all wildcard siblings through one batch and clears
markers only after it succeeds, and a failed sibling enumeration now fails the
forget instead of silently claiming success after an account-wide revoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-10 review findings

A TAGGED forget's revoke is also account-wide for that (device, tag) binding,
so same-tag rows in other teams must be cleaned too while different-tag rows
survive; and reviving one team's row must clear only THAT row's pending
tombstone — the destination-keyed outbox can hold same-pairing records for
different local teams, and cancelling them all lets another team's forgotten
record survive in the backup and restore later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: tag-scoped cross-team forget cleanup; revive clears only its own row's tombstone

Round-10 review fixes. Cross-team sibling cleanup now runs for EVERY forget:
a tagged revoke kills the (device, tag) binding account-wide, so other teams'
same-tag rows are dead and get cleaned, while different-tag rows keep their
own live bindings and survive; the tag-less wildcard keeps its every-tag
breadth. And a revive clears only the pending tombstone whose LOCAL team
matches the re-added row — same-pairing records for other local teams in the
same destination stay pending, so their forgotten backup records still get
deleted instead of surviving to restore later. Legacy unscoped records decode
their local team from the scope they sit in and so match only in the re-added
row's own scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-11 review findings

Three confirmed defects, each with a failing test:

- A wildcard forget's exact-scope cleanup silently skips rows whose
  instance tag is incompatible with this build, while the tombstone
  still flushes and the forget reports success; the revoked-binding row
  survives to resurface as a dead entry.
- Forget clears hidden markers only in the display scope; markers are
  stored per (user, team), so another team's marker survives its row's
  deletion and keeps a re-registering Mac unexpectedly hidden there.
- A whitespace-only persisted device identity classifies as .found, so
  the corrupt-item repair deadlocks: the mint path re-reads and adopts
  the same whitespace value and every launch advertises an invalid
  opaque device id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: exact-scope deletes match wildcard breadth; markers and identity repair

Round-11 review fixes:

- The build-compatibility store no longer guards exact-scope deletes.
  An exact-scope delete targets a row the cleanup explicitly captured
  from loadAllInstances, and the broker's wildcard revoke is tag-blind,
  so the local cleanup must cover incompatible tags too; the guard let
  the tombstone flush and the forget report success while the
  revoked-binding row survived. Ambient verbs keep the guard.
- Forget clears each deleted row's hidden marker in that row's OWN team
  scope in addition to the display scope. Markers are stored per
  (user, team); clearing only the display scope left another team's
  marker to keep a re-registering Mac unexpectedly hidden there.
- KeychainDeviceIdentityStore classifies a whitespace-only item as
  corrupt (.absent), so the duplicate-item repair path overwrites it
  instead of endlessly re-adopting it as .found; the in-memory test
  double mirrors the contract, now documented on DeviceIdentityStoring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-12 review findings

- A pre-witness UserDefaults mirror is adopted on authoritative Keychain
  absence with no proof this is the same physical device; a backup taken
  before the witness shipped restores onto a new phone and clones the
  old phone's (user, device, tag) binding slot.
- A concrete-team restore neither suppresses nor resolves a PARKED
  unknown-destination tombstone, so the supposedly forgotten computer is
  resurrected locally and its backup survives every future restore.
- A partially failed batched cleanup still runs the post-forget refresh,
  whose rowless-marker migration clears the deleted primary's hidden
  marker — the retry entry disappears while the failed sibling row keeps
  its already-revoked binding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: continuity-gated mirror adoption; parked tombstones suppress and resolve

Round-12 review fixes:

- Pre-witness mirror adoption now requires device-continuity evidence: a
  non-migrating artifact proving the install continues on this hardware.
  The probe is the iroh endpoint identity — in Release an
  AfterFirstUnlockThisDeviceOnly Keychain item that never travels in a
  backup, and one every install with a live binding necessarily has. A
  restored pre-witness backup on a new phone lacks it and mints fresh
  (no more cloned (user, device, tag) slots); an in-place upgrade with a
  binding has it and keeps its id; an install that never activated iroh
  mints harmlessly. Both production device-id callers pass the same
  probe so concurrent resolutions agree, and the locked-Keychain mirror
  branch defers instead of trusting a possibly-restored mirror.
- Every restore's suppression list now includes the account's PARKED
  (unknown-destination) tombstones, and a verified team's snapshot echo
  resolves any parked intent whose pairing it contains: the mapping is
  recorded under the parked record's own key and the parked scope
  flushes, migrating the intent to its destination and deleting the
  backup. A forget the user was told succeeded can no longer be
  resurrected by the next restore. FakeBackup now honors successful
  delete uploads in its snapshot, mirroring the server.
- The post-forget refresh runs only after COMPLETE cleanup, so a partial
  batch failure keeps the hidden entry as the retry owner instead of
  letting the rowless-marker migration clear it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing test — round-13 review finding

A forget's cleanup enumerates only the LOCAL store, but backups live in
per-team Durable Objects and only the selected team's backup has been
restored on this phone. The same device's records in another team's
backup get no tombstone even though the wildcard revoke killed their
bindings account-wide; switching to that team later restores the
supposedly forgotten computer as a dead entry. FakeBackup gains a
per-team-bucket mode to model the server's per-team storage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: account-wide forget tombstones; device-id resolution off the UI actor

Round-13 review fixes:

- A forget now parks one ACCOUNT-WIDE tombstone per forgotten pairing in
  addition to the routed per-row intents. Backups are per-team Durable
  Objects and only restored teams have local rows, so the local
  enumeration cannot match the broker revoke's account-wide breadth; the
  parked intent suppresses the pairing in EVERY team's restore, each
  verified snapshot that proves its team holds the pairing gets a direct
  delete (a tag-less intent is the device-wide wildcard and matches
  every tag, with the snapshot supplying the concrete tags), and the
  intent persists until a re-pair revives the pairing. Parked intents no
  longer migrate to a single destination — no single team could retire
  an account-wide tombstone.
- Durable device-id resolution moved off the MainActor for activation:
  a private actor captures the identifierForVendor witness with one
  MainActor hop and runs the Keychain reads/writes, defaults mirror, and
  continuity probe on its own executor, restoring the off-UI-actor
  guarantee the merge reconciliation had dropped. DeviceRegistryService
  gains a nonisolated durableDeviceID(defaults:deviceWitness:...) for
  such callers, and currentDeviceWitness() is public.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-14 review findings

- Parked (account-wide) tombstones replay their local delete only when
  the nil-team scope itself is requested, so an offline launch after a
  crash keeps showing the supposedly forgotten computer: crash recovery
  must be network-independent.
- The parked tombstone set retires only on revive and grows by every
  forget forever — unbounded persisted size and per-restore scan work;
  retention must be bounded.

The forget-deadline scope finding (discovery and in-flight broker calls
can suspend past the deadline) is fixed in the same round; it lives in
the iOS-only cmuxFeature target, where no host-runnable test exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: network-independent parked replay, bounded retention, full forget deadline

Round-14 review fixes:

- Both restore entry points now replay the account's PARKED tombstones
  locally before any backup fetch, so crash recovery (outbox written,
  local delete never landed) works offline instead of depending on the
  restore's suppression list reaching the network.
- The parked account-wide tombstone set is bounded at 256 entries
  (matching the discovery wire cap): intents are deduped by identity,
  stamped with a coarse insertion time via an injected clock, and
  evicted oldest-first when over the cap — an evicted intent's forget
  has had the longest time to propagate, and losing one degrades to the
  pre-account-wide behavior for that single pairing. Routed records'
  encodings are unchanged, so exact-string outbox clearing still works.
- The forget deadline now bounds the WHOLE operation: forgetComputer
  races credential capture, discovery, backpressure waits, and every
  revoke against a cancellable sleeper, cancelling in-flight broker work
  at the deadline instead of only checking between revokes; the
  per-revoke clock checks remain as a cheap early exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: fix Swift 6 isolation and stale optional binding in cmuxFeature

Round-15 review findings — both compile errors in the iOS-only targets
(no host-runnable or CI compile covers them, so no regression test is
practical):

- deviceLocalIrohIdentityExists (and its directory helper) are
  nonisolated so the off-main resolver actor's synchronous continuity
  probe closure can call them without a MainActor hop.
- The sign-out test fake still optional-bound credentialPair from
  before it became the token source's only, non-optional input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: forget deadline sleeper becomes static — extensions cannot hold storage

Round-16 review finding: the cancellable sleeper was declared as an
instance stored property inside the extension that hosts the forget
flow, which does not compile. Static storage keeps the bounded-timeout
shape unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing test — round-17 review finding

A completed same-account sign-in (fresh credential exchange while
already authenticated) preserves the session generation, so operations
pinned to the prior session — the forget flow's frozen credential pair,
the activation runtime's pinned source — keep passing the session fence
with the replaced session's authority.

The sibling round-17 finding (the activation path creates the iroh
endpoint identity before the device-id continuity probe checks for it,
so a restored pre-witness backup sees its own moments-old identity as
continuity evidence) is fixed in the same round; it lives in the
iOS-only cmuxFeature target, where no host-runnable test exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: sign-in always advances the session generation; probe before identity

Round-17 review fixes:

- applySignedInUser now takes an explicit SessionPublication reason: a
  completed credential exchange (.signIn) always advances the session
  generation, even for the same account, because the token session was
  replaced and prior-session pins must fail closed; only .revalidation
  (foreground/startup re-checks of the already-published session)
  preserves the generation for the same account.
- The activation path resolves the durable device id BEFORE creating
  the iroh endpoint identity. The continuity probe treats a
  device-local identity as proof the install continues on this
  hardware; creating the identity first handed a phone restored from a
  pre-witness backup its own moments-old identity as evidence and
  adopted the migrated mirror id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: drop @MainActor child annotation the isolation checker cannot verify

The hosted iOS build fails on the forget-deadline task group:
"pattern that the region-based isolation checker does not understand
how to check" at the @MainActor-annotated child. The plain child hops
to the MainActor implicitly at the revokeMatchingBindings call, which
is exactly what the annotation expressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-19 review findings

- The upload echo is keyed by the live display team, but loadAll's
  legacy visibility can match a TEAM-LESS row: the forget then looks the
  mapping up under the row's own nil team, misses it, and parks the
  tombstone — undeliverable when the network is down at echo time.
- A parked delete suspended in its upload can race a concurrent re-pair
  on the reentrant actor: the revive clears the intent and uploads the
  record, the older delete lands after it, and nothing repairs the
  wiped backup.
- A partially failed batch cleanup returns before clearing ANY markers;
  rows deleted before the failure can never be re-enumerated on retry,
  so their per-team hidden markers keep a re-registering Mac hidden.

FakeBackup gains an on-delete-upload hook (to interleave a mutation
inside the uploader's suspension window), record-op application to its
buckets, and a post-construction fetch-failure switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: row-keyed echoes, delete/revive reentrancy fences, narrowed marker cleanup

Round-19 review fixes:

- The upload echo's mapping is keyed by the ROW's stored team
  (mac.teamID), not the live display scope: loadAll's legacy visibility
  matches team-less rows under a selected team, and the forget looks the
  mapping up under the row's own team — a display-keyed echo was never
  found, leaving the tombstone parked and undeliverable offline.
- Both delete uploaders (the concrete-scope flush and the parked echo
  resolver) now fence against the actor's reentrancy: any sent tombstone
  whose outbox record vanished during the upload suspension was revived
  by a concurrent re-pair, so its current local row is re-uploaded — the
  stale delete can no longer silently wipe the just-revived backup. The
  concrete flush also retires only the records it SENT, so intents added
  during the suspension survive to their own flush, and revived records
  keep their freshly re-saved mapping.
- A partially failed batch cleanup clears the markers of rows it DID
  delete — narrowly: only the deleted row's own team key and the
  user-wide key, never the display scope, which the failed scope (the
  retry owner) shares. Rows deleted before the failure can never be
  re-enumerated on retry, so this is the only moment their markers can
  be cleared.

FakeBackup applies record uploads to its per-team buckets only; the
legacy single-bucket mode serves its seeded list to every team, so
applying uploads there would leak one team's mirror into every other
team's restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-20 review findings

- The account-wide parked intent is inserted only AFTER the batch's
  local deletes have awaited; a Mac re-registering during that window
  clears the routed tombstone but cannot clear the not-yet-created
  parked intent, which then suppresses the revived pairing forever.
- The flush retires sent tombstones by set subtraction computed AFTER
  its post-upload awaits; a re-pair plus second forget during those
  awaits re-adds the identical encoded record, which the subtraction
  silently consumes — an undelivered second tombstone loses its retry.
- The persisted backup-team mapping grows without bound: entries retire
  only when THIS device delivers the pairing's tombstone.

Test doubles: a paired-Mac store and a team-mapping store that fire a
one-shot hook inside their suspension windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: park before deletes, atomic flush retirement, bounded team mapping

Round-20 review fixes:

- removeExactScopes resolves accounts and persists the account-wide
  parked intents BEFORE the first local-delete suspension, so a Mac
  re-registering during a delete clears every tombstone covering its
  pairing — routed and parked alike — instead of leaving a stale
  account-wide intent that would suppress the revived pairing forever.
  The parked scope now also dedupes by identity in addPendingDelete and
  applies the same oldest-first cap there, so a row intent never stacks
  a second encoding beside its account-wide twin and single exact-scope
  removes cannot grow the scope unbounded.
- The concrete flush retires its sent tombstones atomically in one actor
  turn right after the upload (synchronous cache read + write), before
  the mapping-cleanup and repair awaits: a re-pair plus second forget
  interleaving those awaits re-adds its identical record AFTER
  retirement and keeps its own retry.
- The persisted backup-team mapping is bounded at 512 entries with
  move-to-newest insertion order and oldest-first eviction; losing an
  evicted mapping degrades that pairing's next forget to the parked,
  echo-recovered path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-21 review findings

- A parked intent matches later snapshots solely by pairing id and is
  cleared only by a LOCAL re-pair: when another device re-creates the
  record, this phone deletes the revival on every restore and keeps the
  intent forever, making cross-device re-pairing impossible to persist.
- The restore echo records every snapshot mapping under the restore
  team, but LWW can retain a NEWER team-less local row un-stamped; the
  later forget looks the mapping up under the row's actual nil team,
  misses, and parks — undeliverable when the network drops.

The third round-21 finding (a same-account sign-in advances the session
generation but the long-lived activation runtimes stay pinned to the
old generation and return nil credentials until restart) is fixed in
the same round; it lives in the iOS-only and macOS app targets, where
no host-runnable test exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: account-pinned runtimes, revival-aware tombstones, retained-row echoes

Round-21 review fixes:

- The LONG-LIVED activation runtimes (iOS composition and the macOS
  host) pin their broker token sources to the ACCOUNT only, not the
  session generation: every completed sign-in now advances the
  generation, and a same-account re-sign-in must keep the runtime
  serviceable — it is the same user, so serving the new session's
  credentials via the atomic snapshot is correct, where the generation
  pin stranded the runtime on nil credentials until relaunch. The
  forget's short-lived frozen pair stays strictly generation-pinned.
- The restore echo now fires AFTER the merge and carries, per snapshot
  record, the RETAINED local row's actual team and the record's creation
  time. Mappings are keyed by the retained row's own scope (LWW can keep
  a newer team-less row un-stamped, and the forget looks the mapping up
  under the row's real team), falling back to the restore scope for
  records with no local row (the reinstall case).
- A snapshot record CREATED after a parked intent's stamp is a REVIVAL —
  another device re-paired the Mac — and retires the intent instead of
  feeding it a delete; without this the forgetting phone deleted the
  revival on every restore forever. Unstamped legacy intents keep the
  old delete behavior (no boundary is known for them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-22 review findings

- A revived record is recognized only AFTER suppression already filtered
  it out of the merge; with the completed restore memoized, the
  re-paired Mac stays missing locally until relaunch.
- The revival signal compared client-authored createdAt, which another
  phone preserves across a re-pair; the genuine revival misclassifies as
  stale and is deleted on every restore. The record model gains the
  SERVER-authored serverUpdatedAtMs (decoded from the snapshot, never
  uploaded).
- Restore echoes persist mappings one save per record; the production
  store rewrites its whole state per save, so a large restore does
  quadratic UserDefaults work. The mapping protocol gains a batched
  saveAll (default forwards per entry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: server-authored revival signal, in-merge revivals, batched mappings

Round-22 review fixes:

- The worker now surfaces the sync machinery's server-authored per-record
  write time as serverUpdatedAtMs on the restore read (never accepted
  from clients — sanitize strips it). Revival classification compares
  THAT against the tombstone's stamp through a shared skew-margined rule
  biased toward revival: client-authored createdAt is preserved across
  re-pairs on other phones and proves nothing.
- Restore suppression is now stamp-aware: run() takes suppression
  entries (pairing + tombstone stamp), and a record every covering
  tombstone sees as revived MERGES in the same restore instead of being
  filtered out and stranded behind the completed-restore memo until
  relaunch. The post-merge echo then retires the covering intents.
- Restore echoes persist their mappings through one batched saveAll —
  the UserDefaults store performs a single read-modify-write of its
  dictionary and ordering for the whole snapshot instead of a full-state
  rewrite per record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing tests — round-23 review findings

- The revival skew allowance accepts server writes up to a minute BEFORE
  the forget as revivals. Forgetting a currently-online Mac whose backup
  was route-mirrored seconds earlier is the COMMON case; the allowance
  bypasses suppression, retires the intent, and the supposedly forgotten
  Mac restores instead of receiving its delete.
- A partial batch failure never records a hidden marker for a FAILED
  undisplayed sibling: the deleted primary's marker turns rowless and is
  migrated away, so the sibling — with its already-revoked binding —
  resurfaces as a normal computer with no Hidden Computers entry left to
  retry from.

The third round-23 finding (the sign-out quarantine's destructive retry
captures live credentials without pinning them to the pending
revocation's account) is fixed in the same round; it lives in the
iOS-only cmuxFeature target, where no host-runnable test exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: strict revival boundary, pinned quarantine retry, sibling retry markers

Round-23 review fixes:

- The revival boundary is STRICT: only a server write after the
  tombstone's stamp counts. Forgetting a currently-online Mac whose
  backup was mirrored seconds earlier is the common case, and the skew
  allowance let those pre-forget writes bypass suppression and retire
  the intent. The residual (phone clock behind the server) fails in the
  recoverable direction: the revival is deleted once and the other
  device's next mirror re-uploads it with a fresh server stamp.
- The sign-out quarantine's destructive retry pins its credentials to
  the pending revocation's account through the atomic session snapshot,
  failing closed if the user switched accounts between the guard and the
  credential capture.
- A partial batch failure records a hidden marker for every SURVIVING
  failed scope in its own team, so an undisplayed sibling with a revoked
  binding keeps a durable Hidden Computers retry entry even offline —
  where the account-wide parked intent cannot yet finish the cleanup.
  Once any restore completes it, the marker turns rowless and the
  existing migration clears it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: failing test — round-24 review finding

The tombstone stamp is floored to whole seconds while server write times
carry milliseconds, so a server write from the same second but BE…

This branch was successfully deployed

1 active deployment
Preview — b163d2ed Deployed Jan 30, 2026 by vercel[bot]
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.

1 participant