Skip to content

feat: support Home/End/PageUp/PageDown as shortcut keys - #3

Open
pandec wants to merge 1 commit into
mainfrom
feat/home-end-pageup-pagedown-shortcut-keys-clean
Open

pandec wants to merge 1 commit into
mainfrom
feat/home-end-pageup-pagedown-shortcut-keys-clean

Conversation

@pandec

@pandec pandec commented Mar 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

Globe+Arrow keys produce Home/End/PageUp/PageDown at the hardware level on macOS (keyCodes 115/119/116/121). These keys couldn't be used as keyboard shortcuts because the shortcut system didn't handle their keyCodes or Unicode function-key characters. For example, binding Home to "Focus Pane Left" — the recording would silently fail, and even if manually configured, matching would fail because unmodified navigation keys were short-circuited by the normalizedFlags.isEmpty fast path in handleCustomShortcut.

Mirrors the existing arrow key (←→↑↓) pattern across two files.

Touch points (7 changes across 2 files)
  1. storedKey(from:) — map keyCodes 115/119/116/121 to glyphs ↖↘⇞⇟ so the recorder captures them
  2. StoredShortcut.from(event:) — exempt navigation keys from "require at least one modifier" guard (bare Globe+Arrow strips .function, leaving zero modifiers)
  3. matchShortcut — early keyCode-based match before character matching (which fails for function-key Unicode)
  4. handleCustomShortcut — let navigation keyCodes through the normalizedFlags.isEmpty fast path
  5. keyEquivalent — map glyphs to SwiftUI .home/.end/.pageUp/.pageDown
  6. menuItemKeyEquivalent — map glyphs to AppKit NSHomeFunctionKey etc.
  7. keyCodeForShortcutKey — reverse glyph→keyCode lookup

Related:

Test plan

  • Open Settings → Shortcuts, click any action's shortcut field
  • Press Globe+Left (Home) — verify "↖" glyph appears (not silently rejected)
  • Press Globe+Right (End), Globe+Up (PageUp), Globe+Down (PageDown) — all record correctly
  • Bind bare Home to an action (e.g. "Focus Pane Left"), press Globe+Left — verify it triggers
  • Bind Shift+Home to an action, press Shift+Globe+Left — verify it triggers
  • Bind Cmd+End to an action, press Cmd+Globe+Right — verify it triggers
  • Verify existing arrow key shortcuts still work unchanged
  • Verify Home/End/PageUp/PageDown still scroll/navigate normally in the terminal when not bound to a shortcut
  • Verify no performance regression on normal typing (fast path still short-circuits for regular keys)

Globe+Arrow keys produce Home/End/PageUp/PageDown at the hardware level
on macOS (keyCodes 115/119/116/121). These couldn't be used as keyboard
shortcuts because:

1. storedKey(from:) didn't map their keyCodes to glyph symbols
2. keyEquivalent/menuItemKeyEquivalent had no SwiftUI/AppKit mappings
3. matchShortcut character matching fails for function-key Unicode chars
4. The "require at least one modifier" guard rejected bare Globe+Arrow
   (after stripping .function, zero modifiers remain)
5. The normalizedFlags.isEmpty fast path in handleCustomShortcut returned
   early, skipping all shortcut matching for unmodified navigation keys

Mirrors the existing arrow key (←→↑↓) pattern. Adds glyph symbols
↖↘⇞⇟ across all five shortcut system touch points, exempts navigation
keys from the modifier requirement, and lets their keyCodes through the
fast path to reach shortcut matching.
@pandec
pandec force-pushed the feat/home-end-pageup-pagedown-shortcut-keys-clean branch from 6095e00 to f7866dc Compare March 29, 2026 20:10
pandec pushed a commit that referenced this pull request Jul 30, 2026
…w-ai#9071)

* 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 (manaflow-ai#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>
pandec pushed a commit that referenced this pull request Oct 15, 2026
* Isolate CodeRouter from normal agent state

* Pin resilient Stack logout backend
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