Commit Graph
755 Commits
Author SHA1 Message Date
Jinwoo Hong 428558b941 fix(mobile): let the shell's page paint a file preview (OTA phase C, C3.0) (#21591)
* fix(mobile): let the shell's page paint a file preview

A file preview has one shape on the wire: the desktop answers a base64 body
and `normalizeMobileFilePreviewResult` composes `data:<mime>;base64,<content>`
for React Native Web's `Image`. Under `img-src 'self'` the browser refuses to
load it, so every image preview in the page paints nothing — reproduced in the
render check, which logged the refusal naming `img-src 'self'` before this.

`data:` is granted to images and to nothing else, so what it admits is what the
page itself composed out of a reply it already holds; `script-src 'self'` and
`connect-src 'self'` are untouched, and `blob:` is not added because nothing in
the closure needs one. Both platform pins narrow from "the header contains no
`data:`" to "`data:` appears on `img-src` and nowhere else", which is the check
that still fails if a later directive grows one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the data: image case actually loads through

Round 1 is right on both counts. In react-native-web 0.21.2 the hidden <img>
the Image component renders carries `alt`, `style`, `draggable`, `ref` and
`src` and no load handlers at all — it is there for the browser's image context
menu and for `getBackgroundSize()`. The load signal comes from
`ImageLoader.load`, which is `new window.Image()` with `onload`/`onerror` on it,
so the `new Image()` in this case is the same mechanism the screen's own load
runs through rather than a stand-in for it.

And the screen maps `onImageError` to "Unable to load preview"
(`MobileFilePreviewScreen.tsx:282`); "Binary preview unavailable" is the
normalizer's `binary_file` branch, which a CSP refusal never reaches.

Comment only. No assertion moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the img-src data: bound as the destination, not provenance

"admits only what the page itself built" read as a provenance guarantee, and CSP
has none to give: `data:` is matched as a scheme, so the directive admits any
`data:` image URL and the browser cannot tell one the page composed from one it
was handed. Nor is the content the page's own — the mime type and the base64
body both come from the host, and `normalizeImagePreviewResult` only checks the
mime type is a non-empty string.

The true bound is where the URL goes: it is never fetched as anything but an
image, `img-src` is the only directive admitting it, an image fetch executes
nothing (an SVG inside an `<img>` runs no script), and `script-src 'self'`,
`connect-src 'self'` and `object-src 'none'` are untouched.

Both copies reworded identically, since they are kept in step by the render
check's own policy comparison.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 02:36:39 -04:00
Jinwoo Hong edd9e3125b fix(mobile): give the page its height and its long press; C1.7 device proof (OTA phase C, C1.9) (#21589)
* fix(mobile): give the Route A document the height its mounted tree measures against (OTA phase C, C1.9)

The document this builder emits carries no stylesheet, so `html`, `body` and `#root` have no
height, and every box react-native-web lays out below the mount is `flex: 1` against a parent
that measures 0. The collapse is silent in every check that existed: the entry stamps `mounted`,
the route tree commits, `innerText` holds every row, and the accessibility tree reports each one
at the offset it would have had. Nothing is painted below the header, and nothing takes a tap —
the list sits inside a scroller the collapse clipped, and a phone reads it to VoiceOver while no
row responds. Lane C1.7 found it on both an iPhone 17 Pro simulator and a Pixel 9 Pro emulator,
and the same bytes reproduce it in headless Chromium.

The fix is the reset Expo's own web template ships for a react-native-web root, emitted inline
because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet react-native-web
injects at runtime; a linked asset would paint the collapsed layout until it landed.

The render check gains the assertion that would have caught it: the root's box measured against
the viewport, and the one control this route paints with no RPC answered — the New Workspace
button, positioned against the bottom of the root, which the collapse moved to y = -72 — asked
for by `elementFromPoint` at its own centre. Laid out is not reachable, so the check is a hit
test and not another read of the DOM. Without the reset it fails `expected +0 to be 844`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the page have the long press WebKit was taking (OTA phase C, C1.9)

The shell's WKWebView is built with the default text interaction, so WebKit installs its selection
assistant over the page. A hold on a worktree row raises the selection loupe over the row's own
text and the touch is cancelled before the page's responder sees it, which leaves every
long-press action in the page dead on iOS while a tap works. Lane C1.7 measured it: the same
injected hold opens the row action sheet on the native list and on the page in the Android
WebView, and does nothing in the page on iOS.

It is not the document's to fix, which the device disproved one rule at a time:
`-webkit-touch-callout: none`, `-webkit-user-select: none`, and both together all left the loupe
and left the hold undelivered, and headless Chromium confirms the property computes to `none` on
the page's text, so the CSS reaches it and WebKit's own gesture wins anyway.

The cost is real and named here rather than discovered later: the page has no text selection on
iOS, so selectable `Text` — markdown, diff rows, file preview, chat — cannot be selected there
until a page-side copy affordance exists. Everything the shell already forbids is unchanged, and
Android is untouched.

No unit test: the module's Swift checks compile the seven WebKit-free logic files and never import
WebKit, so a `WKWebViewConfiguration` cannot be built in them. The device proof stands in.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the root reset the template's bytes, not a copy with an addition

The comment said the reset is what Expo's web template ships, and `margin:0` was not in it.
`@expo/cli@55.0.36/static/template/index.html` carries height, `overflow` and the root's flex box
and nothing else, and react-native-web emits `body{margin:0}` in the sheet it injects at runtime,
so the addition only covered the frames before that sheet landed. Nothing pinned it either:
removing it left all 60 tests green, which is the other way of saying it was never load-bearing.

Dropping it makes the string one thing with one source instead of a copy to keep in step with two.
The pins on the rest of the reset are unchanged, and so is the frame that mattered: the root still
has a definite height before the first paint, which is what the collapse needed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the render check with the root formatter

`config/scripts` is formatted by the root oxfmt, not mobile's, and CI checks neither, so a 102-char
line I added sat over the root's `printWidth: 100` with nothing to catch it. Reflowed by
`./node_modules/.bin/oxfmt --write` from the repo root; no behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the root reset shares with Expo's template, not that it is its bytes

"The bytes Expo's web template ships" is false and checkable: the template's own block is
pretty-printed with comments and trailing semicolons at 410 bytes, and this string is 112. What is
actually true, and what the next reader needs, is that it carries the same declaration set and the
same `id="expo-reset"`, minified.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin every rule of the root reset, not two substrings of itself

The check read the constant back against itself: `toContain(MOBILE_WEB_APP_ROOT_RESET)` plus two
substrings taken off that same constant. A rule dropped from it took the assertion with it, so
`body{overflow:hidden}`, `flex:1` and the `expo-reset` id were unpinned — and the render check
stays green without the overflow rule, so nothing else held them either.

Each rule is now a literal written here, named one at a time so a failure says which one went, and
the id is pinned beside them. Verified red-first: removing the overflow rule, the `flex:1`, or the
id each fails this test and only this test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 02:28:11 -04:00
Jinwoo Hong 1ef947394b feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2) (#21582)
* feat(mobile): answer navigate-back on the shell side of the bridge (OTA phase C, C2.2)

A page served at `/` holds the one history entry its entry wrote with
`replaceState`, so `history.back()` goes nowhere and a page Back button is
dead. The only stack with somewhere to go is the native one the shell pushed
the page onto.

Adds `notify { name: 'navigate-back' }` to the closed client union, gated on
the existing `navigate` grant rather than a name of its own: an app that can
open a screen can close one, and a new grant name would leave every route
declaring it native on every shell already shipped. `MOBILE_WEB_SHELL_GRANTS`
is unchanged and `BRIDGE_PROTOCOL_VERSION` is not bumped.

`bridgeNotifyRefusal` grows a name-to-grant table, since this is the first
notify whose name is not its grant's. The shell screen pops its own stack and
answers false when there is nothing left, which the host logs as
`navigate-back-refused` — nothing crosses back to the page either way, so
silence there is indistinguishable from a Back button that worked.

Inert until a consumer exists: no page posts the name yet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2)

`useRouteHandoff` wrapped `push`, `replace` and `dismissTo` and left `back`
as expo-router's own, which inside the page pops a history of one and does
nothing. It now pops locally when the document grew a stack of its own, and
otherwise posts `navigate-back` for the shell to pop the native stack.

This is what makes the Tasks header's `onPress={() => router.back()}`
(`src/tasks/mobile-tasks-screen-chrome.tsx`) work once the C2.1 consumer
routes that screen's router through this seam; the barrel still reaches
expo-router directly, so nothing calls this yet.

A shell that granted no `navigate` falls through to the local router rather
than throwing out of a tap handler. A shell that granted `navigate` but is
too old to know the verb refuses the frame as `unrecognised-message` and
logs it; neither is distinguishable from the page, and the fallback goes
nowhere in both — which is exactly where Back already went.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): count back among the members that leave the page document

The header said three wrapped members driven by one answer. `back` is a
fourth, and it is not driven by that answer: it carries no target, so the
document's own stack decides it rather than the shell's route list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): complete the bridge hook probe in its two local literals

`Probe` grew `backPops`, and two cases build the object inline rather than
through `mount`. `tsc -p tsconfig.json` excludes test files, so only the
tests-typecheck ratchet saw it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): measure the stack the page Back button rests on (OTA phase C, C2.2)

`useRouteHandoff().back()` asks expo-router's `canGoBack()` whether this
document can serve a back itself, and that answer is React Navigation's, so
no unit test settles it. The render check now measures it in the browser it
actually runs in.

Read through `router.back()` on `/h/[hostId]/edit`, a real route of this tree
whose chevron is expo-router's own back, because the page exposes no handle
to call `canGoBack()` on and a global added for a test would ship forever.

Measured: the router has nowhere to go on the document the shell opens, and a
location change does not give it one either. So the handoff's `canGoBack()`
gate answers false for everything the shell or the browser can do to the
page, and its local branch belongs to a push the page makes through the
handoff itself.

The shell double now records every notify the page posts and takes the grant
list as a parameter, so a control that handed something to the shell can be
told from one that did nothing. The first case asserts a real tap crossing
the bridge, which is what makes the two absences after it evidence.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a second stack pop while the first is still queued (OTA phase C, C2.2)

`canGoBack()` and `back()` disagree about time. The first reads the committed
navigation state; the second only adds `GO_BACK` to expo-router's
`routingQueue`, which `useImperativeApiEmitter` drains from an effect. Two
`navigate-back` frames delivered in one native batch therefore both read the
stack the first pop has not left yet, both queue, and a three-deep stack
unwinds past the screen the page was opened over. The host forwards every
notify it is granted, so nothing upstream coalesced them.

`useShellStackPop` owns the pop and latches it. The latch clears on the
committed route rather than on a timer, because that commit is the first
moment `canGoBack()` answers for the stack the pop actually left; a pop that
takes this screen off the stack unmounts it and takes the ref with it.

`onNavigateBack` now answers `popped` / `nothing-to-pop` / `pop-pending` so
the `navigate-back-refused` diagnostic is true for the frame it names, and
the log dedupes per reason rather than burying the second behind the first.

Driven against expo-router 55.0.18's own `global-state/routing.js`,
evaluated verbatim with only its externals stubbed: a mock of `canGoBack`
is what hid this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the notify grant table total over the union (OTA phase C, C2.2)

The table was `Record<string, string | undefined>` indexed with a plain
string, so a notify name with no row returned undefined, read as ungated,
and the host acted on a frame it had never granted. Adding a member to the
envelope's notify union raised no error anywhere — the hole was silent.

Keyed on `Extract<BridgeClientMessage, { type: 'notify' }>['name']` with
`string | null` values, an omitted row is now a TS2741 on the table itself,
and `bridgeNotifyRefusal` cannot be asked about a name the table has no row
for.

Adds the `navigate` and `storage` rows, which were missing: the host was
enforcing the navigate grant for `navigate-back` but not for `navigate`.
Both are inert while every page is offered every grant, and load-bearing the
moment a grant is per-route.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): assert no page errors in the browser case that drives popstate (OTA phase C, C2.2)

The case that dispatches a synthetic `popstate` read its answer as "the page
did not move", and a throw under the page's fault boundary leaves the page
exactly there. Without the errors assertion the other two cases carry, that
absence was not evidence of what it claimed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the routing module's members instead of asserting them

The casting gate refuses the two assertions the loader used, and it is
right: a stub that stopped covering an import would have left the members
undefined and the test would have driven a half-evaluated module. Destructure
and check instead, so that case says so.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the two pops the stack latch never hears about (OTA phase C, C2.2)

The comment read as if the clear were exhaustive. It is not: a pop landing on
an equal pathname does not transition `usePathname`, and a `GO_BACK` the queue
discards commits no route at all — `routingQueue.run` shifts every action off
the queue whether or not `ref.current` is set.

Kept the pathname clear rather than moving to the navigator's `state` event.
The event would cover the first stick: `@react-navigation/core` 7.17.2 emits
`state` from an effect keyed on the navigator state object, and every pop
replaces it. It would not cover the second, which changes no state. And the
emitter is the navigator, not the routing module this hook is written and
tested against, so the switch cannot be earned by a test here the way the
queue behaviour was — it would rest on a mock of the signal under test.

Both sticks are bounded instead, in the commit that makes the latch one per
stack: the holder releases on unmount, so a stick lasts at most as long as the
screen that took it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold one pending stack pop per stack, not per screen (OTA phase C, C2.2)

`MobileWebShellScreen` mounts at both `app/h/[hostId]/index.tsx` and
`app/h/[hostId]/web.tsx`, and `/h/a/web` is deep-linkable over `/h/a`, so two
shells can be mounted over one native stack. A latch per screen left each of
them holding its own, and two frames from two pages still unwound two screens.

The latch is module-scoped and carries which screen took it, so a shell whose
own route commits cannot release a pop another shell is still waiting on. The
holder also releases on unmount, which is what bounds the two pops the
pathname clear never hears about: a latch nobody is left to release would
outlive the stack it guards and leave Back dead for the session.

Both screen suites now unmount their trees between cases, because a tree that
is only dropped is a screen still holding whatever pop it took.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the gated-notify name list nothing reads (OTA phase C, C2.2)

`BRIDGE_GRANT_GATED_NOTIFY_NAMES` existed so a caller could ask which names
ride a grant. Once the table became total over the notify union, the table
itself answers that and the only readers left were the two assertions that
read the list for its own sake.

Deleted with them. The behaviour they stood next to is kept: the protocol's
own names are still asserted ungated through `bridgeNotifyRefusal`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the routing-module guard actually catches (OTA phase C, C2.2)

The comment claimed the guard catches a stub that stopped covering an import.
It does not: evaluating the module with every stub dropped still defines all
three exports, because the module assigns them whatever its imports resolved
to, and the failure surfaces later as `TypeError: Cannot read properties of
undefined (reading 'navigationRef')`.

What the guard does catch is an expo-router upgrade that renames or removes
one of the three members this test drives.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the second shell mounted across the holder's removal (OTA phase C, C2.2)

The harness swapped the tree's root element between a single shell, a
fragment of two, and a single shell again. Each swap remounts everything
under it, so the callbacks the cases held belonged to unmounted hooks. One of
those could still take the module-scoped latch, and the instance that took it
was already gone, so nothing was left to release it — the last case in the
file leaked the latch into whatever ran next.

The root is now one component with a slot per shell, so removing the holder
leaves the second shell's instance alone, and every pop is taken through a
callback re-read at call time.

`afterEach` now asserts the latch is clear by mounting a screen after every
other one is gone and requiring it to pop. Without it this leak was invisible:
it surfaces only in a following case, and the case that caused it was last.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 02:09:09 -04:00
Jinwoo Hong 5a208ce6fe chore(mobile): repin the recording corpus to main's tip after #21566 (#21588)
#21566 re-recorded the corpus with `baseline` set to its own branch commit
`47d24d324e`, which the squash merge left unreachable from main. The pin
guard on the push to main fails, and so does the pin job on every open
pull request, since the merge ref cannot reach that commit either.

Repin to main's tip `889c2b562f` and re-record from that tree. Every
golden's body is byte-identical to the one #21566 recorded; only the
`baseline` header line moves (788 files, one line each).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 01:09:22 -04:00
Jinwoo Hong 889c2b562f feat(mobile): say why a host is unreachable instead of "Connecting via Relay…" (#21566)
* feat(mobile): say why a host is unreachable instead of "Connecting via Relay…"

The home-screen host row and host header showed "Connecting via Relay…" for
as long as the desktop stayed unreachable, even when every relay dial had
ended with the cell's 4404 host-offline close. A user's diagnostics export
showed 25 such dials over 25 hours behind that label, and the diagnostics
report itself said "No single failure cause" because relay dial failures
were not recognised and every app resume emptied the evidence window.

Relay close codes now map to a closed RelayHostReachability verdict
(signed-out, host-offline, credential-refused, unreachable), latched after
two consecutive identical dial failures and cleared only by an authenticated
session. The existing signed-out close reason becomes a member of the same
verdict instead of a parallel boolean. classifyConnection renders each
verdict as a label plus a detail line ("Host 1 is offline" / "Check it's
awake, Orca is running, and you're signed in").

Relay dial failures carry their close code as a structured field on the
connection log entry, so the diagnostics analysis names the cause without
parsing error text, and an app resume no longer hides the last failure: it
is reported with a "Before the app last resumed" qualifier and is never a
sendable incident.

* test(mobile): re-record RPC goldens at the new baseline

Only header lines change: the baseline pin on every golden and the
adapterSha256 on the twelve goldens whose mount adapters gained the
getRelayHostReachability context method. No checkpoint moved, which also
shows the commits between the old and new baseline changed no observed
RPC behaviour.

* fix(mobile): tell a refused relay credential to re-pair, not to find the same network

A direct session also rotates the credential, but telling the user to
connect on the same network once explains the mechanism instead of giving
an action, and re-pairing is the one remedy that works from anywhere.

* fix(mobile): let the newest relay failure win the diagnosis, and name the real stale boundary

Relay-path evidence still outranks a newer direct timeout, but among relay
failures the newest now wins: an older 4404 verdict no longer hides a newer
session close (which was also the sendable incident) or a director refusal.
The stale prefix names a network change when that, not a resume, was the
boundary.
2026-09-19 00:24:25 -04:00
Jinwoo Hong 0817476b2c fix(mobile): follow-ups from the C1 stack review, one commit per finding (OTA phase C, C1.8) (#21570)
* fix(mobile): encode the host id the native list hands the shell

`web.tsx` encodes the host id into the pathname it opens the shell on; the
worktree-list route beside it still interpolated it raw. `useLocalSearchParams`
answers the decoded value, so a host id carrying `?`, `#` or whitespace builds a
pathname that is no longer one segment.

That shape is not refused where it is built. `matchesRoutePattern` splits on `/`
alone, so `/h/a?b` reads as the single segment `/h/[hostId]` names and the
session starts; the bridge's pathname rule is what refuses it, one `init` later,
and the shell turns that refusal into `document-load-failed`. The route ends on
a failure screen instead of the native list it already has and was about to
render anyway.

The fix sits at the interpolation rather than at the pattern or the bridge,
because the other two are right: the pathname rule is what a path may be, and
the page decodes the segment back when it matches `[hostId]`, so the screen it
opens is the same one. A deep link is the way such an id arrives, which is what
the sibling route's own test already establishes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): end a route segment at the query, not only at a slash

The dot-segment lookahead both route patterns are built from treated `/` and
end-of-string as the only things that close a segment. An href may carry a
query, so the last segment can also be closed by `?`, and there the lookahead
never fired: `/h/..?x`, `/h/%2e%2e?x` and `/h/.?x` all passed
`BRIDGE_ROUTE_HREF_PATTERN` while their slash-terminated spellings were refused.

The sink is `router.push`, and a URL parser resolves `/h/..?x` to `/?x` exactly
as it resolves `/h/../x` to `/x`. That is the climb out of the `/h/` prefix the
rule exists to stop, reached through the one punctuation the rule did not treat
as a boundary.

Fixed in `BRIDGE_ROUTE_SEGMENT_SOURCE`, which is the single place the segment
rule is written and the reason the two patterns cannot drift apart. The pathname
pattern is unaffected: a `?` fails its character class wherever it appears, so
widening the boundary cannot let anything new through there. The existing
segment-rule block gains the query-terminated spellings beside the ones it
already pins.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say which notifies actually reach the mount-order throw

The header said a call before `init` is a mount-order bug and throws, and named
`notifyPageFault` as the one exception. Two more never reach that throw: a grant
is read off the session, so before `init` there is no grant either, and the `&&`
in `navigate` and `storage` short-circuits before `post` can require one.

The code is right and the comment was not, so the comment is what changed. False
is already these two members' refusal answer — it is what they give a shell that
withheld the grant — and both callers handle it. `useRouteHandoff` calls
`notifyNavigate` uncaught inside `push` and falls back to routing inside the
page, so making this path throw would turn an early tap into an unhandled error
in a handler nobody wrapped, which is the same reason the close path answers
inertly rather than throwing.

Pinned rather than left to the prose: the two gated notifies answer false and
post nothing before `init`, the two ungated ones still throw, and the gated ones
post once the shell has granted them. Not a red-first test — there is no defect
here to reproduce — but the contract now has a test holding it in place.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): re-arm the shell session the route rebuilt, not only the host

Two effects share one session. The first rebuilds it from `hostId` and
`routePathname`; the second is the only thing that ever tells the reducer what
the gates say, and it listed the host alone. A fresh session starts in
`checking` and moves on nothing but `gates-changed`, so a route that changed
under an unchanged host and unchanged gates threw the old session away and left
the new one with no effect to run and no verdict to wait for.

`routePathname` joins the gates effect's dependency list, beside the `hostId`
that is already there for the same reason: both are what rebuild the session
above, so both have to re-arm it. Fixing it in the dependency list rather than
by having the reducer restart on a repeat verdict keeps the reducer's rule
intact — a repeat verdict genuinely is nothing new — and keeps the coupling
stated where the coupling lives.

No caller can reach this today: both routes derive the pathname from the host
id, so the one cannot change without the other. The test drives the hook
directly and holds the invariant the wiring is supposed to have, since the thing
protecting it was a property of the call sites and not of this hook.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): persist the last-visited worktree through the mirrored writer

`writeLastVisitedWorktree` noted the write on the mirror and then dropped the
store's promise with `void`. The mirror reports the key as written the moment it
is noted, so a store that refuses the write leaves a value the page is handed on
every `init` and that nothing ever persisted, and the rejection escapes as an
unhandled one because no caller above it holds a catch.

`writeMirroredStorage` in the same module is already exactly this: note first,
persist second, and swallow the rejection deliberately, because a pin that
failed to persist is not a reason to take the workspace off screen. This writer
had grown its own copy of that pair without the last part. Reusing it rather
than adding a local `.catch` is what stops the two copies drifting again, and it
is the boundary that owns the relationship between the mirror and the store.

The test drives a store that refuses the write and listens for an unhandled
rejection, which is the failure the `void` produced and the only way to observe
it from inside a test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): pin the re-arm oracle, and move the query rationale to the rule

Two review nits from round 1, neither changing behaviour.

The re-arm test asserted the session was no longer `checking`, which a failure
state satisfies just as well as a recovery does — the test would have passed on
the opposite of what it is for. It now pins `native-route`, which is the state a
re-armed session actually settles on here: `/h/host-1/tasks` is not the route
the bundle lists, so the reducer answers with the native screen.

The sentence about `?` closing a segment sat in the doc block for the `init`
pathname bounds, which opens by saying that pathname carries no query. Read
top to bottom the block contradicted itself. The rationale belongs beside
`BRIDGE_ROUTE_SEGMENT_SOURCE`, where the shared rule is written and where the
reason is legible: an href carries a query even though a pathname does not, both
are held to the one segment rule, and widening its boundary cannot loosen the
pathname pattern because a `?` fails that character class anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state what the native router really does with a dot segment

Round 2 review, comment text only.

The rationale beside `BRIDGE_ROUTE_SEGMENT_SOURCE` claimed `/h/..?x` resolves to
`/?x`, borrowing the climb `history.replaceState` performs on the `init`
pathname. That is the wrong sink. An href's sink is the native router, and
expo-router's `resolveHrefStringWithSegments` normalises only an href beginning
with `.`; a rooted one is passed through, its query stripped, and the forked
`getStateFromPath` then matches segments literally against the route patterns. A
dynamic segment compiles to `([^/]+\/)`, which takes `..` as happily as any
other value.

So the harm is not a climb and it is not Unmatched either: `..` is read as the
`[hostId]` a screen is opened for, and the shell opens a host screen for an id
no host has. A different wrong screen from the slash-terminated spellings, and
the same reason one rule covers both patterns. The boundary and the test that
pins it are unchanged; only the sentences describing them are.

The notify header opened by saying three of the four share one guard, one
paragraph above the one explaining that only two ever reach its throw. It now
says both in the same breath.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 23:26:28 -04:00
Jinwoo Hong e2afb5eef9 feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) (#21503)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4)

Three gaps the census named, and the last of them is why the page needed a second
grant.

`expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with
an empty array and the list painted "Host not found" over the host the shell had
just opened it for. `init` gains an optional `host`, and `host-store.web.ts`
serves it: the profile the screens read, with no device token and no public key,
because the bridge already carries the connection those would have opened. The
native writes it cannot make — pairing, renaming, recency — settle rather than
throw, since recency orders a list the page never shows.

AsyncStorage's web build is `window.localStorage`, and the page has none worth
having: Android turns DOM storage off and on iOS the origin host is the session
id, so a pin set in the page was gone on the next remount. The builder aliases
the module to a page store whose values are the app's own — `init` primes the
allowlisted keys, a write is applied locally and posted over a new `storage`
grant, and the app is where it lands. The allowlist is two keys and is the whole
fence: everything the app stores shares one namespace, the hybrid shell flag
included, so a page that could write any of it could turn the feature on for a
build that never offered it. A key outside the list is refused and, crucially,
not kept locally either — a pin that looks set and is not is the failure the
grant exists to avoid.

The bridge host is built only once both have been read, because `init` is
answered once per `ready` and carries them: a host that started without them
would have to be torn down to carry them, and the list would already have mounted
against a host it could not name.

`Alert.alert` on a failed host removal is a silent no-op in React Native Web, so
inside the page that failure had no surface at all. It routes to the error the
list already shows, on both platforms.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the storage-write ref after the commit

The last render-phase ref write in this hook joins the commit-phase effect.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the confirm setter this callback already calls in its deps

A `useState` setter is stable, so the identity of the callback is unchanged; the list now
says what the body reads. Reported on the line this branch rewrote.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4)

Five, from one round of review.

A removal that fails no longer writes the identity error: that one is an early return
over the header, the list and the overlays, with nothing to dismiss it and nothing left
to render the confirm it re-opens. It goes to a dismissible line above the list instead,
on both platforms, cleared by the next confirmed refresh.

`init` reads the allowlisted keys on every answer rather than capturing them at mount, so
a document that reloads inside one mount is primed from after its own writes. The read
stays synchronous: the page refuses every member until `init` lands and the golden
recorder mounts a screen in the same turn it drains one, so a promise here moves the
first render of every bridged replay.

A profile read that rejects is now a shell failure with a diagnostic instead of a `ready`
session with no host behind it and a page asking forever. The page bounds a value by the
envelope's own constant rather than caching what the wire drops. And a write is held to
the keys this page was handed, so one host's page cannot rewrite another's pinned list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): state the two test fixtures' types instead of asserting them

The casting gate reads a `SAFETY:` rationale off the line directly above the assertion,
and a wrapped comment puts a comment there instead. Two of the four were not assertions
worth keeping at all: a hoisted fixture says its own type, and the router comes from the
mock the file already installs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the two fixtures in place, not through a widened binding

`const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate
refuses, and rightly: the evidence is discarded and then invented again. The assertion
belongs at the literal, with its rationale on the line above it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the key a refused page write reached for

The last diagnostic still falling through to the line about a view outliving its
host, on the branch that added it. The key is the evidence: it says which host's
pinned list the page was reaching into.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe this branch's storage writes too

Same merge, one branch further: a probe here also collects what the page asked
the screen to write.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the page the app's storage as it stands, not one init late

The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes
two of them from its own native screens. The shell's map was only re-read after a ready, so a
native write between two readies reached the init after next: the drawer opened on the repo the
user left. The map is now module-scoped and every writer of an allowlisted key notes it as it
writes, so the init that answers a ready already carries it. The store read only seats the map,
and a read that started before a write no longer puts the older value back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): find the banner's dismiss without an assertion

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): keep the storage mirror in the storage tree

The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the
dependency the wrong way round: the shell is what is built on the app's storage, not the other way.
The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller
names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour
change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 22:20:49 -04:00
Jinwoo Hong ac4dc6599b feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:53:58 -04:00
Jinwoo Hong b8f3b1ec00 feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) (#21501)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:21:48 -04:00
Jinwoo Hong d043cfbbee feat(mobile): the page mounts on the shell's init, with the client injected and a fault boundary (OTA phase C, C1.1) (#21500)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 20:45:03 -04:00
Jinwoo Hong f2fd18c820 test(mobile): make the bridged recording-corpus replay a CI gate and pin the C1 page closure (OTA phase C, C1.6) (#21533)
* test(mobile): the bridged corpus replay is a gate, not an opt-in flag (OTA phase C, C1.6)

`rpc-recording-through-bridge.test.ts` replays all 787 goldens through the page
bridge and pins how far that bridge is from byte-identical. It only ran when
`RPC_FOUNDATION_BRIDGE=1` was set, which CI did in a step of its own. A gate
whose CI job has to remember to set a variable is opt-in, and a branch that
widened the divergence with that step edited away would have been measured by
nobody.

The suite now runs by default and `RPC_FOUNDATION_BRIDGE=0` skips it, for a
local run that does not want the three minutes. `BRIDGED_PARITY_OFF` names the
one value that skips, so an unset or mistyped variable still runs the gate.

The dedicated CI step goes with it: `pnpm test` collects the file already, so
keeping the step would have run the same 788 tests twice in one job. Vitest
gives the file a worker beside the rest of the suite, so the marginal wall time
is a fraction of the ~3 min it takes alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the bridged verdict of every C1-page-closure golden by id (OTA phase C, C1.6)

The bridged replay certifies the corpus in counts over 787 goldens, and a count
is the wrong instrument for the claim C1 needs. C1 moves one domain to the web:
`app/h/_layout.tsx`, `app/h/[hostId]/index.tsx` and their import closure. A
golden recorded at a call site in there that stopped replaying byte-identically
is paid for by any of the other 684 that started, and every existing assertion
still passes.

`c1-page-closure.ts` names the 22 families and 103 goldens of that closure and
pins each one to the verdict it gives: 54 byte-identical, 49 in four of the five
classes the suite already excludes, all of them recorder observation artifacts
whose wire bytes C0.5 and C0.8 proved identical. Membership is checked per
family, not against the flat id list, so a golden newly derived into a family
this domain owns arrives as a finding instead of being missed for never having
been pinned. A closure golden may only be excluded into a class that carries a
reason in `BRIDGED_PARITY_EXCLUSIONS`.

Two full-corpus assertions go with it. `identical` was a floor; it is now the
exact 787 minus the excluded classes. And the run's own size is pinned to the
corpus: every class is an upper bound, so without that a corpus that lost
goldens outside the identical set satisfied all of them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): move a closure golden to a verdict it is not already pinned to (OTA phase C, C1.6)

The drift test took whichever golden the pin lists first and moved it to
`params-undefined`. Nothing said that golden was not pinned to that class
already, and the day one is, the test asserts a change it never made. Pick the
first golden whose verdict is something else, and assert the pair of verdicts
the line reports rather than just the new one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin `identical` to its baseline, not to the run's own exclusions (OTA phase C, C1.6)

`identical: pinned - excludedCount` took `excludedCount` from the run, and the sum
assertion above it already forces `total(counts) === excludedCount`, so the pair
reduced to `corpus === pinned`: only the size of the corpus was pinned. With every
per-class check an upper bound, a golden moving out of an excluded class into
`identical` satisfied all of them.

`bridgedParityTallyDrift` pins every number to `BRIDGED_PARITY_BASELINE` exactly,
`identical` among them, and its own tests encode the mutation: one
`result-absent-settlement` golden reported `identical` is two drift lines, where
before it was a green run.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): the observation class counts 3, not 7 (OTA phase C, C1.6)

The number in the prose predates the baseline it describes; `result-absent-observation`
is 3 in `BRIDGED_PARITY_BASELINE` and in the run, and the line above it already says
341 / 3 / 6 / 33 / 8.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the checks that are still there, not the bound that is gone (OTA phase C, C1.6)

Three comments described the per-class upper bound the tally replaced. What the
exact pin is exact *against* is now `unclassified`, the exclusion sum and the
membership pins, so say those.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the closure-exclusion check the cheap suite already makes (OTA phase C, C1.6)

`c1PageClosureExclusions()` reads `C1_PAGE_CLOSURE` and nothing the run produced, so
asserting on it inside the three-minute replay bought nothing the cheap suite next
door does not already buy. `c1-page-closure.test.ts` makes the same assertion with a
presence precondition on top, which is the stronger of the two.

The comment beside the tally counted two checks above it; there are three, and a
golden that moved out of an excluded class into `identical` is invisible to all of
them: `result-absent-settlement` is past the nameable cap, so membership drift never
looks at it, and lowering it lowers the exclusion sum with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 19:56:27 -04:00
Jinwoo Hong d253dd0e1d fix(mobile): the page bridge accepts every reply native accepts and settles what it refuses (OTA phase C, C0.8) (#21511)
* chore(mobile): repin the recording corpus to main's tip (OTA phase C, C0.8)

C0.5 pinned `baseline` to its own branch commit, which the squash-merge made
unreachable, so `rpc-recording-pin-guard.mts ancestry` fails on main and
`--record` refuses to run at all. Repin to main's tip and refresh every header
from it.

Header-only, and the corpus proves it: across all 787 goldens exactly two
distinct lines changed, the old `baseline` and the new one. `recorderSha256`
did not move, because nothing under the recorder's own directory did.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): accept at the page bridge every reply the native client accepts

`BridgeReplyPayloadSchema` required `_meta` on both arms. The predicate the
native client applies to a reply off the wire, `transport/rpc-response-shape.ts`,
requires none, and `src/shared/runtime-rpc-envelope.ts` — the envelope clients
and runtimes share — makes `_meta` optional on a failure with a nullable
`runtimeId`. The page's reader was strictly narrower than the transport it
stands in for, so replies the phone accepts today were refused, dropped with a
diagnostic, and settled nothing.

The reader is now that predicate itself, imported rather than restated: one
module owns the shape, and a widened reader is safe in both directions. `{ ok:
true }` with no `result` key stays refused, because `isRpcResponse` drops it too.

Killer test: `the reply reader is the native acceptance predicate` compares the
page's verdict against `isRpcResponse` over eleven payloads; six of them were
red before this change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): settle the exchange a refused bridge frame was answering

A host frame the page's reader refuses was reported and dropped, and the
request it answered stayed pending for the life of the document. A screen has
no recovery from that: `sendRequest` never settles, so nothing rerenders and
nothing retries.

The page now salvages the id out of the refused frame, through the same caps
the reader applies, and settles it — but only when it already holds that
exchange, so a salvaged id reaches nothing the page did not open itself. A
request rejects with `BridgeReplyRefusedError`, which carries the refusal and is
now marked delivery-unknown at construction: the shell answered, so the desktop
has already run the request and a caller told this was a definite failure would
offer to retry what already happened. A subscription ends the way a refused
`subscribe` ends today.

`bridge-rpc-client.ts` was at the 300-line ceiling, so the inbound routing moved
to `bridge-client-inbound-frames.ts` and the diagnostic vocabulary, which both
sides raise, to `bridge-client-diagnostics.ts`. No `max-lines` disable.

Killer tests: `settles the request a reply it could not read was answering, on
the same turn` (hung to a 5s timeout before) and `ends the stream an event it
could not read belonged to`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name what the bridged replay still excludes, and ratchet the pin

The class this harness was landed to name is closed, so the pin says so:
`reply-meta-required` is 0 and has no exclusion reason, which is what
`divergence-classes.test.ts` now requires of any class the run is allowed to
count. `BRIDGED_PARITY_EXCLUSIONS` gives each remaining class the reason it is a
bound on the claim rather than a defect, the run prints the excluded total
beside those reasons, and one assertion states the whole claim: nothing diverges
that no predicate has named.

Measured, not argued, for `params-undefined`: all ten scenarios that send an own
property valued `undefined` already record the key as absent from the bytes
their request put on the wire — `tw-smart-search-all-providers` records
`{"filter":"assigned","limit":50}` with `workspaceId` already gone — so the
bridged run sends the identical frame. What differs is the object
`ScriptedRpcTransport.complete` matches a scenario step against, one level above
any serialization. The projection named as the fix is not where it lives:
`projectMobileRpcRequestParams` rewrites `worktree.ps` alone, none of the ten
calls it, and the bridge host forwards into the same `StableLogicalRpcClient`
the native screens hold, so there is no shell-side copy to move.

`divergence-evidence.test.ts` asserted the narrow reader that the `_meta`
widening removed; it now pins that the counterfactual changes no verdict, which
is what makes it a detector for that reader coming back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say the remaining classes total 391 in one line

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read the throw a bridged golden died on, not just the scenario

The `params-undefined` arm named a class from two facts that never met: the run
threw, and the scenario somewhere scripts a param key valued `undefined`. Any
failure inside those ten scenarios was that class, reported by nothing. A seeded
wire bug — one extra own key on every request's params — put 627 goldens in
`unclassified` and still left `params-undefined` at exactly 33, all corrupted;
scoped to `linear.listIssues` alone it stayed green.

The evidence now carries the step the scripted transport refused, the paths the
scenario values `undefined` on it, and every path where the params that arrived
differ from the ones it scripts — read off the frames the page posted, not off
the message. The class needs every path that moved to be one of the scripted
`undefined` ones, and at least one to have moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): cancel the stream a refused frame belonged to, not just end it

Ending a stream deletes the page's record and posts nothing back. That is right
for `end` and `error`, where the shell has already retired it, and wrong for a
frame this reader refused: the shell is still serving that stream, so later valid
frames on the id were dropped with no diagnostic, no ack went back, and the only
release left was the host's overflow backstop, which counts unacked frames and so
never fires on a stream that has gone quiet. A newer shell adding a member to a
closed list — a new `binary.format`, say — lands exactly there.

`abandon` posts the cancel and tells the listener; `failExchange` picks it over
`end` for the one path where the shell has not let go.

Six goldens move to a new named class for what the cancel changes in the replay.
The unsubscribe it publishes is a physical payload the native run has no
counterpart for, and it takes the recorder's next occurrence name for that
method, so the scenario stops matching before there is a recording. Four came
from `result-absent-observation` and two from `write-ordinal`; the sum over the
corpus is unchanged and nothing stopped replaying byte-identically.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold the bridged pin to the size of the corpus

Each class in the pin is an upper bound and `identical` a lower one, so nothing
stopped a single class being loosened on its own: raising `params-undefined` by
one passed every assertion the flagged run makes. The comment already claimed the
total was fixed at the corpus; this counts the goldens on disk and checks it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name the two refusals that can settle nothing at all

`oversized` is decided on the raw string and `malformed-json` on a parse that did
not finish, so neither frame ever yields an id and neither can settle the
exchange it was answering. Nothing on the page settles those: `close` or a shell
replacement releases the slot, and otherwise it is held for the life of the
document. Neither arises from a host that is behaving — it chunks at the frame
cap and answers a body over `BRIDGE_MAX_REPLY_BYTES` with an `error` frame — but
the boundary was unstated and untested, which is how it reads as covered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say which way each number in the bridged pin may move

The comment said the pin moves down and never up, two lines above a class that
went from 338 to 341 when a fix changed which difference a run meets first. Both
are true of different halves of it, so say which: `identical` only moves up, a
class only moves down, and two excluded classes may trade members in one edit
that leaves the sum alone. The sum is now checked, so that trade cannot hide a
golden that stopped replaying byte-identically.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin which goldens are in a class, not only how many

A count is blind to a trade. Every predicate reads the scenario rather
than the frame the page refused, so a golden that started refusing for
real can walk into an excluded class while another walks out, and the
counts, the sum and the `identical` floor all still hold.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 16:52:53 -04:00
Jinwoo Hong fc3a5d7326 chore(mobile): repin the recording corpus to main's tip (OTA phase C, C0.8) (#21510)
C0.5 pinned `baseline` to its own branch commit, which the squash-merge made
unreachable, so `rpc-recording-pin-guard.mts ancestry` fails on main and
`--record` refuses to run at all. Repin to main's tip and refresh every header
from it.

Header-only, and the corpus proves it: across all 787 goldens exactly two
distinct lines changed, the old `baseline` and the new one. `recorderSha256`
did not move, because nothing under the recorder's own directory did.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 14:55:13 -04:00
Jinwoo Hong f24f38bd41 test(mobile): certify the golden corpus through the page bridge (OTA phase C, C0.5) (#21485)
* test(mobile): record the golden corpus through the page bridge (OTA phase C, C0.5)

`ScriptedRpcTransport` gains one seam: an optional `wrapClient`, applied
between the recorder's own instrumentation and the logical client, so a
recording can be driven through another transport without the runner
learning which one. `runRecording` passes it through.

The instrumentation moves outside the seam. `ordinal` orders a logical
`sendRequest` against the recording's device writes and physical payloads,
and the operation makes that call at the same moment with or without a
wrapper; stamping it under one times the wrapper's forwarded send instead,
which the unwrapped recording has no counterpart for. Measured: 131 goldens
move their sender ordinal under the seam, none above it. The name a physical
send is filed by therefore becomes a queue rather than one slot, taken on the
inside of the wrapper — a wrapper that forwards on a microtask arrives after
the next logical call has been made, and one slot hands both sends the second
name. Underflow throws; a wrapper that drops a send is a finding.

`rpc-recording-through-bridge.test.ts` is the harness: every golden recorded
again with `BridgeRpcClient` over an in-memory port pair to a `createBridgeHost`
holding the scripted client, compared body for body against the committed file.
One FIFO per direction, delivery on a microtask, and the `init` handshake
delivered in place before anything mounts.

It is opt-in behind `RPC_FOUNDATION_BRIDGE=1` and does not pass yet. 391 of 787
goldens diverge, for four causes that are findings about the bridge rather than
about the corpus; the suite's header names each one with its count, and flipping
the gate is one line once they close.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the recording corpus and refresh its headers (OTA phase C, C0.5)

The C0.5 seam edits `scripted-rpc-transport.ts` and `run-recording.ts`, both
inside `RECORDER_DIRECTORY`, so `recorderSha256` moves and every golden's header
names an engine that no longer exists. `baseline` moves with it, from
1e3795de99 to ddbb194585, which is main's tip and the tree these were recorded
against; the recorder's fence is clean at that commit, tracked and untracked
alike.

Recorded whole rather than in part, as the README requires: a partial refresh
would leave the corpus pinned to two different trees.

The delta is header-only, and that is checked rather than asserted. All 787
goldens changed exactly two lines each, and across the corpus and the manifest
the only keys that moved are `baseline` and `recorderSha256`, to one value
each. No observation moved, which is the claim the pair of digests exists to
make: the engine changed, what it observes did not.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): correct the bridged-parity counts to what the landed tree measures

The counts in the harness's header were read off an intermediate run: 391 is
the divergence with the reply schema's `_meta` requirement already widened, not
the divergence this tree has. Against the tree as it lands, 763 of 787 goldens
diverge and 24 replay byte-identically; widening `_meta` is worth 372 of them
and takes the count to 391.

The same intermediate run is behind a wrong sentence in this lane's first
commit message, which says no sender ordinal moves with the instrumentation
above the seam. Seven do, plus six payload ordinals, and they are the subscribe
reorder cause 3 now names. 131 was the count below the seam.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repartition the bridged-parity classes from a measured run

The header's last two counts were read off an intermediate measurement. Classified
from the failure text of a run with the `_meta` arms widened, the 391 that remain
partition 345 / 33 / 13, not 340 / 13, and the ordinal class is not a reorder on the
wire: the page posts its frames in call order and they publish in that order, while
the writes the operation makes above the bridge land a delivery earlier.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): let the shared bridge port pair carry any shell client

The golden recorder needs the pair the bridge tests already have, holding its own
scripted client rather than the fake, delivering the handshake in place so a screen
can mount in the same turn, and able to answer one counterfactual: what the page
would have done had the shell posted a field it does not. Every other test keeps the
shape it had under `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): catch a wrapper that swallows a send instead of mislabelling the next

The seam took the next name off the queue without checking it belonged to the method
now on the wire, so a wrapper that rejected without forwarding left its name behind
and the following payload was filed under it. Underflow already threw; this is the
other half, and it is the half that is silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the bridged-parity partition by rule instead of by reading it

Counting the classes by eye is how two of them drifted. Every diverging golden is now
named by a rule over the frames and the scenario, each name is disjoint, and a golden
that fits none of them fails the run. The first class needs a second replay with
`_meta` supplied, because nearly every golden is refused some reply for that field
and only supplying it says which ones the field explains: 372 / 338 / 7 / 33 / 13
over 787, with 24 byte-identical. CI runs it so the counts cannot drift again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the recording corpus and refresh its headers

The seam's name check and the export the parity suite reads both sit inside
`recorderSha256`, and the port pair the suite now borrows sits inside the recorder's
fence, so the pin moves to this branch's tip and all 787 headers follow. Two lines per
golden, both of them header: baseline and recorderSha256, and no body byte anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the recorder's own boundary ratchets green

Two of them were red, and the first has been since the seam landed, because that
commit was checked against the recorder's directory instead of the whole mobile
suite. The engine may not import a suite, so the parity classifier moves beside the
recorder rather than inside it, where the golden digest would also stop claiming a
recording's provenance for a rule that only reads failures. And the raw-port ceiling
for the scripted transport rises from five to seven: the seam needs one layer between
the operation's call and the logical client, and its two references are what that
layer costs. A named argument type takes back the third.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the corpus onto the ratchet fixes and refresh its headers

Supersedes the refresh two commits back: the scripted transport moved again, so both
the digest and the pin do. Two lines per golden, both header, no body byte anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stamp the counterfactual on replies only, not on events

An `event` frame carries a `payload` too, so keying off the key rather than
the type put `_meta` into subscription bytes. The page reads an event payload
as `z.unknown()`, so nothing refused it and the classes did not move, but the
tool that names a divergence must not author one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 13:50:55 -04:00
Jinwoo Hong 209d2d8df6 build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) (#21475)
* build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5)

The page bundled as one 8.16 MB script because every route was a static
import. The route manifest now defers each screen behind `import()`, the
build is esm with splitting on, and the document loads the entry as a
module. What the browser parses before the first route can paint drops
from 8.16 MB to 908 KiB; the whole page still weighs the same.

Two budgets hold it: the chunk count, which catches a split running away,
and the bytes the entry reaches by static import, which catches it
collapsing back. The second is the one that matters, and it is measured
from esbuild's metafile because only that says which import is static.

The RequireContext stays synchronous, since expo-router reads keys() to
build the route tree before anything renders. A lazy module cannot answer
`unstable_settings` or `ErrorBoundary`, which expo-router reads off the
namespace, so a test holds that no route in the subtree exports either.

The render check now waits for the route's own text: the entry's mount
signal lands while the route chunk is still being fetched.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read a route's synchronous exports from esbuild, not a regex

`export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all
reach the namespace without matching the declaration pattern the guard was
matching, so the lazy manifest dropped the boundary and the page painted blank.
A star re-export is now reported rather than read as clean.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say that the entry budget is not a per-route opt-out

Measured: statically importing one route already breaks the 3 MiB bound for 5 of
the 14. The hatch only works for a layout node, which is the only place
expo-router reads a synchronous export from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): derive the chunk ceiling from the route count

64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's
routes would have failed on a number measured before they existed. Four per
route plus 16 tracks the measured slope; the entry-bytes bound stays the real
budget.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the web entry's dead suspense boundary

expo-router wraps every screen in its own, so this one never fires; all nine
render checks stay green without it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a client-side navigation fetches the next route's chunk

Goes red with splitting off: the tasks screen paints out of the entry and no new
script is fetched.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name every bundle output by its bytes, not by esbuild's path hash

esbuild's [hash] is over the metafile's input keys, which are paths relative to
absWorkingDir, so a checkout at another depth or with node_modules as a symlink
named a byte-identical chunk differently and shipped a different buildId for one
commit. Outputs are now renamed leaves-first to the sha256 of their final bytes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fail the build on a route the lazy manifest would strip

The guard ran only in a test while the docstring said it failed the build. It
now runs in bundleMobileWebApp and names the route and the export.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): derive the asset ceiling from the chunk ceiling and the images

A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset
count would have failed first and named the count instead of the split. Chunks
plus images plus the document keeps the chunk ceiling the one that trips.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the route-manifest tests out of the bundle builder's

The builder's test file passed 600 lines. The route manifest, the synthesized
RequireContext and the web entry are their own subject and move together.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the export guard the builder's route-source loaders

Without .js as jsx the guard reported a React Native .js route carrying JSX as
"JSX syntax extension is not enabled" instead of reading its exports.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the navigation fetches the tasks route's own chunk

"some new script arrived" passed on any fetch. The builder now names the chunk
each route lands in, read off the metafile, and the check asserts that exact
path arrived and was not already loaded.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): resolve a route's realpath before matching it to its chunk

esbuild writes metafile input keys after resolving symlinks, so every scratch
route tree under /var on macOS reached no output and failed the build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fail the build when the asset ceiling outgrows the shell's map

The derived ceiling had no upper bound, and the native shells return null for a
manifest over their own 256 rather than truncating it. At 42 images the formula
crosses that at 50 routes, inside what Phase C adds, so the build would stay
green while the phone got nothing. The number is read from the contract through
esbuild, not restated here.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): cover the two hard stops in the content-addressed naming

Both throws only ran through a whole bundle before, where neither can be
provoked. A cycle and a route no output claims are now asserted directly; each
test goes red when its throw is removed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): exit the app-bundle build on one line, not a stack

The route-export guard fails this script by design, and a raw stack put the
route and the export name under twelve frames of node internals. Mirrors the
verifier's exit; the message is printed as thrown because every throw on this
path already names its source.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 11:58:42 -04:00
Jinwoo Hong ddbb194585 feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4) (#21467)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3)

One page document's end of the bridge: page frames in through the C0.1
reader, one RpcClient behind it, host frames out. Requests forward with the
arity the page used and answer with the verbatim RpcResponse, chunked when it
is over the frame cap; a rejection crosses as the five-field capture instead.
Subscriptions carry a seq and an unacked window, and end with `overflow`
rather than dropping frames a reader cannot see are missing.

The fence is structural: the protocol names no host, so the client is
whichever this host was built with, and the in-flight caps the page is told
about in `init` are enforced here rather than trusted from there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3)

The channel opens on the session B4 put on screen and closes with it. The
session id is B4's: nothing new is minted, and a remount is a new one, which
is what makes a dead page's frames fail the native origin check.

Both halves are stamped with the session they belong to, because React swaps
refs during the commit and runs the retiring effect's cleanup after it — a
host disposing on a remount would otherwise post its teardown into the page
that replaced it. `bridgeEnabled` is derived from the session step alone,
since the native side treats a prop change as a reload.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the bridge fence holds for traffic, not just for answers

A mutation that dropped the post-teardown guard in `receive` survived: the
teardown case only fed a frame whose answer the outbound guard already
swallowed, so nothing observed that a dead page could still reach a live
client. Both teardown paths now feed a request, a subscribe and a notify,
and assert the client saw none of them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the hook's frames through the page's own reader

`JSON.parse` returns `any`, and taming it with an assertion is a cast the
gate refuses and a check nobody gets. Reading each posted frame through
`readBridgeHostMessage` types it and proves the same thing the host's own
suite does: a frame the page would refuse is a frame that never arrives.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the bridge host as a raw request port owner

The boundary ratchet reads a `.sendRequest` access as a call site, and the
host has three: one per arity the page can use. It is not a call site. It
picks no method, reads no reply and decides no acceptance — the page names
the method and runs the typed operation over the client this carries, which
is what the C0 design put page-side so `runRpcOperation` stays unchanged
there. That makes it an owner, beside the socket and relay senders, not a
migration backlog entry.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4)

Every member of the native contract, carried over the C0.1 envelope so the
screens above it cannot tell a bridge from a socket: requests keep the arity the
caller used, a host RpcFailure resolves as data while a rejection is rebuilt with
its class and its delivery-unknown mark, subscriptions stream with periodic acks,
and the synchronous getters read a cache primed by init rather than answering
before they know.

A state whose generation went backwards is refused and re-asked for, because a
shell rebuilt under the page makes what the page holds the newer of the two.
close settles what the page owns and never touches the shell's client, which the
native screens and the host catalog still share.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): run the page client against the shell host over an in-memory port pair

One FIFO per direction and delivery on a microtask, which is what C0.5's golden
replay needs: a subscribe that overtook a sendRequest would move the recorder's
shared ordinal, and anything stronger than a microtask moves a virtual
millisecond. Every member round-trips through the real host over a fake client;
the frame-level suite covers what no pair can reach, including the handshake
backoff, refusals and the binary lane C6 will fill.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the page bridge client as a raw request port owner

Both ends of the bridge hold the port as a transport: one forwards raw requests
and the other offers them, and neither picks a method or reads a reply.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the assembler discard no abandoned request can reach

A request is only abandoned when its frame never left the page, so the shell was
never told the id and no part can have arrived under it. Says what actually keeps
an omitted param omitted while it is here: JSON drops an undefined value, so the
spread states the intent rather than producing the result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): close the three gaps a mutation sweep found in the page client

A settled id has to give its assembler slot back, or 64 replies that were cut
short before an error leave the page unable to read the next chunked one. Close
says goodbye once rather than cancelling each stream first. And the read guard is
only observable through a port that ignores its own unsubscribe, which is what
the harness can now be.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove a stream that overflows inside subscribe is unsubscribed

A client that emits synchronously from `subscribe` can retire a stream before
its unsubscribe exists to be stored. The identity check that calls it instead
had no test; deleting it left the suite green while the client's stream leaked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the bridge host over in the commit, not after it

A client swap that keeps the session id leaves the handler's own fence inert:
until the passive effect ran, a native frame reached the retiring host and the
client it closed over. A layout effect swaps both inside the commit.

Teardown on unmount now runs while the view is still attached, so a pending
request is answered delivery-unknown instead of being dropped on the floor.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold a refused page frame to one warning per page

A page that sends one bad frame usually sends many, and a line each buries the
first — the one that says why. Same bound the host already keeps on a failing
post, applied per kind and reset when a new page gets a new host.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound the page's terminal viewport at the bridge contract

A viewport crossing the bridge is written into the cached subscribe params of
every stream naming that terminal, including the native terminal screen's, and
the desktop refuses cols over 1000 or rows over 500 when those streams
resubscribe. Unbounded, one page could kill streams it never opened; the frame
is refused instead, and the bound is pinned to the desktop's own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the page's close from latching the bridge host shut

One view carries every document the shell loads, so the page that says `close`
is not the last one. A latched host dropped the next document's `ready` in
silence, and a page that re-sends `ready` on a backoff would retry forever with
nothing posted and nothing logged. Close now cancels what the page owned and
leaves the host live; only dispose shuts it, and a frame arriving after that is
diagnosed rather than dropped.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a throwing client or post inside the bridge host

The `state` frame is sent from inside the client's own state-change fan-out and
a notify runs on the native event handler that delivered the page's frame, so a
synchronous throw from either escapes into a loop the bridge does not own and
takes unrelated listeners with it. Both are fenced and reported once.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove an ack releases the stream's unacked bytes

The frame window reopens on ack through the splice, so deleting the byte
release left every existing test green while a long-lived stream of large
frames would end with overflow on its first frame after an ack.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pass the commit-window harness its children as a prop

`createElement`'s variadic children do not satisfy a props type that declares
`children`, so the file dropped out of the tests typecheck ratchet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): render the harness from the commit-window wrapper, not as children

A props type that declares `children` is what `createElement`'s variadic form
does not satisfy, and passing it as a prop instead trips the react rule. The
wrapper renders the harness itself, which is the parent position the layout
effect ordering needs anyway.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pin the desktop viewport bound by reading it, not importing it

Mobile may not pull an rpc-contract *value* into its bundle, and the boundary
test that enforces that scans this test file too. The pin reads the schema's
own source instead, so drift in either bound still fails loudly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): settle a refused subscribe as the stream it was

The shell answers a refused `subscribe` with `error` on the stream's id.
Routing that to the pending requests dropped it, because no request is
open under that id: the page heard nothing and kept the slot forever.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report a reply or an error the page has no id for

Silently dropped before. Nothing recovers it in place, but a frame the
page cannot place means the two ledgers disagree, which is worth a line.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say disconnected on close instead of going silent

Every native client publishes the transition and keeps answering its last
snapshot; the screens read both. The page's client cleared the cache
instead, so a closing page left its listeners on a dot that never moved
and every getter throwing underneath it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let a closed page client go inert, not throw

An unmounting screen still calls, and nothing on a teardown path catches.
Subscribe hands back a no-op dispose and the notifies do nothing, as the
native client's do, and a request rejects rather than throwing past the
caller's catch. A call before init still throws: that one is a bug.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): lift the init handshake out of the page client

The backoff that asks the shell for a session is its own concern, and the
client had grown past the file's line budget holding it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the cancel a page owes for a stream already ended

A screen unmounts on its own schedule, routinely after the shell gave up
on the stream. Only the double-dispose order was covered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state what the page client does after close

The doc gave the pre-init rule and stopped; the after-close rule is the
opposite one, and subscription failures have no channel but a diagnostic.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): read the shell's page channel as a client transport

The document-start installer leaves `postMessage` and one `onmessage`
slot, the intersection of what the two platforms inject. A page opened
outside the shell has no global at all, so reading it answers null rather
than throwing: the bundle still has to open in a browser.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the page its bridge client instead of a placeholder

The web provider now builds BridgeRpcClient over the shell channel and
mounts nothing until `init` lands: every member throws before a session,
and a screen that rendered first would record its first frame against a
client that has none. Outside the shell there is no session coming, so
the placeholder stays and the route tree mounts at once, which is what
the Route A render check exercises.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): declare the page provider test's probe instead of casting it

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the bridge to one document at a time

A page's `close` now ends that document's turn: until the next `ready`
claims the view, every other frame is dropped and diagnosed instead of
reaching the client, and nothing is posted. Without the fence a straggler
from the closed document was still forwarded, and a `state` frame from the
still-running client landed in the replacement document before its `init`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the request cap against the calls, not the page's ledger

`sendRequest` has no cancel, so a request the page cancelled or closed out
keeps running on the desktop until it answers. The cap now counts those
calls until each settles; counting the pending map let a page interleaving
`close` with batches hold many more than the cap `init` advertises.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ack ratio to the shell's window, not a copy of it

The ack interval test held 256 and 4 MiB as literals, so narrowing the
shell's window would have left the page acking too late with the test still
green. The comment naming the test that pins the ratio pointed at the wrong
file.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give back the slot of a subscribe that never left the page

A post that threw left the stream in the page's ledger with nothing open on
the shell's side, so 32 of them exhausted the subscription budget for the
life of the document. The slot goes back and the listener hears a terminal
error result, which is what the native client does with the same failure.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): end a page stream through its listener, not only the log

A stream the shell ends or fails now reaches its listener as a terminal
error result, the way the native client's emitError does. A consumer reads
that result: host-worktree-refresh clears the flag that says the event
stream is live, and without it the worktree list stops updating for the life
of the document. A dispose the page asked for stays silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): settle the old shell's work before adopting a new session

A second `init` naming a different sessionId is a rebuilt host with empty
tables: every pending request and every open stream the page still held
belonged to the shell that is gone. They now settle delivery-unknown and end
through their listeners before the new session is adopted. A second `init`
for the same session is what a re-asked `ready` earns, and keeps everything.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): take the page's streams out of the ledger before failing them

A listener that resubscribes while the old shell's streams are being ended
is opening one against the shell that is arriving; draining the map first is
what keeps this loop from tearing that one down too. Fixes the lint the
previous commit left behind.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say how long a reply assembler's refusal actually lives

The tombstone is not kept forever: the request ledger discards the id as it
settles the caller, so it normally outlives only the rest of the reply that
raised it. The bounded map is there for the ids nothing settles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ceiling the ready backoff stops widening at

An unclamped backoff reads the same for the first minute and then leaves a
page asking once an hour into a shell that is still booting behind it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say why the document fence carries no epoch

Page frames reach the shell through one native listener per platform, so a
straggler from the closed document lands before the next document's `ready`
and the flag alone catches it. An echoed epoch would be a wire change for
nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 10:51:38 -04:00
Jinwoo Hong f2be6299c8 feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) (#21459)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3)

One page document's end of the bridge: page frames in through the C0.1
reader, one RpcClient behind it, host frames out. Requests forward with the
arity the page used and answer with the verbatim RpcResponse, chunked when it
is over the frame cap; a rejection crosses as the five-field capture instead.
Subscriptions carry a seq and an unacked window, and end with `overflow`
rather than dropping frames a reader cannot see are missing.

The fence is structural: the protocol names no host, so the client is
whichever this host was built with, and the in-flight caps the page is told
about in `init` are enforced here rather than trusted from there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3)

The channel opens on the session B4 put on screen and closes with it. The
session id is B4's: nothing new is minted, and a remount is a new one, which
is what makes a dead page's frames fail the native origin check.

Both halves are stamped with the session they belong to, because React swaps
refs during the commit and runs the retiring effect's cleanup after it — a
host disposing on a remount would otherwise post its teardown into the page
that replaced it. `bridgeEnabled` is derived from the session step alone,
since the native side treats a prop change as a reload.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the bridge fence holds for traffic, not just for answers

A mutation that dropped the post-teardown guard in `receive` survived: the
teardown case only fed a frame whose answer the outbound guard already
swallowed, so nothing observed that a dead page could still reach a live
client. Both teardown paths now feed a request, a subscribe and a notify,
and assert the client saw none of them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the hook's frames through the page's own reader

`JSON.parse` returns `any`, and taming it with an assertion is a cast the
gate refuses and a check nobody gets. Reading each posted frame through
`readBridgeHostMessage` types it and proves the same thing the host's own
suite does: a frame the page would refuse is a frame that never arrives.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the bridge host as a raw request port owner

The boundary ratchet reads a `.sendRequest` access as a call site, and the
host has three: one per arity the page can use. It is not a call site. It
picks no method, reads no reply and decides no acceptance — the page names
the method and runs the typed operation over the client this carries, which
is what the C0 design put page-side so `runRpcOperation` stays unchanged
there. That makes it an owner, beside the socket and relay senders, not a
migration backlog entry.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove a stream that overflows inside subscribe is unsubscribed

A client that emits synchronously from `subscribe` can retire a stream before
its unsubscribe exists to be stored. The identity check that calls it instead
had no test; deleting it left the suite green while the client's stream leaked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the bridge host over in the commit, not after it

A client swap that keeps the session id leaves the handler's own fence inert:
until the passive effect ran, a native frame reached the retiring host and the
client it closed over. A layout effect swaps both inside the commit.

Teardown on unmount now runs while the view is still attached, so a pending
request is answered delivery-unknown instead of being dropped on the floor.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold a refused page frame to one warning per page

A page that sends one bad frame usually sends many, and a line each buries the
first — the one that says why. Same bound the host already keeps on a failing
post, applied per kind and reset when a new page gets a new host.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound the page's terminal viewport at the bridge contract

A viewport crossing the bridge is written into the cached subscribe params of
every stream naming that terminal, including the native terminal screen's, and
the desktop refuses cols over 1000 or rows over 500 when those streams
resubscribe. Unbounded, one page could kill streams it never opened; the frame
is refused instead, and the bound is pinned to the desktop's own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the page's close from latching the bridge host shut

One view carries every document the shell loads, so the page that says `close`
is not the last one. A latched host dropped the next document's `ready` in
silence, and a page that re-sends `ready` on a backoff would retry forever with
nothing posted and nothing logged. Close now cancels what the page owned and
leaves the host live; only dispose shuts it, and a frame arriving after that is
diagnosed rather than dropped.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a throwing client or post inside the bridge host

The `state` frame is sent from inside the client's own state-change fan-out and
a notify runs on the native event handler that delivered the page's frame, so a
synchronous throw from either escapes into a loop the bridge does not own and
takes unrelated listeners with it. Both are fenced and reported once.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove an ack releases the stream's unacked bytes

The frame window reopens on ack through the splice, so deleting the byte
release left every existing test green while a long-lived stream of large
frames would end with overflow on its first frame after an ack.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pass the commit-window harness its children as a prop

`createElement`'s variadic children do not satisfy a props type that declares
`children`, so the file dropped out of the tests typecheck ratchet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): render the harness from the commit-window wrapper, not as children

A props type that declares `children` is what `createElement`'s variadic form
does not satisfy, and passing it as a prop instead trips the react rule. The
wrapper renders the harness itself, which is the parent position the layout
effect ordering needs anyway.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pin the desktop viewport bound by reading it, not importing it

Mobile may not pull an rpc-contract *value* into its bundle, and the boundary
test that enforces that scans this test file too. The pin reads the schema's
own source instead, so drift in either bound still fails loudly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the bridge to one document at a time

A page's `close` now ends that document's turn: until the next `ready`
claims the view, every other frame is dropped and diagnosed instead of
reaching the client, and nothing is posted. Without the fence a straggler
from the closed document was still forwarded, and a `state` frame from the
still-running client landed in the replacement document before its `init`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the request cap against the calls, not the page's ledger

`sendRequest` has no cancel, so a request the page cancelled or closed out
keeps running on the desktop until it answers. The cap now counts those
calls until each settles; counting the pending map let a page interleaving
`close` with batches hold many more than the cap `init` advertises.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 10:46:47 -04:00
Jinwoo Hong 381a3da46f feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449)
* refactor(mobile-web): share the bundle manifest assembly with a second builder

Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and
the helpers the Phase C app builder needs become exports. No behaviour change
to the shipped bootstrap bundle.

The CRLF guard grows two exemptions it needs once it is pointed at mobile/src:
the image and font extensions .gitattributes already pins -text, and the
gitignored webview engine modules the postinstall writes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): web entry for the host route tree, and its two transport siblings

The entry mounts app/h on react-native-web through expo-router's own ExpoRoot.
It lives inside mobile/ so one React resolves, and supplies RpcClientProvider
itself: the route tree starts below the native root layout that owns it.

route-manifest.ts is a real typed module whose body the builder replaces --
esbuild has no require.context. A virtual specifier would need an ambient
declaration and would leave the entry unchecked.

Two .web.* siblings, both listed with a reason in web-overrides.json: the
transport substitution point (a placeholder client until C0.4 lands
BridgeRpcClient) and the device token store, whose native path imports
expo-secure-store, which is {} on web.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(build): build:mobile-web:app, the phone's host routes bundled for the web

Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app,
with the same manifest and the same two-scratch-build determinism check. Dark:
build:mobile-web, packaging and the A2 census are untouched, and C1 is what
flips build:release.

Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin
hashed assets rather than data: URLs, because the shell's CSP sets img-src
'self'; the render check under that exact header is what found it. The script is
referenced root-absolute for the same reason a <base> tag cannot be used: the
document is served at every route depth and base-uri is 'none'.

The budget sits below the contract's per-asset ceiling so growth trips a build
rather than a refused asset on a phone. esbuild splitting does not lower it:
one entry with only static imports emits one chunk (measured).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let React Native Web paint under the shell CSP

RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so
style-src 'self' blocks every rule and the page renders unstyled. Measured, not
predicted: the render check serves the document under this exact header and
reported the violation.

'unsafe-inline' is granted to style-src and nothing else. script-src 'self'
holds, which is the directive that decides whether page code can arrive any way
other than as a fetched same-origin script. The test now pins that scoping
rather than rejecting the token everywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: prove the Route A app bundle on every PR

A dedicated job, for the same reason the browser provider has one: it needs
mobile/node_modules and a real browser, and the sharded test matrix would pay
for both on every shard. It builds the bundle, verifies it, and runs the
builder, override-census and render suites. It ships nothing.

The mobile_web_app signal is lifted out of should_run the way static_analysis
is. A mobile-only diff is desktop-irrelevant and skips every gated job, and
that is exactly the diff that changes the page this job builds.

Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml
join the installer cache keys in the two workflows that build an installer off
a hashFiles key, since beforePack requires out/mobile-web and a mobile-only
change must miss those caches rather than reuse a stale build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): pin the shipped builder against the app builder's own module name

The assertion named a specifier that no longer exists, so it held vacuously.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the RN Web style-src grant in the Swift checks

The Swift twin of the Kotlin CSP test still required style-src 'self' and
no unsafe-inline anywhere, so it trapped on the approved grant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): make the Route A render check name what each route paints

The check asserted only "some html, no errors", which expo-router's Unmatched
screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green.
Each route now asserts content only its own component produces, and the
unmatched case asserts the screen positively so the negatives discriminate.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): read the shell CSP past the comments that quote directives

Both constants document themselves with // comments containing quoted
directive text, which the quoted-string scan picked up as directives. One
parser now drops comment lines, and iOS and Android go through it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(build): honour a .web.* route sibling in the app bundle

Routes were imported by absolute path with the extension, so esbuild's
resolveExtensions never applied and a .web.tsx under app/ was dead code the
census still accepted. The manifest now carries a key and a module: the key
stays the native filename so the URL does not move, and the module is the web
sibling when one exists.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): tie each named shim to the esbuild option that implements it

The shim list was asserted against a literal copy of itself, which passes
however the build is configured. Each entry now carries an appliesTo that
reads its own option, checked against the real options object.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(build): line up the CRLF exemptions, the budget comment, and the job scope

The builder loads .gif as a file but neither .gitattributes nor the CRLF scan
exempted it, so the blanket eol=lf pin would have rewritten one. A test now
keeps the two lists in step. The Phase C byte budget's comment sat on the
asset count, and a root package.json edit could change build:mobile-web:app
without running the job that proves it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(build): satisfy the index-check lint rule in the CSP parser

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: key the installer caches on the mobile page trees too

beforePack builds the mobile web bundle into the installer. Today those bytes
are Phase A's, which src/** already covers, but once C1 flips the entry to
mobile/app a page-only change would hit a cache holding a stale installer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): skip the bundling tests where mobile dependencies are absent

The sharded `test` job collects config/scripts/**/*.test.mjs and installs no
mobile dependencies, so the two new suites failed there on "Could not resolve
react-native-web". They now skip themselves with a message naming the job that
runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing
install fails it instead of skipping everything it exists to prove.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(build): scan mobile/packages in the .web.* census

The census claimed the app entry never resolves into packages/, but the
dictation hook imports @orca/expo-two-way-audio and the built script carries
ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and
planting a .web.* in each scanned tree proves the scan is not passing because
a tree happens to be empty.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): assert the route exclusions against a tree that has them

mobile/app holds no test, spec or +api file, so the exclusion rule was
asserted against a tree it could not fire on. A scratch tree plants one of
each; dropping the rule now fails this test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): 404 unknown file paths in the render check's page server

The server answered every path with the document, so pointing publicPath at
/wrong-prefix still rendered three green routes: the script is fetched from
the one prefix that is served. A path naming a file now has to come out of the
bundle, which is what the shell's manifest map does.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): cover the app bundle verifier's own checks

The verifier had no test. One doctors the buildId, which the packaged assert
catches; the other rewrites the tree so every digest still agrees and only the
two fresh builds can tell, which is what a stale out/ looks like. Deleting
either check now fails a test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(build): tidy the app bundle comments and the job's path prefixes

Drops an export nothing read, merges two comments that had drifted apart from
the constant they describe, and corrects the claim that the job runs on every
PR when it is path-gated. package.json leaves the prefix list because
GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it,
since the page resolves a .web.ts out of there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(build): merge the duplicate node:fs/promises import in the census

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): redirect the hybrid shell route on the web page

app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls
requireNativeViewManager at import. In a browser that throws before React
mounts, and the route manifest imports every route statically, so one native
route left the whole page blank at every URL.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): fail the render check with the error that stopped the mount

The check waited on "#root has children" with Playwright's animation-frame
polling, so a route module that threw at import read as a bare 30s timeout
naming nothing. It now waits on a mount attribute the entry sets after the
router commits, polls on a timer, and races the wait against the first
uncaught error so the failure carries it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): answer the favicon the render browser asks for

CI resolves the runner's Google Chrome, which requests /favicon.ico; the
bundled headless shell does not. The bundle carries no icon, so the server
answers 204 rather than turning a browser habit into a console error the
render assertions read as a page fault.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): settle the render check's uncaught-error race without rejecting

The entry throws during goto, before anything awaits the race, so a rejected
promise surfaced as an unhandled rejection beside the real failure. The same
signal now resolves with the error.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the page transport in the raw request port inventory

The placeholder client implements the port, so the boundary test counts it as
an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient
replaces it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 09:50:37 -04:00
Jinwoo Hong 47d107cf2e feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window

`useHostStatusGates` settled the same closed gates for a host that answered
`status.get` with no capabilities and for one whose status nobody could read:
both paths produced an empty capability list and an `ok` verdict. A caller that
walls on a missing capability cannot tell those apart, and the mobile web
bundle's wall is terminal, so it must never fire for the second.

`statusReadable` distinguishes them. `hostProtocolWindow` exposes the two
protocol numbers the hook already read for `evaluateCompat`, as the reply's own
fields, so the bundle wall can evaluate its own window without a second
`status.get`. Both are additive; no existing consumer changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): add the hybrid shell flag and the generation path both loaders demand

`orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same
shape as the terminal autocomplete flag.

`generationDirectoryPath` converts the store's `file://` uri to the absolute
path the native shell view requires: both `MobileWebShellGeneration.load`
implementations refuse anything without a leading slash, and `expo-file-system`
only ever hands out uris.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the hybrid shell session as a pure reducer

Every decision the route makes, as `(session, event) -> (state, effects)`: the
capability wall, the lazy sweep and cache read, the manifest check, the cached
build-id hit that skips paging, the offline open with no compat check, and the
three recovery rules the native shell view's contract states.

Pure, so the rules are table tests rather than a simulator run. Two latches sit
beside the state because both outlive it: `retriedOnce` spans the delete and
refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced
by a `ready` under a new session id.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4)

Wires the four Phase B and A pieces together and adds no decision of its own.
`h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native
`desktop-too-old` wall still applies above it. With the flag off — every store
build, since the only writer is a `__DEV__` Troubleshoot toggle — the route
redirects to `h/[hostId]` and the screen is never constructed, so nothing is
fetched, written or swept.

The runner owns only the impure edges and checks an epoch before every dispatch,
so an unmount, a host change or a retry abandons work in flight and aborts a
download that would otherwise hold four of the host's read slots. The native view
is keyed on the session id, which is what makes the reducer's remount a rebuilt
WebView with every fence reinstalled.

A census test pins who touches the flag: the route reads it, the developer row
reads and writes it, and the key itself lives in one module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the hoisted test doubles instead of asserting them

The changed-code casting gate refuses `as` in new code, and these three were
only widening an empty literal. An annotated `vi.hoisted` factory does the same
job under a check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress

Found on a simulator with the paired desktop stopped: the client never settles
on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting`
-> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping
`reconnecting` to "still connecting" left a phone holding a verified cached
generation on `checking` forever instead of opening it, which is the one case
the offline rule exists for.

`connecting` and `handshaking` are the first dial and still wait; everything
else is unreachable. The mapping moves next to the reducer it feeds, because it
is a decision and the runner is supposed to hold none.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): build the reachability stub instead of asserting it

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move the shell session vocabulary into its own module

Pure move, no behaviour: the states, events, effects and gates the reducer
and its runner share now sit beside the reducer rather than inside it, so
the transition rules have room to grow under the file's line budget.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop a shell effect result whose flow has been superseded

Every restart of the flow bumps a number the effects of that run are stamped
with, and a result echoes it back: a manifest read still in flight when the
socket drops used to reject after the offline path had already opened the
cached generation, replacing a displayed workspace with a download failure,
and a status refetch arriving mid-check used to run the cache read and the
download twice. The gates restart no longer clears the remount latch either;
only the retry button does, so a reconnect cannot grant a second remount.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census the flag across modules, not just src and app

The native view tree was outside the scan, so a reader added there would
have passed an assertion that reads as exhaustive. Proven by adding one to
the shell view module: the census fails.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let only the state that mounted the view hear the view

A native batch reports two failures in a row, and the reducer applied both:
document-load-failed started the delete-and-refetch, render-process-gone
then made it terminal without a new flow, and the cache read the recovery
had already asked for dragged the session back to checking behind a failure
screen. A report arriving outside `ready` is from a view that is no longer
on screen, so it changes nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say a host status could not be read instead of spinning on it

A transient status.get failure settles the gate unreadable and nothing probes
it again, so the route sat on "Checking host" for as long as anyone left it
there and Try again re-read the same settled answer. It now says what
happened and offers no retry, and a status that does become readable picks
the flow back up on its own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): restart the flow on the verdict that changed, not on every gates object

A reconnect cycle rebuilds the gates several times a second with the same
answer in them, and each one re-swept the staging tree and flipped an offline
screen to a spinner and back. Only a changed verdict restarts now, which is
also why the gates effect has to depend on the host id: two hosts whose gates
read identically would otherwise leave the second session in `checking`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): cover what only the shell runner can get wrong

Three cancellations had no test: the epoch that stops a result reaching a
session that is gone, the unmount cleanup that aborts the download, and the
retry that does both before starting over. Each is now red under its own
mutant. The download also re-checks the abort before it writes, since an
abort landing between the fetch's last read and the commit would otherwise
still put a generation on disk for a screen nobody is on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the shell runner's refs after the commit, not during render

React can replay or discard a render, so a handle written during one can run
effects for a session that never existed. The client and the host cache key stop
being refs at all; the effect handle is committed in an effect above every
effect that dispatches.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the hybrid shell flag unreadable outside a development build

Development and release share a bundle id, and the iOS data container survives
an install-over, so a flag a developer toggled on would follow the store build
in and mount the shell on a deep link. The release read never reaches storage.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drive the route's flag read as each build kind reads it

The route test exercises the real preference read, so it has to say which build
it is. A store build whose container kept a development toggle redirects.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let a cache read that lands mid-dial wait for the compat check

A connection still being made is not a host that cannot be reached. Opening the
cached generation there skips the compat check the landing connection is what
makes answerable, so only `unreachable` takes the offline path now.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): move the developer toggle only after its write lands

The route reads the flag back from storage, so a switch that moved on the tap
let the open button race the value that was being persisted. The switch and the
button both stay put until the write settles, and a failed write keeps the
previous position.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): say which build kind a test runs as without asserting on globalThis

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): undo a staged generation the abort reached before the commit

The commit is the write staging cannot take back: it renames into the active
slot and moves the host index. An abort landing while the bytes were being
staged now removes the staged tree instead of activating it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): open the cached workspace when the link, not the bundle, cut a read short

An RPC rejection can reach the reducer before the reachability change does, so
the offline gate never fires and a phone holding a valid generation reads that
the workspace could not be downloaded. A read that failed on the link now opens
what is on disk, the same path offline takes; a verdict about the bundle, from
the host or from the bytes, still fails.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send a hybrid shell recovery through the same gate a start takes

A view failure deleted the host cache and went straight back to the manifest
check on whatever gates the ready session happened to be holding. Gates that
arrive while a generation is on screen are stored without restarting, so after a
reconnect whose status probe failed a ready session carried statusReadable false
and an empty capability list, and the recovery's manifest check walled the host
as bundle-unavailable: terminal, no retry, about a host that never answered.

The gate is now one verdict both entries read, and recovery passes its delete
through it, so an unreadable status lands on the status-unreadable message that
re-arms when a readable gate arrives, and only a readable refusal still walls.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 08:22:25 -04:00
Jinwoo Hong 3aefee4a13 feat(mobile): native page-shell bridge in orca-mobile-web-shell (OTA phase C, C0.2) (#21434)
* feat(mobile): native page↔shell bridge in orca-mobile-web-shell (OTA phase C, C0.2)

Adds one prop, one event and one view function to the shell view, off unless
asked for: with `bridgeEnabled` false nothing is registered on either platform,
so Phase B's behaviour is byte-identical.

iOS accepts a `WKScriptMessageHandler` message only from our own WebView, the
main frame, the `orca-mobile-web` scheme and the session we loaded under, and
replies through `callAsyncJavaScript` with the payload bound as a real JS value.
Android registers a `WebMessageListener` gated on a `WEB_MESSAGE_LISTENER`
feature query (Chromium 88; unsupported is `isolation-unavailable`, and only
when the bridge was asked for) and replies through the reply proxy.

Simulator-measured before any acceptance logic was written: WKFrameInfo's
securityOrigin does populate for the custom scheme, but WebKit ASCII-lowercases
the host, so `orca-mobile-web://sess-01JN_aZ9/` reports `sess-01jn_az9`. Exact
equality would refuse every message from a mixed-case session id. Folding is
ASCII-only rather than caseInsensitiveCompare, because U+212A KELVIN SIGN folds
to `k` under Unicode and would match a host nobody minted.

The 640 KiB cap is measured on the raw UTF-8 string. Inbound it is a silent,
counted refusal; outbound `postBridgeMessage` throws, because its only caller is
the host and a dropped reply is a request that never settles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pick the completion-handler callAsyncJavaScript overload

The trailing closure resolved to the `async` overload, which the compiler read
as an extra trailing closure. The label is `in contentWorld:`, and naming the
completion handler is what selects the synchronous one. Restates the two
exception classes' inherited Sendable conformance, which Swift 6 warns on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fold the request host ASCII-only, shared with the bridge

`resolveRequestPath` compared the request host with `caseInsensitiveCompare`,
which folds U+212A KELVIN SIGN to `k`, so a host nobody minted could match a
session id containing `k` and be served every asset. Both predicates now use
one `MobileWebShellOrigin.asciiLowercased`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): converge the shell load guard on applied props, not install success

The re-entry guard compared `bridgeEnabled` with `bridgeInstalled`, which is
written only where the install succeeds. With the prop true, every early return
— malformed session id, unreadable generation, a WebView with no
WEB_MESSAGE_LISTENER — left the two unequal, so the next prop commit re-entered,
reset the state machine and re-emitted loading then failed, forever.

Both platforms now record the prop triple and compare it field by field in one
pure `MobileWebShellAppliedProps.matches`, checked by swiftc and JUnit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): settle postBridgeMessage on delivery and bind it to the frame that spoke

postBridgeMessage resolved whatever happened: the completion handler was nil,
and `bridgeInstalled` stayed true after the renderer died and after a failed
prop update, so the host's request never settled. It also posted with `in: nil`,
which means the current main frame, while page to native binds to the applied
session.

Both ends now use the frame the last accepted message came from, checked
against the applied session id with the same ASCII fold, and the promise is
rejected when there is nowhere to post or when WebKit reports the delivery
failed. Android drops its reply proxy on the same three events for parity.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the bridge delivery script throw when the page has no bridge

`if (bridge) { bridge.__deliver(m) }` made a page the installer never ran in
indistinguishable from a delivered message: the script completed, so
callAsyncJavaScript succeeded, so the host's promise resolved on a message
nobody received. Unguarded, the missing global throws and the promise rejects.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the applied-props record to the fields it compares

Nothing failed if a fourth prop joined the record and no comparison mentioned
it — the prop would simply never reload. Both suites now assert the record's
stored fields by name, so adding one without deciding whether it re-enters is
red rather than silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): import assertEquals for the applied-props field pin

Belongs with the previous commit, which left the import behind; no amend.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse and unbind the document a prop update replaced

Two ways the previous document kept speaking for the load that replaced it. On
Android a failed prop update nulled `served` and the reply proxy but left the
web message listener installed, so a page still alive after `stopLoading` posted
through a listener bound to the origin this mount had stopped serving, and
re-armed the proxy doing it. Every disable path now goes through one removal.

On both platforms that document is same-origin whenever only the directory or
the bridge prop changed, so it passed acceptance between `stopLoading` and the
next commit and emitted after the host was told `loading`. Acceptance is now
armed at navigation commit — `didCommit` on iOS, `onPageStarted` on Android —
and disarmed by a new prop triple, a failure, and a renderer that died. The
state lives in the load-state machine and the arming clause is a field of the
pure accept predicate, so both are checked by swiftc and JUnit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the bridge post target only for the document that armed it

`WKFrameInfo` outlives the frame it describes, so the held target has to be
cleared at the commit that re-opens arming as well as at the provisional start,
and a post in flight between the two has no document to go to.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): publish the Android bridge state written off the main thread

`reportDocumentFailure` runs from `shouldInterceptRequest`, so the reply proxy
it drops and the commit flag it clears are written off the UI thread that reads
them. Same reason `documentFailed` and `served` already carry it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what a resolved postBridgeMessage does not prove

Android's reply proxy is void with no acknowledgement, so resolve there means
enqueued. The shared handle promised delivery, which is only ever an iOS answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 07:38:47 -04:00
Jinwoo Hong 8fc81182ab feat(mobile): envelope contract for the web shell bridge (OTA phase C, C0.1) (#21432)
* feat(mobile): bound a web-shell bridge frame at one enforcement point

The page and the shell exchange frames over a native channel that will happily
carry whatever either side hands it. `parseBridgeMessage` is the only place the
byte, depth and node caps are checked, and the byte cap is checked against the
raw string so it protects `JSON.parse` rather than trusting it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): carry a bridge rejection without losing its delivery-unknown mark

A host `RpcFailure` is data and rides in the reply untouched; a rejection of
`sendRequest` is the other path and needs rebuilding page-side. The mark that
says the request may already have run is a `WeakSet` on object identity, so it
cannot survive serialization and has to be re-applied, and the recorder reads
`error.constructor.name`, so the rebuilt error is named rather than plain.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): declare every message the web shell bridge carries

One schema per message in both directions, with `v` gating envelope shape and
`init.grants` gating capability. Unknown keys are dropped rather than refused:
the page bundle ships from a desktop that updates independently of the installed
shell. A reply payload is read through loose objects so a field a newer host adds
reaches the page unaltered, which is what the goldens record.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): split an oversized bridge reply instead of refusing it

The native screens have no reply byte cap, so refusing one at the frame cap would
invent a failure the phone does not have; source control's diffs would be first
to hit it. Frames are measured after serialization and only then accepted, so an
escaped control character or a surrogate pair cut across the boundary cannot push
one over. The absolute ceiling aborts the request rather than truncating a reply.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the bridge numbers rather than deriving every fixture

A test that builds its fixture from the constant it is checking moves with that
constant: widening the frame cap, the depth, the node count or the reply ceiling
left every boundary case passing. These numbers are wire between a released shell
and a page served by a desktop, so they are pinned as literals; the in-flight and
subscription caps had nothing holding them at all.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the page's frames, not the desktop's answers

The depth and node caps exist to bound the cost of walking a hostile frame,
and only one direction is hostile. A 5 000-row listing reply carries 25 000
values, so holding the shell's answers to the same 20 000 node cap would
refuse ordinary data. `parseBridgeMessage` now takes the direction and walks
`page-to-shell` only; both directions keep the frame byte cap, and a chunked
reply keeps the 8 MiB ceiling as its single bound.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): carry a decoded screencast frame, not just its bytes

The binary `event` carried `b64` alone, but a binary listener is handed an
already-decoded `BrowserScreencastFrame`: format, metadata and the
screencast's own frame counter would all have been lost, and the envelope's
`seq` is the backpressure counter, not that one. The frame's fields now ride
beside the base64, mirrored field for field, with a compile-time pin that
nothing but the image is missing. C6 writes the encoder.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): fail to compile when the sender grows an option

The options pin only proved the schema accepts what the sender declares
today. A `Record<keyof SendRequestOptions, true>` makes the other direction
a compile error, so a new option cannot ship past the bridge unnoticed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): carry an error code of any shape, as the recorder does

The capture narrowed `code` to a string or a number, but the recorder
records whatever code it finds. A structured code would have crossed the
bridge as an absent field and moved a golden the day C0.5 replays through
it. Absent still means absent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): let the schema state the part bound on its own

The splitter's parts-ceiling branch could not fire: the largest reply the
ceiling admits, with every character re-escaping, splits into 26 parts
against a cap of 27. A branch no input reaches is a second statement of a
bound that drifts from the first. The derivation is pinned by a test now,
and `replyPartSchema` is the only place the bound is written.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound the ids a reply assembler holds at once

Nothing expired a half-assembled reply, so a host that sent a first part
and never a last one grew the map for the life of the page. A reply exists
only for a request the page made, so the in-flight cap is the right bound,
and the new id is the one refused. C0.4 owes the assembler a discard for
every request it settles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refuse a screencast metadata field that is not a number

Only the compile-time pin stood between a metadata field and `z.unknown()`.
Every one of the nine is now exercised, so widening any of them fails a
test rather than only a typecheck.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): survive an error whose own getter throws

Reading `code` and `cause` runs whatever getter defined them, and both were
read in one parse, so a getter that throws took the capture with it: the
rejection path would have thrown where it had to produce an envelope. Each
field is read on its own now, and a throwing getter costs that field only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a surrogate pair whole across a chunk boundary

A pair cut in half encodes as two replacements, three bytes each, where the
pair whole is four. The sender cut by code unit and the assembler summed the
parts, so a reply within two bytes per boundary of the ceiling was refused
`reply-too-large` for bytes it never had, and each half-pair frame was not
well-formed UTF-8 for the native bridge to carry. The cut backs up one unit,
and the ceiling is measured once on the joined text. Code units still bound
what is held, since a reply is never fewer bytes than code units.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): always produce a capture, whatever the error does when read

`message` and `constructor` can be getters, and `String(value)` runs a
`toString` the thrower wrote, so reading an error is running someone else's
code. A throw there left the rejection with no frame at all and a promise
that never settles. The whole capture is guarded now, and the fallback still
carries the delivery-unknown mark, which is a `WeakSet` lookup.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make an error frame sendable by construction

A megabyte message or code is not a protocol error, it is a big string, and
it produced a frame the receiver refuses as oversized: a rejection the page
never hears. A cyclic code took JSON.stringify down with the whole frame.
Messages are truncated to 16 KiB and marked, a code is dropped when it will
not serialize or is past 4 KiB, and the worst chain the budgets allow now
measures 512 KiB against the 640 KiB frame cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a refused reply refused, and bound them together

Every failure dropped the id, so the next part opened a fresh accumulator:
a duplicate part then a whole set completed, and one id could feed 67 MB
through an 8 MiB ceiling one refusal at a time. A refusal is remembered now
and answers every later part, until the page discards the id. The bytes held
across all ids gain a ceiling of their own, since 64 replies at the per-reply
ceiling is half a gigabyte of parts that never complete.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say that a new enum member is not an additive field

The version rule read as though anything additive was safe. A value outside
a closed list is refused whole by the older side, so `end.reason`,
`binary.format`, `connection.state` and the foreground reasons are
negotiated, not appended. The byte-cap comment had its inequality the wrong
way round while I was there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin what the guards keep, not only what they drop

Two mutants lived: clearing the assembler could have kept its tombstones,
and the guard around a cyclic code was hidden by the outer guard added for
a throwing getter. The capture is now asserted whole, so dropping the code
has to leave the message and the cause behind, and teardown has to forget.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): carry the code that was measured, not the one that made it

A stateful `toJSON` answers the budget check and the frame serializer
differently, so the snapshot is what crosses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 07:21:31 -04:00
Jinwoo Hong b749091b67 feat(mobile): native shell view serving a mobile web generation from a private origin (OTA phase B, 3/4) (#21417)
* feat(mobile): declare the orca-mobile-web-shell TS surface

Two props and one event: a generation directory the TypeScript store owns, a
session id that scopes the private origin, and a load state. No module
functions and no reload — a retry is a remount under a new React key, which
rebuilds the WebView and reinstalls every fence.

The native event body is a flat dictionary, so parseMobileWebShellLoadState
rebuilds the union instead of asserting it and answers null for anything it
does not recognise.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): serve a generation from a private origin on iOS

A WKWebView behind a custom-scheme handler that answers only from a map built
once from the generation's manifest, with the CSP as a response header on the
document. The scheme handler reads on a serial background queue and keeps a
live-task set that stop() removes from: an asset is up to 10 MiB, and
delivering to a stopped task raises an Objective-C exception Swift cannot
catch.

Origin, request refusal, the manifest map and the policy header hold no WebKit
type, so tests/MobileWebShellChecks.swift compiles and runs them with swiftc,
no device.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): compare the iOS shell's applied props field by field

One joined string could not tell a directory ending in the separator from a
shorter one with a longer session id. Two fields have no separator to collide
on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a string schemaVersion is not a manifest

The contract declares a number. The Kotlin side read it with optInt, which
coerces "1" to 1, so a manifest that widened the field would have been served;
this check covers the same shape on both platforms.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): serve a generation from a private origin on Android

A WebView behind shouldInterceptRequest, answering only from the same
manifest-built map as iOS, with the CSP as a response header on the document.
The origin host label is a slice of the session id's SHA-256, never of the
session id: Chromium lowercases an https host and java.net.URI reads null for
a label holding '_', which is how the reference 403'd every asset.

A main-frame failure is reported from a post() because Chromium commits its
own error document after onReceivedError returns. onRenderProcessGone destroys
the dead WebView and does not rebuild it, so the retry policy stays in one
place. clearCache(true) is never called: it is process-global and would wipe
the terminal WebView's cache too.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): untrack the shell module's gradle build output

The previous commit staged 312 files from android/build. mobile/.gitignore
anchors /android/ at the mobile root, so a module's own gradle output was
never covered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): parse the shell load-state payload with a zod shape

The anti-slop gate rejects an `object` parameter and `Reflect.get`. zod reads
a shape key straight off the value, so the own-property strip stays.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the web shell one load-state machine per platform

A failure is terminal, and a repeat says nothing. Chromium commits its error
document after onReceivedError returns and a rule list compiles long after a
generation was refused, so both platforms could report over a failure the
caller had already acted on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the Android shell reporting ready over a failed document

onPageFinished ran after reportDocumentFailure's post and both emitted `ready`
and set the WebView visible again, putting Chromium's error page on screen. A
prop change after the renderer died now reports instead of going silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): publish the Android shell's served generation atomically

The map and the host it is keyed against were two plain fields written on the
main thread and read on Chromium's, so an interceptor could see a stale null
and 403 a good frame, or a new map against the previous host.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the iOS shell to one terminal load state

A rule list that failed to compile after a generation was already refused
emitted a second, contradictory reason. The document-failure flag it carried
is now the state machine's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop serving the previous generation after a failed prop update

Both platforms returned early with the old map still installed and the old page
still on screen, so a caller told the shell had failed was looking at a working
one from the generation before.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the shell document at "/" and nowhere else

/index.html answered the same bytes without the policy header, which rides the
document response alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the shell's response headers as a pure predicate

Which response carries the policy header was decided inside the two request
handlers, where no test without a device can reach it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the shell's path-length edge and its charset casing

Both limits were checked only from the rejecting side, so a one-off length and
an uppercase charset passed unnoticed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): state the Android shell's file-URL settings and what B4 must check

The two file-URL settings were left to their defaults, and the settings that
only a device can prove named nobody to prove them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): drop the shell module's unresolved entry points

Nothing imports the module by name, on either side; the TypeScript is reached
by path, as the notification-dismissal module's is.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the iOS shell failing a document it cancelled itself

stopLoading on a prop update and every navigation the policy delegate refuses
reach the failure delegates as errors, so a healthy page reported `failed`,
lost its `ready`, and sent the caller to delete a good cached generation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): answer when the iOS rule list store is missing

Optional-chaining past a nil store ran no completion handler, so the view
stayed at `loading` for good. The next prop update now reads the same terminal
isolation failure a compile failure sets.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a manifest whose schemaVersion is true or 1.0 on iOS

NSNumber bridges both to 1, so `as? Int` accepted a manifest Kotlin rejects.
Verified against JSONSerialization: objCType is c for true, d for 1.0, q for 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop an Android document failure the next load did not have

The report is deferred past Chromium's error document, so a prop update could
land between the decision and the report and fail the generation that had just
replaced the one that actually failed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert each blocked global's descriptor whole

contains("writable:false") passed on a WebSocket descriptor that had lost it,
because the serviceWorker copy still carried one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the Android shell's navigation and refusal decisions

Both lived inside the WebViewClient, which no suite compiles, so dropping the
navigation guard or answering a refusal with 200 changed nothing anyone could
see.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the domain a policy-cancelled frame load is reported under

WKErrorDomain has no frame-load codes: WKErrorCode stops at the app-bound
domain errors, and 102 belongs to the legacy WebKitErrorDomain. The iOS SDK
exports no symbol for it, so the assert that pinned one is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 05:03:30 -04:00
Jinwoo Hong db2ffe7afe fix(mobile): default injected timers to receiver-free wrappers (#21416)
* fix(mobile): default injected timers to receiver-free wrappers

Every transport class stored a global timer function on an object and then
called it back through that object, so the receiver was the instance or the
dependency bag rather than the global. Hermes ignores the receiver; browsers
reject it with TypeError: Illegal invocation, which makes the web build fatal
at the first retry, liveness probe, or relay grace timer.

Default each injected timer to a wrapper that calls the global receiver-free,
and narrow the seam's type from `typeof setTimeout` to the call signature it
actually uses. Node's `typeof setTimeout` also demands a `__promisify__`
member that no injected timer or wrapper can supply, so the wrapper cannot
satisfy it. Pruning mobile-relay-background-grace.test.ts from the typecheck
baseline follows: the narrower type makes that file check clean.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the default timers against a browser receiver check

Both classes are now constructed with no injected timers under a global
setTimeout/clearTimeout that throws Illegal invocation for any explicit
non-global receiver, mirroring the WebIDL rule. The watchdog gets its own
file because its existing test is grandfathered out of the typecheck ratchet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the default clear leg and drop bare timer injections

The clear assertions were vacuous: cancel() and stop() also drop the state a
fired callback checks, so a no-op default clearTimer stayed green. Both tests
now assert the wrapped global clearTimeout received the exact handle setTimeout
returned, which fails when that default is mutated to a no-op.

Three relay tests injected bare setTimeout/clearTimeout into dependency bags,
the same receiver shape the product fix removed; inert under node, fatal under
jsdom. relay-host-signed-out-verdict drops two `as unknown as typeof setTimeout`
casts, since ScheduleTimer now types those arrows contextually.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census bare global timers parked in properties and defaults

mobile-endpoint-lifecycle could regress to bare globals with every other test
green, because nothing there is reachable from a unit test. Walk every product
file's AST and fail on a global timer parked where a later call reaches it
through a receiver: a `??` or `||` default, an object literal member, or an
assignment onto a property. A plain local capture stays legal, since calling it
bare leaves the receiver undefined.

A separate test asserts the walk sees the five fixed sites' wrapper shape, so an
empty or misdirected scan fails instead of passing vacuously.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): define the receiver-free timer defaults once

Five hand-written wrappers each restated the same invariant, so five places
could drift. timer-scheduler now exports defaultScheduleTimer and
defaultCancelTimer, and carries the reason for them; every site takes its
default from there. The census keys its presence precondition on those two
identifiers instead of the arrow shape.

The census also missed `??=` and `||=`, which park a global exactly like their
non-assigning forms. Both are handled now, with a parsed-source case per parking
form and one for the local capture that stays legal.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:59:56 -04:00
Jinwoo Hong 002ff3ddb8 feat(mobile): per-host generation store for the mobile web bundle (OTA phase B, 2/4) (#21409)
* refactor(mobile): export the mobile web manifest read schema

The generation store re-parses the manifest it cached, and it must read it back
with the same loose reader the fetch accepted it under: parsing strictly after
accepting loosely would turn a host's added field into a forced redownload on
every launch.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): add the per-host mobile web generation store

Turns a verified bundle into an atomically activated, host-scoped generation
directory under the OS cache, and reads it back. No RPC, no UI, no flag: the
native view is later handed the directory read-only and never writes to it.

The single directory under `generations/` is the activation, so there is no
activation file to edit: a commit deletes every other generation before the
rename, an interrupted one leaves zero generations for the runbook's redownload
rule, and two directories or an unreadable manifest drop the host tree instead
of guessing. `tmp/` is never an activation candidate and every one of them goes
at launch. `hosts.json` carries recency only, so losing it costs eviction order
rather than a generation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never evict the host a commit just activated

`now()` is a wall clock. With four hosts cached, one backward jump made the
fifth commit's own entry the oldest, so it evicted the host it had just
activated and handed back an ActiveGeneration whose directory was gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* Revert "fix(mobile): never evict the host a commit just activated"

This reverts commit a082ac1777. That commit carried all six round-1 fixes under
a subject naming only one of them; the six land again below, one per commit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the generation store's unused eviction entry point

`evictHostsBeyond` had no caller: commit enforces the four-host ceiling itself,
and a launch-time sweep for a shrunk limit can be added when something shrinks
it. The two `createDirectory` calls went with it, since the port already creates
intermediates, plus a line on what the Android rename fallback leaves behind.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never evict the host a commit just activated

`now()` is a wall clock. With four hosts cached, one backward jump made the
fifth commit's own entry the oldest, so it evicted the host it had just
activated and handed back an ActiveGeneration whose directory was gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fold case when refusing an asset named like the manifest

APFS and NTFS are case-insensitive by default, so `Manifest.JSON` landed on the
store's own `manifest.json` and the activation read back as the asset's bytes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a completed activation when the recency index cannot be written

`hosts.json` is written after the rename, so a disk that filled between the two
turned a generation already on disk into a thrown commit. The index carries
recency, not truth, and the next activation rewrites it whole.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse to commit a staged handle whose tree is gone

Commit deleted every other generation before it looked at the staged tree, so
committing an aborted or swept handle destroyed the live generation and only
then threw. The check moves ahead of the first delete.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): close four surviving generation-store mutants

Sweeping only the first host's tmp, staging over residue, dropping the serial
queue, and dropping the stale-index pruning all passed the suite. The stage
race needed two differing asset lists under one build id to be visible at all:
with identical ones an interleaved pair ends on the same bytes as a serial one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): activate only a build-id entry that holds a manifest

An entry under `generations/` matching the staged build id was taken as the
activation on its name alone, so an empty directory of that name — what a
crash between the rename and the post-rename check leaves on Android under
API 26 — or a plain file made the commit drop the verified staged tree and
return a generation that cannot be read back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never delete a cache because a manifest read failed

The adapter mapped every `file.text()` throw to null and the reader treated
null as corruption, so one iOS data-protection or I/O blip deleted the only
verified generation a host had. Missing stays null and still drops the tree;
a failed read now throws, and the reader returns no activation without
touching disk, leaving the caller to redownload.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the generation store's unreachable build-id guard

`MobileWebBundleManifestReadSchema` already pins `buildId` to the sha256
pattern, so no manifest reaching the store can fail the second check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that eviction ignores a non-host directory

Dropping the host-key filter in `listHostDirectories` passed the whole suite;
the ceiling would then count and evict anything else under the OS cache
directory.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a mid-download host out of the cache ceiling

A host holding only a staging tree was counted against the four-host limit
and, having no index entry, sorted first for eviction, so four cached hosts
plus one download meant the next activation deleted the tree that download
was about to commit. The ceiling now counts hosts with a generation; sweeping
still walks every host directory.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): record recency when a commit finds the build already active

The same-build early return skipped the index write, so a host that
redownloaded the bundle it already had stayed the least recently activated
and was the first evicted. No eviction pass on that path: the host count is
unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): honour only staged handles the store itself issued

`StagedGeneration` is structurally typed, so any object of that shape made
`commitGeneration` rename over, and `abortStagedGeneration` delete, a
directory of the caller's choosing. Handles are tracked in a per-store
`WeakSet` and anything else is refused before a filesystem call.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:44:36 -04:00
Jinwoo Hong 1e7a69710d feat(mobile): update wall for the desktop-served mobile web bundle (OTA phase B, 1/4) (#21411)
* feat(mobile): decide whether a web bundle may open against its host

A pure verdict for the bundle update wall, ordered so the answer names the
soonest cause: a host with no bundle has no manifest to disagree about, and an
unknown manifest schema makes the protocol window inside it unreadable. Same
`?? 0` defaults as `evaluateCompat`, so an absent status field reads as the
oldest host that could have answered rather than as permission.

Every blocked verdict is terminal. There is no native workspace fallback, so
each one carries the numbers it compared for the support breadcrumb.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read an unknown bundle schemaVersion through to the wall

The client reader pinned `schemaVersion` to the one schema this shell knows, so
a future schema 2 failed the parse before `evaluateMobileWebBundleCompat` could
call the shell too old. The user would have seen a transport error where the
update wall belongs.

The host's own manifest stays closed in both directions, where it is written.
Goldens are unaffected: every recorded reply carries schema 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the block screen copy for the bundle walls

One component still renders every wall. `updateSide` picks the app to update
from the reason, so the copy and the store link cannot disagree, and a new
reason is a compile error there rather than a mobile title over a desktop
button. The existing protocol copy is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): type the bundle protocol window the update wall compares

The loose reader left `runtimeProtocolVersion` and
`minCompatibleRuntimeProtocolVersion` as unknown index members, so the parsed
manifest could not reach `evaluateMobileWebBundleCompat` without a cast. Both
are now read as non-negative ints, and the reply-schema test pins it at the
call site: the wall is invoked on a parsed manifest, so dropping either field
stops compiling.

A host that omits the window is now refused. Only a host too old to advertise
`mobileWeb.bundle.v1` can send one, and the phone never asks such a host for a
manifest. The probe test's fake manifest gained the fields it was missing,
which is the typed reader catching its first stale fixture.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): say whether a bundle verdict actually checked a manifest

`ok` meant two different things: the manifest was read and its window contains
the host, or no manifest had been read at all. A caller that mounted on the
second would mount an unchecked bundle, so `manifestChecked` separates
permission to fetch from permission to open.

The host-status input is now a `Pick` of `HostStatusReply` instead of a
hand-copied pair. Both fields default through `?? 0`, so an upstream rename
would have silently blocked every host rather than failing a build.

Drops two assertions that restated the module's own literal back at it. What
proves today's bundle opens is that the shared contract's schema version is a
member of the supported list, so that is the assertion left standing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): offer a refetch, not a store, for a bundle the host outgrew

`bundle-incompatible` on the mobile side means the workspace cached for this
host is older than the host's client floor. A store update cannot clear that
and a reconnect can, so the screen no longer sends the user to a download that
would change nothing. The button is gone rather than relabelled, because the
recovery is leaving this screen, and the note drops its "already updated?"
opener for the same reason.

`blockRemedy` replaces `updateSide` and is now passed to the copy instead of
recomputed there, so the title, the body, and the button are decided once.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the platform assertion from the block-screen mock

The mocked `Platform.OS` was widened with an assertion so a test could switch
stores. An annotation on the binding does the same widening in a position the
compiler checks, which is what the changed-code quality gate asks for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say which bundle-compat default is fail-open, and drop a dead field

Both comments claimed the two host-status defaults point the same way. Only
`protocolVersion` is absent-means-oldest. An absent `minCompatibleMobileVersion`
is `?? 0`, which is no floor at all, so the mobile arm is fail-open by design
and matches `evaluateCompat`. A reader taking the old sentence at face value
would have gone looking for a bug.

`supportedSchemaVersions` had no consumer on the verdict: the block screen
renders a title and body, and B4 reads neither. The exported constant stays,
since that is what the wall is decided against.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:27:45 -04:00
Jinwoo Hong c263f5d092 chore(mobile): repin the RPC recording baseline to main after #21374 (#21402)
#21374 squashed to 60a774c30c, which main does not contain, so the pin guard's ancestry check is red on main; main has since moved past that commit and src/shared changed, so the pin is main's tip 1e3795de99 rather than the squash sha.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 03:34:30 -04:00
Jinwoo Hong 60a774c30c feat(mobile): client operations and dev probe for the desktop-served mobile web bundle (OTA phase A, 5/5) (#21374)
* chore(rpc-contract): provisional catalog entries for the mobile web bundle methods

PROVISIONAL, and the only commit on this branch that must not survive the merge
as written. `rpc-params-catalog.generated.ts` is generated from the host method
registry, and A5's client operations cannot name `mobileWeb.bundle.manifest` or
`mobileWeb.bundle.chunk` until A3 registers them: `defineRpcOperation` constrains
`method` to `RpcMethodName`, which is `keyof typeof RPC_PARAMS_BY_METHOD`.

These two entries are what the generator emits once A3 lands. After merging A3,
run `pnpm run generate:rpc-params-catalog` and keep its output, not this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): checked client operations for the desktop-served mobile web bundle

Two `defineRpcOperation` descriptors over the A1 contract, both
`require-result-or-throw` at `on-settle`: there is no partial success in a bundle
read, and a salvage policy would produce a half-bundle that fails a hash check far
from the cause.

Readers are hoisted `looseObject`s that require only what this client reads, so a
later optional member stays a Rule 1 addition for released phones; the host's own
schemas stay strict. `dataBase64` is bounded by the contract's chunk size, so a
host that overshoots is refused at the boundary rather than at reassembly.

`readMobileWebBundleErrorCode` maps the host's six codes out of the thrown
`code: message` diagnostic and answers null for everything else. Membership comes
from the contract's own enum, which is built from its `hostUnionArms` record, so
the arms here cannot drift from the host's union.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): fetch and verify a whole mobile web bundle over the paired connection

`fetchMobileWebBundle` reads the manifest, pages every asset at the chunk size the
host advertised, and verifies each reassembled asset against the manifest's sha256
before returning it. Nothing is cached and nothing is rendered: this is Phase A's
proof that the pipe carries a bundle intact.

Four asset reads run at once and no more, because the host refuses the fifth
concurrent read on one connection with `mobile_web_bundle_read_limited`; paging
inside an asset stays sequential, since the next offset is only known to be wanted
once a reply says it is not the last.

Every chunk reply restates its build, path and offset and the whole asset's length
and hash, and all five are checked. A desktop that auto-updates mid-download
answers a later chunk from a different build, and nothing else in the reply says so.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): dev-only troubleshooting row that fetches the mobile web bundle

The Phase A proof that the pipe works on a device. Tapping it fetches the whole
bundle from the paired desktop and reports the build, asset count, byte count and
elapsed time, or the host's error code.

`TroubleshootView` gains a `developerRow` slot and the route fills it only when
`__DEV__` is true, so a shipped build mounts nothing: no host lookup, no client
acquisition, no request. The row reuses the screen's existing button and check-row
styles, so it adds no visual vocabulary.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): recording scenarios for the mobile web bundle operations

Two families over the real product modules: `mobileWeb.bundle-manifest` drives the
manifest descriptor alone, so the loose reader's verdict on one reply is the whole
observation, and `mobileWeb.bundle-fetch` drives the paging flow over a two-asset
bundle whose entrypoint spans two chunks.

The fetch family's state carries the decoded bytes of every asset rather than a
count. A reassembly that misplaces a chunk still has the right length, so only the
bytes say so.

Goldens land with the repin in the next commit: the recorder fences on the pinned
tree, and these modules are not in it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording corpus and record the mobile web bundle goldens

`--record` refuses on any tree but the pinned one, and the pin predates this
branch's product modules, so the corpus is repinned to `bbf8264425` — the last
commit here to touch a fenced path — and re-recorded whole, the way
`rpc-recording/README.md` prescribes for a product change.

The delta is the clean one that repin predicts. All 778 existing goldens move
exactly one line, `baseline`, and nothing else: no body moved, no other header key
moved, none was deleted. Nine are added, two pilot per family plus the five reply
matrices the two families derive.

The fetch adapter projects its result rather than returning it whole. The result
carries a Map of Uint8Arrays, the observation refuses a non-plain object, and the
first recording lost the settlement and filed an unhandled rejection in its place.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the fake host's params through a boxed field read

The changed-code casting gate refuses the assertion the fake transport used to
type its recorded params. Boxing the value the way `settings-read-operations.ts`
does reads the same fields with no assertion, and a non-object params reads as
absent instead of throwing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to this branch's last fenced commit

The casting fix landed under `mobile/src`, which is a fenced path, so the pin no
longer named the tree `--record` runs on. Repinned to `79c3eed6db` and re-recorded.

Every golden moves the `baseline` header and nothing else, which is what a repin
with no product change is: the edited file is a test, and no recording loads one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): mutation evidence that the fetch projection observes the bytes

Writes every chunk at offset 0, so a multi-chunk asset reassembles as its last
chunk over a zero-filled buffer. The length still matches the manifest, so only
the sha256 check and the decoded bytes in the projection can see it, which is
what the fetch family's state exists to show. The mutant is killed.

`mutants/` is outside every golden digest, so this moves no recording.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop every worker's chunk reads the moment one asset fails

`stopped` was read only between assets, so the other three workers paged their
asset to the end after the fetch had already rejected: 121 chunk requests where
4 had been issued at the rejection. Each one holds one of the host's four read
slots, so an immediate retry was refused with `mobile_web_bundle_read_limited`
that only the abandoned workers caused.

An internal AbortController now stands beside the caller's signal and is checked
before every chunk request, not just between assets. Also pins the entry abort
check, the overrun check with real bytes, the measured byte total, and a schema
refusal whose message is prose rather than one of the six codes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the code anchor and both operation descriptors

`RPC mobile_web_bundle_unavailable failed` separates the anchored reader from an
unanchored one; the prose test that claimed to cover it had its first token at
index 0, so the anchor was load-bearing and untested. Also pins that a schema
refusal, which the dispatcher raises with zod prose before the bundle handler
runs, reads as no code, and that both descriptors stay
`require-result-or-throw` / `on-settle`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): dial the host on tap in the dev bundle row, and name it

Opening Troubleshoot in a dev build acquired a client at mount, which is what
kicks a dial, on a screen that opened no connection before. The probe now
acquires only once the row is tapped, and each request owns its AbortController
so a re-run, an unmount or StrictMode's second mount abandons the previous fetch
and stops its chunk reads instead of holding the host's read slots.

The screen carries no host parameter and troubleshoots every paired host, so
there is no host it is "on": the row still takes the first paired host but now
names it in the result instead of implying it speaks for all of them. The label
says whether it is still connecting or already fetching.

There is no `__DEV__`-conditional `require` idiom in this repo to trim the row
out of a release bundle with, which the route now records.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): refresh the recorder corpus counts

397 scenarios, 787 goldens, 790 tests from the README's own three-file command.
The 44 salvage goldens are unchanged; only the total they are quoted against
moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record for the mid-asset stop

Baseline moves to c519c2027d, the last commit on this branch to touch a fenced
path, and the whole corpus is re-recorded from it.

Delta against the pin, by the README's four classes: 786 header-only, 1 body
moved, 0 added, 0 deleted. The only key that moved on the 786 is `baseline`;
neither `recorderSha256` nor any `adapterSha256` moved, so nothing this branch
touched is inside a hashed recorder path.

The one body move is the disclosed behaviour change.
`matrix-mobileweb.bundle-fetch-app-js.json` is the reply matrix at the app-js
binding: where a partition leaves the app-js chunk without a result, the fetch
now stops the other workers mid-asset, so the sender list loses the chunk calls
they used to make for a bundle nobody would read.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold the rendered tree and the captured signal in boxes

Assigning to a `let` inside a callback leaves it narrowed to `null`, which the
harness was answering with two type assertions. A one-property box is a checked
type and the casting gate no longer has anything to report.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the branch's final fenced commit

Removing the two type assertions touched a test file under `mobile/src`, which
is inside the fence, so the pin moves to cae8f4a318 and the corpus is recorded
again from it.

Header-only, as a repin with no behaviour change should be: 787 header-only, 0
body moved, 0 added, 0 deleted, and `baseline` is the only key that moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let runRpcOperation send a params-less method

A3 declares `mobileWeb.bundle.manifest` with `params: null`, so the generated
catalog types its send params as `void` and the two call sites that pass an
explicit `null` stopped compiling.

`bindDeferredRpcOperation.request` already solved this: `RpcSendArguments`
admits `null` exactly where the catalog declares no params, because
`params: null` is not the frame that omits the key and narrowing it would
rewrite bytes shipped senders already put on the wire. `runRpcOperation` was
the one send entry point that never adopted the tuple, having had no
params-less caller until now. The compile fence pins all three accepted
shapes and that a params-bearing object is still refused.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus after merging main

The merge brought A3's host methods and the generated catalog, and the
follow-up widened runRpcOperation, so `mobile/src` and `src/shared` both
moved. Repins `baseline` to 5be50beb41, the last commit to touch a fenced
path, and re-records everything.

Delta against that commit: 787 header-only, 0 body moved, 0 added, 0 deleted.
The only header key that moves is `baseline` — the transport change is
type-only, so nothing a screen observes changed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound the bundle a manifest can make the client allocate

M1: the loose client reader kept every ceiling A1 declared except the one
that bounds their product. A manifest could pass `totalBytes` 0 alongside
256 assets of 10 MiB each and the fetch would allocate 2560 MiB against a
32 MiB contract. The reader now sums `assets[].byteLength` against
MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES. A ceiling rather than the host's
sum === totalBytes equality, because this client never trusts `totalBytes`
for anything and bounds what it will actually allocate instead.

L1: a tap dials the host, and nothing bounded that wait. A host whose client
never arrives left the row reading `Connecting…` with its button disabled
for the life of the screen. A deadline through the diagnostics folder's own
`startDiagnosticFetchTimeout` settles it to a failure and drops the
acquisition. Ten seconds, because acquiring a client is local work: the
connect and request timeouts live below this and only apply once one exists.

L2, four survivors now pinned: the eof break against a zero-byte asset end to
end, the offset half of the chunk echo check on its own, the anchor that
keeps `rpc (mobile_web_bundle_unavailable)` from reading as a code, and both
`abandoned` guards against a run the screen moved on from.

Also: the stop check moves above the per-asset buffer, which makes the
worker loop's copy redundant; drops the unreferenced chunk reply type; and
restores the comment pairing in operation-mutations.ts, where the bundle
entry had been inserted between the catalog mutation's comment and its entry.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus after the round-2 fixes

Repins `baseline` to 3252779fa7, the round-2 product commit, and re-records
everything.

Delta against that commit: 787 header-only, 0 body moved, 0 added, 0 deleted,
and `baseline` is the only header key that moves. `recorderSha256` holds even
though `mutants/operation-mutations.ts` changed, because the mutant directory
is excluded from the recorder digest on purpose — nothing on the recording
path reads it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus onto the merge that carries A4

A4 (#21376) added a mobile/src file inside the recorder fence, so the pin
has to name a commit that contains it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 02:47:50 -04:00
Brennan Benson 85576b6361 chore(mobile): bump to 0.0.51 and Android versionCode 18 (#21382)
0.0.50 is closed on the App Store and shipped as mobile-android-v0.0.50
with versionCode 17, so both values are consumed. Fastlane fails the iOS
release when the resolved version is not higher than the closed train.
2026-09-18 02:11:25 -04:00
Brennan Benson 71f3bdb700 chore(mobile): bump Android versionCode to 17 for the 0.0.50 release (#21335)
versionCode 16 already shipped as mobile-android-v0.0.48, and Android
refuses an install whose versionCode is not higher than the installed
one. Keep expo.version at 0.0.50 so the release tag can match it.
2026-09-17 22:57:02 -07:00
Jinwoo Hong b90837ee46 feat(mobile-web-bundle): advertise the bundle capability where a bundle ships (OTA phase A, 4/5) (#21376)
* feat(mobile-web-bundle): advertise the bundle capability where one ships

status.get pushes mobileWeb.bundle.v1 only when the install's bundle resolves and
its manifest parses, beside the other conditional capabilities. Dev trees and
`orca serve` installs may carry no out/mobile-web, and a static entry there would
promise a download that only ever answers mobile_web_bundle_unavailable.

No protocol version bump: protocol-version.ts asks for one when a method or a
required field is removed or changes meaning, not when a capability is added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that mobileWeb.bundle.v1 is inert on a released client

Derives the old desktop's reply by removing the one capability from what the new
one sends, rather than writing down what the old client had, and asserts every
released read of status.get lands identically apart from that string: the gate
hook, the three transport readers, the quick-command predicate and the
worktree-create support probe.

Proved red against three mutants: a closed enum on the capability schema (the
salvaged field drops whole, so nothing publishes), a client-side filter over the
new name, and a gate that changes floatingWorkspaceEnabled when it sees it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): name the invariant behind the fake client's cast

The changed-code casting gate wants the rationale on the line, and the reason is
narrow enough to state: every reader under test reaches the client through an rpc
operation's `request`, which uses sendRequest alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 01:56:20 -04:00
Jinwoo Hong 1cd2964501 perf(mobile): build the two projected git enums once, not per parse (#21311)
`readProjectedConflictOperation` and `readProjectedCompareStatus` constructed
a `z.enum` on every call, so every `git.status` and `git.branchCompare` reply
paid the constructor. Hoisted to module constants; the git-status payload
schema reuses the same instance. Behaviour is unchanged: same arms, same
fallbacks, identical reader output on all eleven recorded matrix cases.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 20:35:04 -04:00
Jinwoo Hong 40b2230508 test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* fix(mobile): move the last six reply-enum pins where tsc looks

mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.

Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:

- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
  arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy

openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.

Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): fix the type errors in eighteen test files

Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:

- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
  where act wants void, so each becomes a block. The async ones await only a
  genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
  member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
  the branch-compare summary's errorMessage, and SessionOptionDescriptor's
  transport, which #20884 added precisely so a producer could not inherit the
  wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
  refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
  becomes `advance?.kind`, one widened status arm takes `as const`, and the
  Expo notification fixture keeps `data` required because the dismissal cases
  assign through it.

631 test files pass, 6222 tests, unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): typecheck the test files, on a ratchet

mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.

tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.

The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.

Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile-recorder): correct the corpus counts and the salvage claim

The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.

"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the tests-typecheck ratchet's parser

The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.

Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): await the timer advances the act() rewrite dropped

Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): unshadow MobileHostCard's .tsx suite

A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census every test file into the typecheck program

The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(shared): make the enum helpers refuse the ways they can prove nothing

openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): describe the census and correct the baseline count

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the push fixture cast its SAFETY rationale

Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): build the push fixtures as typed notifications

Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the grouped-question advance read non-optional

`advance?.kind` let an absent advance take the null-draft branch instead
of failing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run the tests-typecheck ratchet on Windows

Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once

tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.

`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 18:58:25 -04:00
Jinwoo Hong 49274394fc refactor(mobile): put the branch-compare leg on the lifecycle owner, with a currency probe (step 5) (#21299)
* refactor(mobile): put the branch-compare leg on the lifecycle owner (step 5)

The compare kept three hand-rolled guards for one reply, combined in an
`isCurrentLoad()` the four exit points each had to remember to call:
`branchCompareGenerationRef` (latest-wins), `currentBranchCompareIdentityRef`
(the route identity, written in render) and `mountedRef`.

The owner replaces the first two. An attempt now `reset()`s and then `load`s, so
the newest attempt is the only one holding a live lease, and the reply is
published only through `commit(lease, value)`. What retires a compare is named
at the call site: this host, this route identity, this workspace.

A compare is a refresh, so neither of the owner's other two mechanisms applies
here and the `reset()` before each `load` is what says so: nothing it holds is
reusable, and no attempt may share its predecessor's reply. Dropping that line
makes the second attempt join the first's request and publish a base ref the
user already navigated away from.

The identity retire moves into the render-phase adjust-on-prop-change block,
where the identity ref was written. Leaving it to the next load's scope is not
the same thing: that load only starts once the fresh `git.status` returns, and
an in-flight compare would publish the old worktree's commits first.

`mountedRef` stays. A detached route has no screen to publish to, which is a
fact about the view, not about which reply is current.

The three decision points that used to write state mid-flight — no base ref, a
refused capability, an unreadable reply — are a returned `BranchCompareOutcome`
now, so the loader body writes nothing and the screen is written in one place.
That also puts this file under the loader-write source fence.

No golden moves: the recording suites reproduce byte for byte.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct the compare scope comment to the one call that reads it

The pilot's wording named two scope consumers; the compare leg has only `load`.
What the scope still adds over the render-phase retire is the structural half: a
scope the owner has not seen retires on its own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the lifecycle owner's loader a currency probe

A loader that spans two round trips had no way to ask whether its scope
had moved, so a superseded attempt sent its second request and was only
refused at commit. The probe answers exactly the question commit asks and
carries nothing to publish with, so the owner's publish fence is unchanged:
a loader that stops on it returns null, which the owner already reads as
no value.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a superseded branch compare off the wire

Restores request-count parity with main for the one path the migration
changed: an attempt superseded while it resolved its base ref used to stop
before sending git.branchCompare, and under the owner it sent one and was
refused at commit. It now stops on the owner's currency probe between the
two legs, so the screen is unchanged and so is the request count.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the probe's missing generation actually is

Stripping the directive gives TS2339, a member that does not exist, not a
privacy error: the probe has no generation to keep private.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a detached route sends no compare

The detach reset() was the only thing retiring an attempt after the route
went away, and deleting it left the suite green. This schedule detaches
mid base-ref lookup and asserts nothing reaches git.branchCompare; without
the reset() it fails with one request sent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the scope member the identity key already carries

statusIdentityKey is `${hostId}\0${worktreeId}`, so listing worktreeId
beside it read as a third fence when it fences nothing new.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): split the compare protocol out of the loaders hook

The outcome union, the attempt and the screen mapping are the compare
leg's own protocol, not the hook's: nothing in them reaches React. Moved
verbatim to mobile-branch-compare-outcome.ts with a unit pin for the
mapping, which only the hook's schedules covered before. The hook drops
from 283 to 230 lines against a 300 limit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): correct the joiner comment and narrow the compare sender

A joiner never receives the probe: its fn is never invoked, it awaits the
originating request's promise, and retire() clears inFlight so none can
join across a generation bump. The compare attempt takes the operation
sender the convention names rather than a whole RpcClient, which it only
ever used as that.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 18:39:18 -04:00
Jinwoo Hong eabfbaab88 refactor(mobile): drop the unreachable dispose-before-ready notifications arm (#21293)
* test(mobile): pin the desktop-notification dispose-before-ready contract

Drives `subscribeToDesktopNotifications` through the real `RpcClientStreamRegistry`
so the disposer's effect on a later `ready` reply is stated rather than implied.
Both cases pass against the current module, before any code is removed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the unreachable dispose-before-ready notifications arm

`disposed` is set only on the first line of the disposer, whose next statement
detaches the stream listener in every transport, so the `ready` arm can never
observe it. Removing the branch changes no behaviour and moves no golden.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin cancel fencing in the relay and logical stream layers

The notifications comment claims every transport detaches a listener inside its
disposer, but only the stream registry was pinned. Adds the same live/cancelled
differential pair to the relay stream manager and the logical client, the latter
against a physical session with an inert disposer so only the logical guard can
fence the late event. Drops a self-comparing assertion to a length check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the notifications registry fake instead of asserting it

The changed-code quality gate rejected three `as` casts. The fake client is now
declared `RpcClient`, so the compiler checks it really satisfies the port, and
the registry's `unknown` send port is narrowed by a reader that throws on a
frame without a string id and method rather than asserting one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 17:34:46 -04:00
Jinwoo Hong 0b1cde0e01 chore(mobile): repin the RPC recording baseline to main after #21269 (#21287)
The last step-7 squash orphaned the pin again. Repin to 4a86b2dc56 and
re-record: 778 goldens and the manifest move only on the baseline field.
With this the unchecked-reader inventory on main is empty.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 16:53:06 -04:00
Jinwoo Hong 4a86b2dc56 refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history (step 7) (#21269)
* test(mobile): record main's file-preview and markdown-disk-fallback replies

Four of this branch's read sites had no malformed-reply coverage, so the reader
change would have had nothing to move at them. `familyGoldens` matrixes only the
first scenario of each family, and `files.preview-load`'s base is the grant-refresh
chain while `session.tab-documents`' is the served markdown tab — which left
`files.read` and `files.readPreview` on the worktree preview path, the artifact
image read, and the markdown tab's on-disk fallback recorded on their success path
only. This commit is the before picture, taken from main's own tree with no product
edit in it.

Three new families, five scenarios, ten goldens:

- `files.preview-worktree-text` / `files.preview-worktree-image` — `files.read` and
  `files.readPreview` as the preview screen asks them for a worktree file.
- `files.preview-artifact-image` — `files.readTerminalArtifactPreview`.
- `session.markdown-disk-fallback` — the `files.read` leg a headless host's
  `renderer_unavailable` sends the markdown tab down. It carries a second scenario
  that serves `markdown.readTab`, because a matrix site needs a fulfilled reply
  recorded somewhere in its own family to replay as the `normal` partition.

No existing scenario moved to a new family and no adapter changed, so every
pre-existing golden keeps its `adapterSha256` and `scenarioSha256`. Recorded in a
detached worktree at the manifest's pin (`4b876758d3`) with this manifest copied in;
the control is that all 748 pre-existing goldens came back byte-identical to
origin/main's, which `git diff c2962a765a -- mobile/rpc-foundation/goldens` confirms
as empty.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history

Thirty-five unchecked reply readers across seven files become checked zod readers,
so a malformed host reply surfaces as one readable error at the operation boundary
instead of a downstream TypeError, a rendered `undefined`, or a screen left ready
over garbage. Deliberately a behaviour change on malformed replies only: every good
reply decodes to the same value it did, which the `normal` partition of every
matrix golden holds byte for byte. Nothing on the wire moves — no method, params,
options, timeout or acceptance policy changes at any site.

The inventory drops from 137 readers over 31 files to 102 over 24.

What each domain checks, and what it deliberately does not:

- files/preview — one schema for `files.read` and `files.readTerminalArtifact`, one
  for the two preview methods. `content` is required on the text pair because the
  markdown disk fallback publishes it into the tab with no guard; the image pair
  requires nothing, because normalizeImagePreviewResult guards all four members and
  the host's own "binary I cannot preview" and "not actually an image" arms are good
  replies the screen renders today.
- files/tab-doc — stricter than the preview screen on the same two methods, because
  a tab publishes what it read into a typed ready document with no guard. `git.diff`
  reads as two variants, and an arm this build has not heard of takes the binary one
  rather than refusing the reply.
- files/explorer — the directory listing is an array and a row needs the name and the
  directory flag the tree projection turns on; the legacy capped list needs its rows'
  paths and the truncation flag its note draws.
- files/ownership — the two members that decide *where a write lands* are fatal on a
  wrong type rather than salvaged, because absence reads as `local` downstream and a
  salvage would send a mutation to the wrong host. `hostId`'s absent/null/string
  states stay distinct, and the SSH connection generation passes through at its own
  type because the mutation echoes it back to the host.
- dictation — the setup the sheet renders is checked; the model rows need the `id`
  the sheet keys and sends back. The five sends whose reply body no call site reads
  keep an unknown payload, and so does `speech.dictation.finish`, whose transcript is
  read past a staleness guard that a reader throw would move the failure across.
- host-screen — the repo catalog, the SSH labels and the host platform. The four
  writes read no reply body; `worktree.activate` stays opaque because the session
  route's second report site awaits it outside any catch.
- agent-history — the capability gate and both scan containers. The session rows stay
  unknown on purpose: `agent` is a vocabulary that grows with every agent CLI Orca
  learns to scan and that this client echoes back on resume, so narrowing it would
  refuse a newer host's reply or drop the very sessions it added.

Two shared readers were widened to take the strings the reply readers hand them —
`getRepoExecutionHostId` and `buildRepoHostIdByRepoId` — because both already answer
`local` for a host-id spelling they cannot parse, and closing that spelling in a
reply schema would refuse a newer host's own catalog.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus and re-record the checked reply readers

`baseline` moves to this branch's last fenced commit, which is what `--record`
refuses without: main's fenced tree drifted past the session domain's pin when
#21114 and the dependency bump landed, and the product edit in the commit before
this one moves it again.

Every body move is confined to a malformed partition of a family this branch
touched. No `normal` partition moved, which is the byte-for-byte control on good
replies, and no golden outside the seven files' families moved at all.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the dictation reader requiring a mode main rendered without

The setup sheet's `normal` partition refused after the reader landed, which is the
success control saying the schema was wrong rather than the fixture: `dictationMode`
was declared required because the one unguarded consumer pushes it into a
`useState<'toggle' | 'hold'>` and cannot invent a value, but main rendered a sheet
whose reply omitted it, and requiring a member no consumer crashes on is exactly the
version claim Rule 1 of the remote-wire contract warns about.

The member is salvaged now and keeps its open arm set, so an unknown mode still
degrades to `toggle` rather than to one that matches no segment. The native-chat
refresh spells that same `toggle` for an absent mode, which is the value its state
already started at, and the route parity pins are refreshed for the one literal and
the two callback bodies that moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin past the dictation fix and re-record

Second repin of the branch: the fix to the setup reader is a fenced-tree change, so
`--record` refuses until `baseline` names it. The speech family's `normal` partition
is back to main's projection, which is what said the first reader was wrong.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): mutant evidence for the checked reply readers

Three mutations applied by hand, run, and reverted, recorded beside the adapter
family mutations in the same shape. They are kept in their own file because a reader
mutation is not killed by a pilot scenario: a pilot serves a good reply, and a schema
that has stopped checking a member reads a good reply exactly as before. What kills
them is a matrix golden's malformed partition, the schema's unit pin, or a consumer
pin, and each is named against its mutation.

Two survived their first run, and both survivals were defects in the gates:

- Loosening the file tab's `content` was invisible, because the pin dropped members
  only in pairs and each pair is refused by the sibling. The pin now drops exactly
  one member per iteration, and the preview text schema and the legacy file list got
  the same treatment.
- Collapsing the hostId tri-state was invisible, because no golden serves an explicit
  null host — the local ownership scenario omits the member. The ownership test now
  captures all three states end to end, which is where a tri-state belongs.

`repo-metadata-platform` is re-anchored where this branch moved the read it mutates:
the hand-rolled `readHostPlatform` became the reply schema's own projection. The
defect it injects is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record main's repo-icon and speech-vocabulary replies

The closed enums this branch introduced had no fixture behind them. `provider`,
`dictationMode` and `repoIcon` were carried by no scenario at all — the fulfilled
repo-metadata golden records `repoIconsByName: []` — so the corpus could not have
moved whatever arm set the schemas declared, which is how a reader can pin a
vocabulary the host does not speak and still decode to a zero-move delta.

Two scenarios, both appended to an existing family so `familyGoldens` adds no
matrix golden, recorded from main's own tree at the pin with no product edit in it:

- `settings-repo-metadata-icons` — all three `RepoIcon` arms, a github-sourced
  image with a label, an explicit `badgeColor`, and a mixed-host catalog so the
  ssh/settings/platform wave runs too.
- `speech-setup-sheet-model-vocabulary` — `provider` on both arms, `status` on two,
  `dictationMode: "hold"`, and null and numeric `sizeBytes`/`progress`.

Control: re-recording the whole corpus at the pin reproduces every committed
golden body, including this branch's five earlier before-pictures; only `baseline`
and the masked `lockfileSha256` move.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the repo icon narrowing a member no consumer reads

The image arm of `repoIcon` declared `source` as the four values
`RepoIconImageSource` spells today (src/shared/repo-icon.ts:3). MobileRepoIcon
reads `type`, `src`, `label`, `emoji` and `name`, and never `source`, so the only
thing that enum could do was fail the union arm for a source a later host adds —
dropping the whole icon and drawing the Folder default where main drew the image.
That is the one arm set on this branch whose degrade was not already main's own
behaviour for an unknown value.

Dropping the declaration keeps the member: `looseObject` passes it through
verbatim, so the decoded object is byte-identical to the one main published, which
`settings-repo-metadata-icons` now records.

The two type sites that hold an icon move to the decoded type. A host `RepoIcon`
still satisfies the rendered union, so the worktree rows that carry one are
unaffected.

Every other closed enum on this branch was checked against the host's own shared
type and left alone: speech `provider`/`status`/`dictationMode`
(runtime-worktree-contracts.ts:83/85/86), `groupBy`/`sortBy`
(persisted-ui-state-types.ts:41-42), `platform` (Node's own domain; the handler
answers `process.platform`). For each, a salvaged member lands on the same branch
main's unknown value did: `=== 'openai'` and `=== 'ready'` stay false, a missing
`groupBy` and an unmapped one both answer null, and an unknown platform and a null
one both label the host "This computer".

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin past the repo-icon fix and re-record

Header-only: all 770 goldens move on `baseline` alone, including the two recorded
from main's tree two commits back. The icon fix and the two new fixtures decode to
the bytes main published.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the repo-metadata readers total the way main's were

readSshTargets and readHostPlatform answered [] and null for any payload at
all. The checked schemas threw for a non-object, and because the label write
runs first in the same sequence that throw also skipped the platform write, so
a malformed reply left both decorative labels at their previous values instead
of degrading. A .catch on each restores main's answer without giving up the
row filter or the checked reader.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): forward the dictation mode instead of substituting a default

The reader closed the mode to two arms and the native-chat refresh spelled
`?? 'toggle'`, which is a good-reply change no golden covers: main left the
state undefined for a reply that omits the mode, and undefined binds no press
handler on the terminal input mic. Head gave that mic a working toggle. The
member is forwarded as the string the host sent and the refresh is main's line
again, so an absent or unknown mode leaves the mic exactly as inert as main's.
The route-parity runtime-string pin is main's own sha again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin past the review fixes and re-record

The repo-metadata readers are total again, so both families' `result-absent`
and `result-null` checkpoints decode to main's bytes instead of the caught
throw, and the two delta rows they cost go away. The dictation mode forwards
verbatim, which no recorded reply exercises differently.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin onto the merge and re-record

Pins the corpus to the merge commit so main's ten create-terminal goldens and
this branch's own are recorded from one tree.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct three reader comments round 2 caught

The ownership schema said an explicit null hostId means the host said local;
the code refuses it, which is the whole reason mutant (c) exists. The AiVault
sessions cast cited a golden whose fixture row carries three members, not the
sixteen the cast claims — the full row is in aivault-history-screen-listed —
and both the issues cast and the schema doc said the rows are rendered when
the only read anywhere is issues.length.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct stale file:line citations in the batch-A reply schemas

Resolved every citation in the seven reply-schema modules and the SAFETY
notes against the tree and diffed each target line against the claim beside
it. Twelve were wrong, two of them past the end of a file that had shrunk,
so they read as evidence while pointing at a closing brace.

- file-explorer: the entries put is :157 not :160, the relativePath split is
  file-list-fallback.ts:48 not :42, and the truncated publish is :136 not
  :141. buildFileExplorerRows is no symbol at all; the sort-and-walk is
  flattenDirectoryCache (file-tree.ts:58).
- file-ownership: the !summary throw is :68 not :64.
- file-preview: the markdown disk fallback reads content at :60 not :65.
- file-tab-doc: the html body render is :68 not :81 and the file arm is
  :73-75 not :86-88 (the file has 78 lines); the isImage guard is :58 not
  :66; the kind !== 'text' branch is :41 not :44; mobileDiffImageDataUri
  spans :22-33 not :20-31; the unguarded content.length is
  mobile-diff-lines.ts:35, the function that does it rather than :34.
- agent-history: both members land at :133-135; :135 alone is issues.
- dictation: the parenthetical read as citing the staleness guard when it
  named the rpcPayloadMember read. Both are cited now, :237 and :225.

Comments only. No schema, type, or runtime behaviour changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the unguarded activation report site that pins the opaque schema

Handler audit over all 33 interpret sites in the four domains found one site
that is structurally unguarded: use-mobile-session-startup.ts:170 reports the
activation verdict from inside a fire-and-forget `void (async …)()` whose only
`.catch` sits on the request, not on the chain. A throw there would be an
unhandled rejection and would also skip the terminal fetch below it.

Nothing throws there today, because `worktree.activate` reads
hostScreenUnreadReplySchema, which is `z.unknown()`. That totality is load
bearing rather than incidental, so the doc now names the line it protects and
contrasts it with the first report site at :141, which is chained
`.then(…).catch(…)` and would survive a throw.

Comments only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a bound descriptor's interpret survives being detached

bindDeferredRpcOperation builds interpret as a shorthand method closing over the
captured operation, never `this`, which is what lets eleven call sites pass it as
a bare function reference. Nothing named that invariant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the RPC recording baseline to the main merge

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pin the closed reply enums to the host unions where tsc looks

pullfrog: the PR body promised a Record<HostUnion, true> pin for every
closed enum in this batch and the code had none. Adding them in the
schema tests would have changed nothing: mobile/tsconfig.json excludes
*.test.ts, so a coverage record there is never typechecked (a mutation
that dropped a key stayed green).

hostUnionArms(coverage) in zod-salvage spells the arm list as a
Readonly<Record<U, true>> in the schema module itself, called with the
host union as the explicit type argument: an arm the host adds is a
missing property, one it drops is an excess property. Used for the speech
provider and status (RuntimeSpeechModelSummary), the workspace groupBy and
sortBy (PersistedUIState) and Node's platform list, which host-screen now
imports from mobile-runtime-host-platform instead of duplicating. The repo
icon branches satisfy Readonly<Record<RepoIcon['type'], z.ZodType>>.
Three mutations (drop `manual`, add `bogus`, drop the image branch) each
fail tsc. The tests iterate the exported lists; the platform mutant is
re-anchored to the renamed constant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 16:38:16 -04:00
Jinwoo Hong 7e2ebac318 chore(mobile): repin the RPC recording baseline to main after #21246 (#21266)
Every step-7 squash leaves the pin guard red on main until the baseline
names a commit main contains. Repin to 6142657d7a, the #21246 squash, and
re-record: 766 goldens and the manifest move only on the baseline field.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 14:21:03 -04:00
Jinwoo Hong 6142657d7a refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create (step 7) (#21246)
* test(mobile): record main's agent.launch create receipt before checking it

`agent.launch` is the one read site in the tasks domain's project-board,
runtime, source-search and workspace create/source files with no recording
family at all, so main's answer to a malformed launch receipt was undocumented
and a checked reader would have had nothing to move.

One family, one scenario, two goldens: `worktree.agent-launch-create` drives
`createWorktreeWithNameRetry` down the `agent.launch` arm instead of
`worktree.create`, which needs an `agentLaunch` argument on the existing
worktree-create-retry adapter. The agent is a constant there on purpose — which
agent is picked changes only the params, and the arm under test is which method
the create is issued on.

A separate family rather than an eighth `worktree.create-retry` scenario:
`familyGoldens` drives its reply matrix over the family's FIRST scenario, so
adding to that family would have recorded a pilot golden and left the launch
receipt with no partitions. As its own base it gets all eleven.

Recorded from a detached worktree at the pinned baseline with this branch's
`rpc-recording/` and manifest copied in, per the recipe in the recorder README:
`mobile/pnpm-lock.yaml` has drifted past `4b876758d3` on main, so `--record`
refuses on this branch's tree even though `mobile/src` and `src/shared` are
byte-identical to the pin.

Thirty-four existing goldens move on `adapterSha256` and nothing else — the six
families mounted through the edited adapter module. No body moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create

Forty-three unchecked reply readers across five files become checked zod readers,
so a malformed host reply surfaces as one readable error naming the method
instead of a downstream TypeError, a rendered `undefined`, or a screen left ready
over garbage. Deliberately a behaviour change on malformed replies only.

Five schema modules, each recording the consumer line behind every requirement
and the host handler it was checked against:

- `task-project-board-reply-schema.ts` — the sixteen `github.project.*`
  envelopes. Where a consumer reads a member off BOTH arms unguarded the schema
  is a union on `ok`; where it guards everything (`result.error?.message ?? '…'`,
  `result.labels ?? []`) it is a flat passthrough and requires only the
  container, because a requirement on a member the consumer already defaults
  would refuse a reply main rendered.
- `task-runtime-reply-schema.ts` — the hydration reads. The three preference
  writes read `z.unknown()`: no call site interprets their body.
- `task-source-search-reply-schema.ts` — the provider searches and the pasted
  single-item lookups. The Linear union replaces the hand reader in
  linear-mobile-issue-read.ts, whose own copy reached the screen unattributed.
- `workspace-source-reply-schema.ts` — SSH state, agent detection, orca.yaml
  hooks, sparse presets and base-ref search.
- `workspace-create-reply-schema.ts` — the create receipt, the launch receipt and
  the hosted-base union.

Requirements are exactly the members a consumer reads unguarded AND a recorded
golden shows the host sending. That second half is load-bearing: the recorded
GitHub search row is `{ number, title }`, the recorded Linear issue is `{ id }`,
the recorded project is missing `id`/`url`/`source` and the recorded sparse
preset is missing `repoId`/`createdAt`/`updatedAt` — requiring what the shared
types declare would have dropped rows main renders. Where the value therefore
stays looser than the screen's own state type, the call site keeps one narrowing
cast with that reason on it rather than a default that would fabricate state.

Two enum decisions, both pinned:

- `ownerType` is CLOSED with no fallback. It is echoed into the next
  `github.project.listViews` params, and remote-wire-compatibility.md rule 4
  forbids a reply-schema fallback from shaping a param; the host's own listing
  handler answers `validation_error` for any other value.
- `ssh` `status` is OPEN and degrades to `disconnected`, main's own answer for a
  state it did not receive. The readiness gate is an equality test against
  `connected`, so an arm this build has not heard of can never grant a create,
  and the record survives with its Connect affordance.
- Every other host vocabulary a consumer equality-tests — the project view
  `layout`, the `setupRunPolicy` — stays `z.string()` for the same rule.

Tri-states are preserved, not collapsed: the row detail's `reviewDecision`, a
work item's `author` and the SSH record's `error` each keep explicit `null`
distinct from absent, with a unit pin on each.

`blank-workspace-create.test.ts` splits one `it.each` in two. The two create
routes now answer a workspace-less reply differently: `agent.launch` still
reports "Failed to create workspace", because its reader guards `worktreeId`
itself, while `worktree.create` is named as unreadable, because the create screen
reads `result.worktree.id` unguarded into the session route. Both reach the same
catch; only the sentence changes.

`mobile-tasks-refactor-parity.test.ts` moves four hashes and no count. Hooks hold
at 350 with 28 bodies edited and no dependency array moved; statements hold at
417 and declarations at 194; `semantics` loses exactly four lines, all four
string literals that lived inside the one deleted inline cast type. No method
literal and no `rpc:` call signature moves.

The inventory loses its five tasks lines; the boundary test stays green.

Goldens are refreshed in the next commit, which is where the disclosed behaviour
change is proved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record the corpus over the tasks domain's checked readers

Repins `baseline` to d4cfac98b4, the commit that landed the checked readers, and
re-records all 760 goldens. The repin rewrites that header on every file; the
recorder edit below rewrites `recorderSha256` on every file too.

The disclosed behaviour change is the body-moved set and nothing else: the
malformed reply partitions of the families whose readers this branch converted.

Two recorder files move with it, both re-anchoring evidence the checked readers
displaced rather than deleted:

- `pilot-recordings.test.ts` restates the b2 seed. The shipped null result is
  still the seed and the screen still reports an error the user can see; what
  moved is the sentence, from V8's "Cannot read properties of null (reading
  'ok')" to the reply and method the reader names.
- `operation-mutations.ts` re-anchors that seed's `acceptance` mutant. Its defect
  is a null envelope reaching the metadata sheet, and the call-site guard it was
  injected at can no longer see one, because the reader refuses the envelope
  first. The anchor is the schema now, and loosening it to `z.unknown()` puts the
  null back on the path to `result.ok` — the same defect at its new home.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the reply-schema pins without type assertions

The changed-code casting gate counts a `as` in a test like any other, and eight
of them had crept into the new schema pins. Each is replaced by an assertion that
reads the same fact off the typed value: the schema already declares
`worktreeCreateIdempotency`, `glab`, `status` and `error`, so the narrowing was
never needed, and the two "is this key present" checks are JSON comparisons,
which is the honest way to ask — `JSON.stringify` drops an absent key and keeps
an explicit null, which is the whole distinction a tri-state pin is making.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the tree it was recorded from

The previous repin named d4cfac98b4, and the assertion cleanup that followed it
touched `mobile/src` — a fenced path — so the header pointed at a tree the
working copy no longer was. Repins to 6b740c3f61 and re-records.

Bodies are unchanged: only `baseline` moves, on all 760 goldens. Four test files
cannot reach a recording, which is the point — the fence does not know that, and
a header that names a tree nobody can reproduce is the one claim it exists to
make.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): point the reply schemas' consumer citations at the landed lines

Every requirement in the five schema modules names the consumer line that
justifies it, and the migration moved those lines: deleting a thirty-line inline
cast type shifts everything under it. The citations now resolve against the tree
they ship in.

Comment-only. No schema, no reader and no consumer changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the tree the citations landed in

`baseline` follows the last commit to touch a fenced path, and the citation fix
did. Bodies unchanged: `baseline` moves on all 760 goldens and nothing else.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin both reader vocabularies against the host's own unions

The two enums these readers declare were checked against mobile's restatement
of the wire, not against the types the handlers return. A closed enum written
from the wrong vocabulary drops every row that carries an arm it omits, and no
golden can catch it when no fixture carries one.

Both arm sets are now keyed by the host type in a Record, so an arm added to or
removed from SshConnectionStatus or GitHubProjectOwnerType fails tsc before any
test runs. The SSH degrade's inertness is pinned at the gate that reads it
rather than argued in a comment: an arm a newer host sends and the degraded
value reach the same label, the same readiness verdict and the same error.

Also corrects a comment claiming the file-mutation owner check reads members
this schema forwards. It asks ssh.getState through a reader of its own, and no
mobile code reads providerEpoch, supportsFolderDownload or remotePlatform.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the tree the vocabulary pins landed in

Comments and tests cannot change a decoded value, so the whole delta is the
baseline header key: 760 goldens, one line each, no body moves and no scenario
or adapter change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record real provider rows in the smart-search and paste fixtures

The smart-search and paste-lookup scenarios carried hand-written stubs, not
rows any handler can build: Linear issues of `{ id }` alone, GitHub items of
`{ number, title }`, and a GitLab item keyed by `iid`, a member neither work-item
type declares. Every one of them omits members the host's own types declare
non-optional and mobile then reads with no guard, so the corpus was evidence for
a requirement it could never have justified.

The rows are now the shapes the corpus already uses elsewhere (`tk-list-linear`,
`tk-provider-load`, `tk-list-gitlab-items`), checked member for member against
LinearIssue (src/shared/linear/issue-types.ts:3), GitHubWorkItem
(src/shared/github/work-item-types.ts:17) and GitLabWorkItem
(src/shared/gitlab-types.ts:165).

No schema moves in this commit. It records what main renders for a real row, so
the requirement that follows can be read against main's own behaviour rather
than against a stub.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): require the members the tasks readers' consumers read unguarded

Round-1 review found four unguarded consumer reads at members the corpus proves
the host sends, plus two the corrected fixtures now prove. Each one ends in a
TypeError inside a render or a useMemo, which is the defect class this migration
exists to close.

Required, each because a consumer reads it with no guard and the host's own type
declares it non-optional:

  title on an accessible project  project.title.toLowerCase()
  name, directories on a preset   localeCompare, and two joins
  labels on a work-item row       item.source.labels.filter, both label editors
  state.name, team.name, priority createLinearTask, and the reviewer sort

All six sit inside a salvagingArray, so a row that lacks one drops and the list
survives. The single-row paste lookup names the reply instead, because there is
no list for it to survive in.

Loosened in the other direction: the SSH record no longer requires `error` or
`reconnectAttempt`. Nothing reads either one — the gate spells
`matchingState?.error ?? null` and nothing anywhere reads reconnectAttempt — and
the record is a salvagedOptional, so requiring an unread member drops the WHOLE
record, whose fallback on the connect path is `fallbackSshState(id,'connected')`.
A reply of `{ targetId, status: 'auth-failed', error }` would have shown the
drawer as connected. Mobile's own stored type is widened to match; the shared
wire type is untouched.

The `iid` extension on the lookup row goes with it. Neither work-item type
declares such a member and every GitLab consumer builds its iid param out of
`item.source.number`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the screen parity hashes over the merged tree

Main landed the sibling tasks lane, which edits the same screen hook files this
branch does, so the merged tree hashes to neither side's constant. Both inputs
are legitimate: main's reply-schema conversions and this branch's WorkspaceSshRecord
rename.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): justify the smart-source casts by the schema, not the fixture

Five SAFETY notes argued from the corpus back to the wire: they justified a
cast by the stub rows the fixtures used to carry (`{ iid, title }`,
`{ number: 12, title: 'twelve' }`). Those rows were the defect corrected in
6763ff12e9, so the claims are now false, and the reasoning was never sound —
a fixture cannot say what the host may send. Each note now cites the schema's
own requirement rule, the host type and the consumer read.

Comment-only; no golden moves. The hook and statement parity hashes move
because `normalized` hashes a statement's full span, comments included.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): repair the line citations this lane's own edits aged

Eleven citations across five reply-schema modules pointed at the wrong line.
Every one was correct when written and rotted afterwards: the SAFETY-note
rewrites, the F4 dedupe's deleted casts and the sibling lane's merge each
shifted the files being cited. A citation is the whole argument for a
requirement, so a stale one reads as a fabricated one.

Found by resolving every `file.ts:line` in the five modules against the merged
tree and comparing the line's text to the claim beside it, not by reading them.
The ones that still resolve correctly are left alone, including three that
looked stale and were not.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the two advisory task probes total so a nullish payload cannot unhydrate the screen

`preflight.check` and `linear.status` are read under `success-result-or-skip` and documented as
advisory. That policy accepts an envelope whose `result` is absent or null, then asks the reader to
decode it; a `looseObject` refuses, the throw leaves the reader, and the caller's catch discards the
entire hydration. The corpus records the difference: on the `result-absent` and `result-null`
partitions main hydrates the Tasks screen and lists one provider, and the checked readers left it
unhydrated with no providers.

`.catch` restores main's answer exactly. Every consumer guards to the leaf and compares to `true`,
so absence, null and a garbage payload have always meant "not installed" and "not connected".

Four cases pin it, and removing either catch fails all four.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the RPC corpus at this lane's head

Repins the recording baseline to b354d1338a (the advisory-probe totality
fix) and records all 382 scenarios from that tree.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the RPC corpus after merging main

Repins the recording baseline to the merge commit and records all 386
scenarios from that tree, so the corpus carries both main's step-7 batch
and this lane's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the Linear row cast the checked schema made unnecessary

`found` is already assignable to the mobile `LinearMobileIssue` alias once
`linearIssueRowSchema` requires its nine members, so the assertion and its
disable line carried no type error. The sibling cast in
smart-source-search-requests.ts stays: it targets the shared `LinearIssue`,
whose `labelIds` is required where the schema leaves it optional.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): rewrite the SAFETY lines that argued from deleted fixtures

Three casts still justified themselves with the `{ id }` and
`{ number, title }` rows this branch replaced in round 1, which reads as a
licence to loosen the requirements that close the reproduced crashes. Each
now names what its schema requires and what the cast actually covers, each
verified by deleting the cast and reading the error:

- Linear rows: all nine read members are required, so `labelIds` alone is
  the gap between the schema and the shared LinearIssue.
- GitHub search: `items` and eight row members are required; the salvaged
  `T | undefined` types and the deliberately opaque `sources`/`errors` are
  what remain.
- Sparse presets: `id`, `name` and `directories` are required; the cast
  covers the three SparsePreset declares that the reply omits.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): resolve the bare :NN citations the first audit could not see

The earlier repair resolved only qualified `file.ts:line` citations, and
these doc blocks name a file once and then reference it as a bare `:NN`,
so every continuation reference went unchecked. Re-running the audit with
a resolver that carries the last-named file and directory forward finds
twelve stale line numbers and four references whose nearest named file is
the wrong one.

Fixed: the four `find`/`filter` lines and the layout equality tests under
the `views` requirement, the settings commit, the detail refusal throw,
and the three metadata guarded reads, all shifted by one to five lines.
The ui-state, paste-resolved and host-method references are now qualified,
because an intervening citation to another file silently reassigned them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the tasks parity hashes the round-2 fixes moved

One statement changed (the Linear list cast is gone) and three SAFETY
comments nested inside statements were rewritten, so the hook and
statement hashes move. Counts hold at 350 and 417, and the declaration,
semantic, render and style hashes do not move, which is what shows no
type, call or rendered tree changed with them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): delete the Linear list reader the checked schema replaced

`extractLinearIssueReadItems` lost its only caller when the smart-search
operation moved to `rpcResultVariant('linear-issues', …)`. What remained
was a function no screen can reach, a suite reporting coverage for it, and
a second 'Unexpected Linear tasks response' string competing with the
named reply error. The `LinearMobileIssue` type stays: it is the mobile
`LinearIssue` alias.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what a refused sparse-preset list actually does to the screen

"Reports the named error" overstates it: the error setter's value is
destructured with a leading underscore and read by nobody, here and on
main. The visible delta is `presetsLoaded` staying false, which disables
"New preset" and both draft entry points where main let the user create
one. No shipped host reaches the state: `repo.sparsePresets` has no
refusal arm.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the RPC corpus after the round-2 fixes

Repins to the round-2 head and records all 386 scenarios from it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the RPC corpus after merging main at 7a1f55c52a

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the persisted-ui-state reader total so an unreadable ui reply cannot unhydrate the Tasks screen

Main boxed the payload and read `undefined` off a string, number or array, so the screen
hydrated; a refusal here threw out of hydrateTaskState and failed the settings, preflight and
Linear legs beside it. Null and absent now hydrate with the defaults too, since every read of
the state is optional. The GitHub search SAFETY line separates the members the schema requires
from the ones it only types, and the parity hashes follow that comment text.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the RPC corpus over the total ui-state reader

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 14:08:59 -04:00
Brennan Benson abc8386e14 fix(mobile): name a create's launch so a lost reply cannot build two workspaces (#21137)
* fix(mobile): name a create's launch so a lost reply cannot build two workspaces

`agent.launch` admits a caller-supplied `operationId` through a durable ledger, so
exactly one execution happens and every replay returns the recorded answer. No client
sent one, so the machinery was inert and the original defect was still live: mobile
retries a lost create by design, and a retried launch built a second agent in a second
workspace.

Mobile now mints an operation id per create candidate and sends it whenever the host
advertises `agent.launch.replay.v1`.

The invariant is one operation per candidate. `computeAgentLaunchFingerprint` folds
`target` whole, so the workspace name is inside the fingerprint; carrying one id across
a name-collision bump would meet its own row under a differing fingerprint and refuse
`agent_session_operation_conflict`, failing the create outright on the second candidate.
The id is therefore minted beside `clientMutationId` at the top of each loop iteration
and reused verbatim by every retry arm inside that candidate — never re-minted, since a
new id is a new operation.

Admission runs ahead of every effect, so `_invalid` / `_expired` / `_capacity` prove
nothing launched: those re-send the same candidate unnamed rather than let bookkeeping
fail a create the host would have performed. `_unknown` is the one refusal that is not
safe to re-send, and it surfaces.

Also corrects a false comment: the legacy path caches the whole launch under
`clientMutationId`, so inside its 60s window a replay adds neither a workspace nor a
surface, and outside it adds both — not "a second surface, never a second workspace".

* fix(mobile): preserve launch identity on refusals

* fix(mobile): use launch receipts to authorize replay

* test: move mobile launch replay coverage outside node project

* fix(mobile): enforce replay-safe launch delivery at the host

* test: run mobile launch contracts in mobile checks

* test: cover mobile launch contract workflow dependencies
2026-09-17 10:06:11 -07:00
Jinwoo Hong 6b426a8623 test(mobile): repin the RPC recording corpus to main after #21176 (#21254)
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 12:44:41 -04:00
Jinwoo Hong 3e32b83522 refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7) (#21176)
* refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7)

Twenty-one unchecked reply readers across thirteen files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`, or
a card left "proven" over a reply that carried no rows. Deliberately a behaviour
change on malformed replies only.

What each domain required, and why it required no more:

- notifications (5 readers). All four call sites read the payload through `?.`,
  so every schema is nullish at the top level and no member is required. The
  test-push `reason` and the register `reason` become closed enums, because the
  two comparisons against them are the whole of what they decide and an arm this
  build does not know took the generic copy on main too. The stream unsubscribe
  and the unregister read no body at all.

- components (4). `repo.hooks` requires `source` and nothing else: the drawer
  assigns it straight into `SetupHookDetails.source`, whose type is
  `string | null`, with no guard in between — nullable so the "no hooks file"
  answer keeps its explicit null. `setupTrust` is nullable as well as optional
  because the `components-setup-ask` fixture sends an explicit null, and
  salvaging that would move a `normal` golden. `ui.get`'s trust record salvages
  per repo, so one unreadable repo cannot cost the others their approvals. The
  Codex redeem reply stays `z.unknown()`: `decodeResetResult` is a real
  scope-and-snapshot validator and splitting it would give one reply two refusal
  rules.

- terminal (4). The send verdict and the viewport pair keep main's exact
  `=== true` projections. `terminalSendAcceptedSchema` moves here from the
  session domain, which now re-exports it: terminal is the lower layer and two
  identical copies could drift on what "delivered" means.
  `terminal-send-rpc-response.ts` is deleted, its projection now being the
  schema's.

- transport (3). `status.get` declares its five members and requires the object;
  the three callers disagree about what an unreadable status means, so each keeps
  its own verdict behind a named reader — the gate wants the failure, and the
  probe and the pairing race must not have it, because both call `interpret`
  inside a `.then` fulfilment handler where a throw becomes a detached rejection.
  `capabilities` salvages whole rather than per element, which is main's own rule
  and what `transport-capability-probe-non-string-capabilities-drop` records.
  The two pairing readers are the shared credential contract itself, moved off
  the four call sites that each ran `.parse()` on the interpreted value; its
  `.strict()` is main's shipped rule for that released surface, not a new one.

- home (2), worktree (2), browser (1). The stats row is checked as an object and
  nothing more, `totalHomeStats` being the reader that says so itself; its
  per-host slot is now typed as the wire row it holds rather than as the computed
  total. `worktree.ps` cannot require `worktrees`: the host answers a union whose
  unchanged arm carries `{ unchanged, snapshotId }` and no rows. The twelve
  browser commands read no body; `browser.goto`'s settled URL stays nullish
  because `navigateToAddress` is inline in `MobileBrowserPane.tsx`, which no
  adapter mounts, and a move there would ship unevidenced.

Three fixtures were wrong and are corrected, each disclosed rather than worked
around: the runtime-context test kept a content hash directly under a repo key,
which is not a shape `ui.get` sends; and two snapshot-client tests ran their
reply list dry and handed `fetch` an absent result while claiming to model a
transport failure.

`push-test-envelope` is re-anchored at the same defect's new home, the cast
having been deleted. The boundary test's offender floor comes down from 20 to 10
with the list, which is what its own comment says it is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus and re-record step 7's checked reply readers

`baseline` moves to this branch's product commit, which is what `--record`
compares the fenced tree against, and every one of the 758 goldens is
re-recorded from it. The repin is what rewrites the `baseline` header on all of
them; nothing else about the corpus moves except the bodies disclosed below.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): mutate the workspace catalog's reader back to unchecked

The step-7 defect evidence needs a scenario whose reply is the one the change
moves. Every pilot scenario in the catalog family scripts a well-formed reply, so
a mutant that only changes how a *malformed* reply reads has nowhere to diverge —
which is why the pilot's own suite passed against an unchecked catalog reader
while its matrix golden failed.

`worktree-catalog-snapshot-unreadable` scripts `worktree.ps` answering
`{ ok: true }` with no result at all, which is what `result-absent` drives at the
matrix site, and records the fetch rejecting with `RpcIncompatibleReplyError`.
`worktree-catalog-unchecked-reader` then swaps the operation's reader for one that
answers `compatible: true` for every payload — main's reader, in one line — and
the recording moves back to a fulfilled fetch carrying
`admission: { kind: 'invalid' }`, which is the answer that let a broken catalog
render as an empty host (STA-3123).

One golden added and none moved: the manifest sits outside the fenced paths, the
family's matrix base is still `worktree-catalog-snapshot`, and the mutation
registry is not part of `recorderSha256`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the push-test reason arms the closed enum constrains

`pushDeliveryTestResultSchema.reason` closes over the four arms of the host's
`MobilePushTestResult` (src/shared/mobile-push-contract.ts:99), but no scenario
carried the member, so the corpus could not have caught a wrong vocabulary.
Three scenarios on the existing display-test mount carry it now: the two arms
the screen branches on and one arm no build knows.

Each golden was recorded first at the main pin 4b876758d3 and reproduces there
byte for byte, so the `normal` partition pins main's rendering rather than this
branch's. The unknown arm is the load-bearing one: main renders "Could not send
through Orca's push service." for an unrecognised string, and the salvage drop
renders the same sentence, so the closed enum costs a recorded `reply-salvage`
observation and no screen text.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): cite the host union behind each closed reply enum

A closed `z.enum` is only as good as the vocabulary it was checked against, and
mobile's own declared types are written from memory. Each of the three enums now
names the host type it mirrors, so the next reader re-checks it in one grep
rather than trusting the arms.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the commit that carries the host-union citations

`--record` compares the fenced tree against `baseline`, so a comment in
`mobile/src` moves the pin like any other product edit. Every one of the 762
goldens changes by exactly its `baseline` line and nothing else, which is the
evidence that the citation commit is inert.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the agent-history panel rendering when the catalog omits its rows

`worktrees` is a salvaged member, so it is absent on the three envelopes that
read as an object without rows, and `use-mobile-agent-history-state.ts:61` calls
`.find` on it with no guard. The cast erased that and the corpus recorded the
result: `matrix-aivault.history-screen-worktree.ps-1` carried
`crash: Cannot read properties of undefined (reading 'find')` on
`inner-ok-missing`, `inner-false-string-error` and `inner-false-object-error`.

`?? []` is what the sibling Home card already does. The SAFETY note cited that
card's golden, which is the opposite site, and now cites this panel's own family.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a malformed worktree.ps reply reported as invalid_response

`host-workspace-list-states.tsx` prints this code to the user verbatim, so the
checked reader's throw landing in the generic catch renamed a host-payload
defect into a connectivity failure. STA-3123 exists to make a broken remote host
diagnosable, which `network_error` is not.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the browser page commands back their one-line form

Only `browser.goto` reads a reply body, so the reader belongs on a two-argument
wrapper rather than on all thirteen call sites. The exported type of every
command is unchanged, and the doc comment no longer promises a shape the file
did not have. 109 lines to 72.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): import the terminal send verdict from the terminal domain

The re-export forwarded to two callers, which a direct import already gives
them, and the session suite re-pinned three cases `terminal-reply-schema.test.ts`
owns. One definition, one pin, one file hop fewer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): move the codex reset-credit pins next to their schema

They sat in the New Workspace suite, so a reader looking for the capability
whole-list drop by filename did not find it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record after the round-1 fixes

Three checkpoints of `matrix-aivault.history-screen-worktree.ps-1` move from a
recorded white screen to the loading list: `inner-ok-missing`,
`inner-false-string-error` and `inner-false-object-error` answer the panel an
object with no rows, and the `?? []` now seats an empty list instead of letting
`.find` throw. Every other golden changes by its `baseline` line alone, which is
the evidence the other four fixes move nothing the recorder observes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): stop requiring the stats row its own reader guards

Round-2 findings 1 to 3.

`homeHostStatsSchema` required an object that `totalHomeStats` already guards
(`if (!host || typeof host !== 'object') continue`), so the requirement bought
nothing at the read and cost the row upstream: the refusal reached
`fetchMobileHomeStats`'s `.catch`, the per-host slot was never written,
`hostIds.filter` found no host and the Home header drew no stats row where main
drew `0 / 0s / 0`. It takes `.nullish()`, and `HomeStatsRow` admits the
`null | undefined` main always had. The unit pin now says the slot keeps a null
summary and the total skips it, and sums one through `totalHomeStats` to show the
zeroed row survives.

The Home card's `SAFETY:` note claimed the reader proves `worktrees` is an array.
It does not; the `?? []` does. That is the same false sentence round 1 removed
from the agent-history panel, and a reader who believed it would delete the `??`
and reintroduce the white screen.

The `catalogError` branch on `RpcIncompatibleReplyError` had nothing holding it:
no adapter mounts the host screen, so no golden can reach it. One case in the
snapshot client pins the class the `catch` keys on. Mutation-checked by forwarding
the catalog schema as `z.unknown()`, which fails that case alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record after the round-2 fixes and main's merge

Pin moves to 0b8bd1c3c7, this branch's last fenced commit. The re-record
normalises the ten session create-terminal goldens main re-recorded in #20069
without repinning, and writes the Home stats family's bodies back to what main
records now that the summary reader no longer requires the object its own
consumer guards.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record the corpus at the merge with main

The merge took main's goldens verbatim at main's pin, so the four goldens this
branch adds were the only ones whose header did not name a commit in this
history. Repinning to the merge commit and re-recording gives all 764 one pin
and one recorder, which is what the new ancestry guard asks of the corpus.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): point the reply schemas' citations at the lines they claim

Every `file:line` in this branch's diff was resolved against the tree and its
target line compared to the claim beside it. Fourteen were wrong. Most had
drifted one to four lines onto a closing brace or the line after the code they
quote, which reads exactly like a good citation and can only be caught by
resolving it.

Two were wrong in kind rather than by a few lines. The viewport comment
credited the `=== true` projection to the refit call site, which reads plain
truthiness; the rule was main's reader, and the refit's two decisions are the
lines now cited. The capability comment cited a line for main's
`every(typeof === 'string')` rule inside the code this change deletes, so it
resolved to an unrelated brace; it now states the behaviour and says why no
line carries it.

Two more pointed at the head or tail of the statement they named and are
tightened to the line that does the work.

Comment-only: no schema, no reader and no call site moves. The corpus is
repinned and re-recorded on top because the recorder fences `mobile/src`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record after the citation fixes

The citation commit is comment-only, and the corpus says so: all 764 goldens
move on the `baseline` header line and nothing else. The re-record is needed
only because the recorder fences `mobile/src`, which a comment is inside.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the three unguarded-site reply schemas total so a malformed result cannot throw where main tolerated it

The ui.get reader is interpreted inside a fire-and-forget IIFE with no catch, and the home
stats and push delivery readers sit behind handlers that would print the reader's own sentence
where main drew a zeroed row or the generic copy. Each schema now decodes any unreadable
result as absent, which lands in the fallback main already took.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record the corpus over the total schemas

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the host-status gate reader by its export

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 12:40:18 -04:00
Jinwoo Hong f949d5fcc4 ci(mobile): fail CI when the RPC recording pin leaves main's history or the corpus does not reproduce (#21156)
* test(mobile): fail CI when the RPC recording pin leaves main's history

`mobile/rpc-foundation/pilot-scenarios.json` carries the commit every golden
claims it was recorded from, and `--record` refuses on any other tree. A
behaviour-change branch pins its own last fenced commit, which stops being
reachable the moment the branch squash-merges: nobody can record on main again
until a hand-made repin lands, and until now only a human noticed. #21123 was
that, and so was the repin after #20954.

`scripts/rpc-recording-pin-guard.mts ancestry` fails when the pin is not an
ancestor of the commit under test, and prints the repin recipe. It refuses to
answer on a shallow clone rather than trusting grafted history, so the job
checks out with `fetch-depth: 0`. Ordinary product drift past a reachable pin
is not a failure.

`reproduce` makes the other claim the corpus header makes, which the recording
suites do not: they replay the goldens against the CURRENT tree, so a golden
recorded somewhere other than the pin -- a merge that auto-merged golden JSON,
a refresh copied back from a scratch directory -- passes them and is what the
header exists to deny. It checks the pin out detached, lays this tree's
recorder and manifest over it, and lets the same suites compare in place, so
the comparison is `compareGolden` with lockfile and platform masked as ever.

It runs unconditionally on a push to main, which has no `verify` job and is
where a squash lands a spliced corpus. On a pull request it runs only when the
corpus, the manifest or the recorder moved: nothing else can move the verdict
away from the one the base commit published, and `verify` replays the corpus
against the branch tree meanwhile.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): judge the recording pin against the tree it was read from

Round-1 review of the pin guard.

The pull_request ancestry check read the pin out of the merge preview and judged
it against the branch head. Those differ whenever main repins after the branch
point, so ordinary stale branches failed, and the instruction told the author to
repin to their own head -- which creates the unreachable pin the guard exists to
catch. Judge the checked-out tree instead.

`git worktree prune` in the reproduce teardown was repository-wide. This git
directory is shared by every worktree on the machine (611 registered here), so
it could deregister an unrelated one whose directory was momentarily missing.
`worktree remove --force` alone is enough; a failure to remove is now reported
rather than papered over.

Also: the concurrency group is per commit on main, because GitHub cancels a
pending run in a group whatever `cancel-in-progress` says; the skip gate fails
closed when a provenance path stops matching instead of skipping forever; the
census-boundary comment states the rule the code uses; and five exports with no
consumer are now module-private.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): let an untracked golden and the guard itself buy a reproduction

Two bot findings on the skip gate.

`git diff` sees tracked paths only, but the reproduction's overlay copy and its
census both read the corpus directory as it sits on disk, so an untracked golden
or manifest is input to the verdict and used to skip the run that would judge it.
Enumerate untracked entries under the provenance paths the way the recorder
already does, and run rather than skip: an unjudged local addition is the case
the reproduction exists for.

The guard script is now a provenance path of its own, so a change to it re-runs
the reproduction it implements. Left alone deliberately: run-process.ts and the
workflow's `paths:` scope over src/shared, which is a pre-existing gap for the
whole mobile workflow rather than this job's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refuse to reproduce when the suite list has drifted from the files

Round-2 review.

The suite names reach vitest as positional filename filters, and vitest exits 0
when only some of them match. A renamed census suite therefore dropped out of the
reproduction silently and the guard still printed that the corpus reproduces:
three files and 761 tests instead of four and 762, exit 0. Resolve every name
under the recorder overlay before spawning, and throw naming the drifted entry.
The unit case walks the list and omits each name in turn, so no single rename can
slip past it. This is the same fail-open shape as the renamed-pathspec finding.

Also: pass an explicit directory type to `symlink`, since Windows needs one and a
junction needs no privilege where a real symlink does; and build the throwaway
test repositories with `symbolic-ref` rather than `--initial-branch`, which needs
git 2.28 against a declared baseline of 2.25.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 02:34:03 -04:00
Jinwoo Hong 229dd62cab test(mobile): repin the RPC recording corpus to main after #21169 (#21173)
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 02:33:59 -04:00
Jinwoo Hong 01a1b6b024 refactor(mobile): checked reply readers for the tasks item and list domain (step 7) (#21169)
* refactor(mobile): checked reply readers for the tasks item and list domain (step 7)

Thirty-eight unchecked reply readers across four tasks files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`,
or a sheet left ready over garbage. Deliberately a behaviour change on malformed
replies only; nothing on the wire moves.

  mobile-task-item-state-operations.ts    17
  mobile-task-item-detail-operations.ts    8
  mobile-task-item-comment-operations.ts   7
  mobile-task-list-operations.ts           6

Two rules decide every schema, and both are stated in
task-provider-entity-reply-schema.ts:

1. A member is required only where a tasks consumer reads it with no guard.
   Everything reached through `?.`, `??` or a `typeof` test stays optional,
   because a reply without it rendered the same fallback then and now.
2. No member is required that the site's own recorded `normal` reply lacks. The
   corpus is the only evidence of what a host really sends at each site, and
   requiring a member absent from that control would turn a good reply into an
   incompatible one.

Rule 2 holds two schemas at the container: `github.prFileContents`, whose
recorded reply is `{ oldContent, newContent, truncated }` where
`getPRFileContents` returns `{ original, modified, ... }`, and `gitlab.todos`,
whose recorded row is not a `GitLabTodo` and whose `normal` partition therefore
records main crashing in `actionName.replace`. Both still gain their container,
which is what names a reply that is not an object or not a list. Correcting
those two scenarios is the follow-up that unlocks narrowing the rows.

Nine writes share one envelope reader and five comment writes share another:
`ok === false` and `error` are one host convention across them, and no input
would make two of them want different answers. The acceptance, the name and the
recorded family stay per operation. Three readers are reused rather than
re-declared — the session domain's boolean confirmation for `setPRFileViewed`
and `resolveReviewThread`, and its salvaged-member combinators throughout.

Three call-site shape tests the reader now answers for are deleted: both
`Array.isArray(payload)` guards on the checks read and the
`typeof count === 'number'` fallback on the item count. `GitHubPRFileContents`
is widened to optional members, which is what the reader can promise, and
`buildGitHubPrFileDiffPreview` takes the widened sides — `splitContentLines`
already treated a falsy side as no content, so no runtime behaviour moves.

The tasks source-parity hashes are refreshed: hook, statement, declaration and
render-token counts are unchanged, the render-token hash does not move at all,
and `semantics` is a pure deletion of ten lines.

Inventory: 137 unchecked readers over 30 files becomes 99 over 26.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recording corpus and re-record the tasks reply deltas

`baseline` moves to 9133c02c5b, the commit that made the tasks item and list
readers checked, and the whole corpus is re-recorded from it. The repin rewrites
the `baseline` header of every golden; the body moves are the disclosed
behaviour change and nothing else.

What moved, and why: a malformed reply at one of the thirty-eight migrated read
sites used to reach the consumer as the declared type and fail downstream — a
property read on `null`, `.map` on a string, a rendered `undefined`. It now
stops at the operation boundary as one `RpcIncompatibleReplyError` naming the
method, which each call site's existing `catch` shows where it showed the
`TypeError` before. Every move is confined to a malformed reply partition of a
`tasks.*` or `linear.issue-detail` family; no `normal` partition moves, and no
family outside the tasks domain moves at all.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): anchor the tasks reply readers' null-collapse mutant

One registered mutant for the class the session domain shipped twice before a
review caught it: the assignable-user row's explicit `avatarUrl: null` collapsed
into absence. `tk-item-detail-metadata` records that null in visible state, so
the pilot suite kills it; it also fails the unit pin beside the schema and both
of that family's matrix goldens, including their `normal` partitions.

Three by-hand experiments back the other two claims the corpus should hold.
Applied to the product source, run, reverted:

- Loosening a required member the consumer reads with no guard —
  `linearIssueSchema`'s `title` to a salvaged optional — fails the unit pin
  `one Linear issue: refuses a reply missing a member createLinearTask reads
  with no guard`, and `tsc` rejects it, because the loosened output is no longer
  a `LinearMobileIssue`. No golden moves: the reply matrix varies the envelope a
  host sends, never the shape of a row inside a result, so a row requirement is
  unreachable from the corpus and the unit pin is the only thing holding it.
- Loosening a *container* requirement is reachable: making
  `linearAccountStatusSchema` `.nullable()` fails one matrix golden,
  `tasks.provider-load: reply partitions at linear.status#1`, on the
  `result-null` partition, as well as its unit pin.
- Swapping one checked reader back to unchecked —
  `githubPullRequestChecksRead` to `rpcUncheckedPayloadReader` — fails two
  matrix goldens, `tasks.item-review-github` and
  `tasks.project-row-review-checks`, both at `github.prChecks#1`.

Nothing under `mutants/` is pinned by a golden header, so this moves no
recording.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): correct the gitlab.todos fixture to a real GitLabTodo row

The `tk-list-gitlab-todos` reply sent `[{ id, targetType, target: { … } }]`, a shape `listTodos`
never produces: the host returns `GitLabTodo[]`, whose row carries `actionName`, `targetTitle`,
`targetUrl`, `projectPath` and `updatedAt` flat. Main crashed on it —
`Cannot read properties of undefined (reading 'replace')` from `createGitLabTodoTask`, with the
list rendering as an empty inbox and a raw TypeError on screen.

That crash was being read as evidence: a reader could not narrow this row without "refusing the
site's only success control", when the control was never a success. The fixture is the defect, so
the fixture is what moves.

The row is now a real `GitLabTodo` (src/shared/gitlab-types.ts:219) and main renders it: one item
titled "A GitLab todo", subtitle `group/project #4`, status `review requested`.

Recorded from the pinned main tree, not from this branch, so the corrected fixture's main
projection exists as a golden before any reader touches it: a detached worktree at
`4b876758d3158a8eb6b798055d8db7c58d1cd4a9` with this branch's manifest laid over it and its
`baseline` set to that commit, per the recorder README's detached-pin recipe. Control: all 756
other goldens reproduced the base corpus byte for byte; only these two moved. Both keep
`baseline: 4b876758d3`, which is the tree that produced them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): check the GitLab to-do row and drop its cast

With the fixture corrected, the row is readable, so the reader reads it. `gitlabTodoSchema`
requires the five members the screen reaches with no guard — `id`, `actionName` (read as
`actionName.replace`), `targetUrl` (what tapping the row opens, and the title's fallback),
`projectPath` (the subtitle and the repository badge's key and label) and `updatedAt` — and leaves
every guarded member optional: `targetTitle` behind `targetTitle || targetUrl`, `targetType` and
`targetIid` behind the two tests in `gitLabTodoTargetRef`, and `authorUsername` and `state`, which
this screen carries but never reads.

The list salvages: one unreadable to-do drops and the rest of the inbox still renders, which is
what the rest of this domain does with a row it cannot place. Nullish still reads as the empty
inbox the call site already read.

`GitLabTodo` in mobile-tasks-provider-detail-types.ts now says what the reader proves rather than
what the host declares, which is what lets the `as GitLabTodo[]` assertion at the call site go. It
was the last cast in this domain's consumers, and it was re-typing rows nothing had checked — the
phantom-field shape this series exists to remove.

Parity: the same three hashes the step-7 commit moved move once more, for the deleted cast and the
widened row type. Counts are unchanged and `semantics` does not move.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus after the to-do fixture and row check

Repins `baseline` to the commit that checked the GitLab to-do row and re-records all 758 goldens,
so the whole corpus is pinned to one tree again: the two `gitlab.todos` goldens were still pinned
to main's, which is the tree that produced their before-picture.

The disclosed move is the `normal` partition of `tasks.task-list-gitlab-todos`. That is the fixture
correction showing through, not a reader change: with a real `GitLabTodo` on the wire the list
renders one item where it used to render a TypeError. Every other body move in this refresh is a
malformed-reply partition, as before.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): correct the github.prFileContents fixture to the host's shape

The recorded `normal` reply at both `github.prFileContents` sites was
`{oldContent, newContent, truncated}`, which `getPRFileContents` never returns:
it answers `{original, modified, originalIsBinary, modifiedIsBinary,
originalTooLarge, modifiedTooLarge}` (src/main/github/pull-request-file-contents
.ts:121-128), with the two too-large flags set only where a side was skipped for
size (:54). Both sites' `normal` partitions therefore proved nothing about the
success path, and `githubPullRequestFileContentsSchema` cited that fiction as
the reason it could require nothing. Same defect class as the `gitlab.todos`
fixture corrected in 32bd65c134, found by round-1 review.

Before-picture recorded against main's own product tree, not this branch's: a
detached worktree at `4b876758d3`, this branch's `pilot-scenarios.json` copied
in with `baseline` set to that commit, per the recorder README's detached-pin
recipe. Control: 744 of the 758 goldens reproduce the base corpus byte for byte;
the 12 copied here are the two affected families, and the remaining two are the
already-corrected to-do pair, which reproduced 32bd65c134's bytes exactly.

The 12 carry `baseline: 4b876758d3`, the tree that produced them. The next
commit's reader change and the repin that follows it re-record them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record main's reaction rendering for both providers

No scenario in the corpus carried a comment reaction, so nothing in the 758
goldens could see what a reader does to one. Round-1 review found the
consequence: this branch closed `reactions[].content` to a mobile vocabulary
(`thumbs_up`) that no producer sends, and the corpus stayed green because the
member was never on the wire.

Two scenarios, one per provider, each a second scenario in an existing family so
no matrix base and no existing golden moves. The GitHub one carries
`GitHubReactionContent` as the host sends it — `'+1'` and `'heart'`
(src/shared/github/comment-types.ts:3-17, normalised from GraphQL at
src/main/github/comment-reactions.ts:19-27). The GitLab one carries
`GitLabReaction`, which is `{ name, count }` with no `content` at all
(src/shared/gitlab-types.ts:60-72).

Recorded against main's product tree at `4b876758d3` through the detached-pin
worktree, so the `normal` partition now pins what main renders rather than what
this branch renders. Control: all 758 existing goldens reproduce the previous
pin recording byte for byte; the only difference is these two added files.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): forward a comment reaction instead of matching mobile's vocabulary

`DETAIL_REACTION_CONTENT` was `thumbs_up | thumbs_down | ...`, a vocabulary no
producer of this list sends. GitHub answers `github.workItemDetails` with
`PRComment[]` whose reactions are `GitHubReactionContent` — `'+1'`, `'-1'`,
`laugh`, ... (src/shared/github/comment-types.ts:3-17), normalised from GraphQL
at src/main/github/comment-reactions.ts:19-27 — and GitLab answers with
`GitLabReaction`, `{ name, count }` with no `content` member
(src/shared/gitlab-types.ts:60-72). The closed arm set dropped every real
reaction row on both providers, which is a good-reply path this PR must not
change.

`content` is forwarded now, salvaged the way every other guarded member here is;
`count` stays required, because the `count > 0` filter at
mobile-tasks-item-comments.tsx:145 is the one unguarded read. `DetailComment`'s
eight phantom arms go with it: mobile's declared type was written from memory,
not from the wire, and widening the type is the fix rather than narrowing what
the host may send.

`COMMENT_REACTION_EMOJI` was keyed by that same phantom vocabulary, so it
resolves no glyph for a real reaction and the chip renders without one. That is
a pre-existing defect and it stays exactly as it is: the map is typed
`Record<string, string>` and the lookup takes `?? ''`, which resolves to the
same `undefined` main resolved for both providers. The two scenarios recorded in
68a3db2a3a pin that rendering, so a future arm set cannot drop the rows unseen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): stop citing the file-contents fiction as the reason nothing is required

The schema comment and its unit pin both named `{ oldContent, newContent,
truncated }` as "the recorded reply", which d26aeecdb0 corrected. The rule that
keeps every member optional is unchanged and is now stated from the reads
instead: the call site files the payload under the file path and reads nothing
off it, the review panels reach each flag through `?.`, and `splitContentLines`
takes `string | undefined` behind a falsy guard. The pins move to the host's own
shape, plus the too-large pair a skipped side carries and the empty object that
shows no member is required.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): import MERGEABLE_STATE instead of redeclaring it

`task-item-detail-reply-schema.ts` declared its own copy of `['MERGEABLE',
'CONFLICTING', 'UNKNOWN']` while already importing four member helpers from
`../session/github-pr-entity-reply-schema`, which exports that arm set and uses
it in the identical expression three times. Two copies of one wire arm set is
one place to fix when a provider gains a fourth state.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): inline the alias-only bindings the deleted casts left behind

Each of the eight was `const result = x as { ok?: boolean; error?: string }`.
With the cast gone the line is a rename of a binding that already has a name,
and every one of them is followed immediately by the same `ok === false` check.
Reading `created.ok` / `updated.ok` / `written.ok` / `replyResult.ok` directly
leaves one name per value.

The parity constants move with it and with the reaction change before it. The
comment there names both: ten string literals leave `semantics` with the phantom
reaction vocabulary and one arrives with the `?? ''` fallback, and the alias
deletions move the hook and statement hashes. No `rpc:` or `jsx:` signature
moves, the render-token hash does not move, and the hook, statement and
declaration counts are unchanged at 350, 417 and 194.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): register the container-requirement mutant the matrix kills

`f1b695f161` proved by hand that making `linearAccountStatusSchema` `.nullable()`
fails the `result-null` partition of `tasks.provider-load`'s matrix, and left it
unregistered. Registering it in `pilot-mutants.test.ts` is not available: that
suite drives the manifest scenario as written, and `tk-provider-load` scripts a
fulfilled `linear.status`, which a nullable container accepts exactly as the
required one does. The mutation only has somewhere to diverge under a partition
the pilot never reaches.

`family-mutants.test.ts` drives one named variant of a family's matrix instead,
against that variant's own slice of the golden, and each entry names both the
partition that kills the mutation and one that cannot see it — so the entry
records where the coverage is rather than that some golden went red. The
comparison is the whole recorded variant, the way the family suite compares:
under a last-state projection this mutant survives, because the divergence is in
the settlement and both paths reach the same final screen state.

Nothing under `mutants/` is pinned by a golden header, so this moves no
recording.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus after the round-1 review fixes

`baseline` moves to 542c1c38ed, the last fenced commit, and the whole corpus is
re-recorded against it. The two fixture corrections and the two added reaction
scenarios were recorded at main's pin first, so this run is what carries them
onto the branch's own reader.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): keep the Linear create arm's own binding out of the inlining

The alias inlining swept one line further than the finding it answers: the
Linear arm's `const result = linearIssueCreate.interpret(reply)` is a
declaration with a name, not an alias for one, and renaming it put a
pre-existing `createLinearTask(...) as Extract<TaskItem, …>` assertion from
#17438 inside this branch's changed lines, where the changed-code casting gate
attributes it to this PR. Reverted to `result`; the eight bindings the review
listed stay inlined.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): let each module build its own Linear team reader

`linearTeamListReader` was exported from the detail-operations module only so the
list-operations module could import it, which adds an operations-to-operations
import edge that buys nothing: `rpcResultVariant` is a pure factory, so two calls
with the same schema produce two functionally identical readers. What keeps the
composer's picker and the saved-selection reconciler agreeing about a team row is
that both build from `linearTeamsSchema`, which is already exported.

Deleting the export also puts the composer-policy JSDoc back on
`linearComposerTeamListRead`. JSDoc binds to the next declaration, so the block
explaining why this method carries two operations with different acceptance
policies had drifted onto the reader, leaving the operation it is about
undocumented.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the last round-1 commit

`baseline` moves to 75c568c244 and all 760 goldens are re-recorded against it.
Nothing but the `baseline` header moves: the two product commits since the
previous repin — the Linear create arm's binding restored and the team reader
built per module — change no observation in any recording, which is what a pure
refactor should look like here.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the RPC recording corpus to the main merge

The merge of origin/main moved both lockfiles, which the recorder fences,
so --record refused until the pin named a commit whose fenced tree matches
this one. Repinned to the merge commit and re-recorded: 760 goldens, header
only, `baseline` and `lockfileSha256`. No observation moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): point the reply-schema citations at the lines they name

The alias inlining and the detail-loader rewrite moved fifteen of the consumer
lines these comments cite, and the comments are the evidence for requiring a
member only where the consumer reads it unguarded. Every citation re-checked by
opening it; the host-side ones were already right.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): stop repeating the file-contents fiction on the type

The corrected fixture carries four of the six members at both call sites, so
"the recorded reply carries none of these" is false. The reason is the one the
schema already gives: no reader reaches a member without `?.` or
splitContentLines' falsy guard.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): forward a file's viewed state instead of closing it

No scenario reply carries a file row, so no golden can observe either of this
schema's two arm sets. `viewerViewedState` is read only by two `=== 'VIEWED'`
tests, so it is forwarded and an arm this build predates reaches them as itself.

`status` stays closed: its only consumer sends it straight back as a
`github.prFileContents` param, which the host validates against the same seven
arms (github-pull-request-params.ts:62). Forwarding could not reach the wire
without a cast, and the host would refuse the arm on its own params, so the
drop to absent that becomes `?? 'modified'` is the compatible read.

Parity: one declaration and three arm literals, no executable change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say that a Linear state-update refusal is ignored, as on main

`require-result-or-throw-message` throws only on an outer refusal, and
`linear.updateIssue` refuses in band as `{ ok: false, error }` on a
successful envelope, so the refusal reaches no `catch`. Main read the
same payload unchecked and discarded it, so nothing here changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recording corpus to the origin/main merge

Main's #20069 re-recorded ten session create-terminal goldens and moved
twenty-one files under src/shared, both inside the recorder's fence, so
`--record` refused until the pin caught up. The re-record moves one key,
`baseline`, in all 760 goldens and nothing else; the ten taken from main
reproduce byte-for-byte apart from that pin.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 02:17:25 -04:00
Brennan Benson e42f7c00bd feat(native-chat): render a proposed plan as a plan, not a generic approval (#21090)
* feat(native-chat): render a proposed plan as a plan, not a generic approval

A finished plan arrives as an ExitPlanMode tool call. With no handling for
it, the generic approval path serialized the tool input, so a plan appeared
as thousands of characters of escaped JSON. A plan is content to read, not a
privilege to grant.

Classify the plan in the permission callback and carry it as a typed subject
on the approval item, keeping the existing approval kind so the prompt still
reaches every consumer. Mobile filters pending approvals on that kind, so
introducing a new one would have made the prompt vanish there silently.

Classification runs before registration, so a future permission-mode
short-circuit cannot swallow a plan proposal. The assistant tool-use stream
is a second ingress and is pinned by its own test, because neither path can
be assumed to fire on its own.

Rather than adding a second card, the plan renders inside the approval
card's existing bounded content region. It inherits the height cap, the
scrolling, the keyboard focus and the pinned action row that region already
provides, and a typed plan replaces the raw detail instead of rendering
both. Buttons read as plan decisions. Mobile renders the same subject
through its own markdown component in the same region.

* fix(native-chat): preserve plan review semantics

* fix(native-chat): keep plan approval one-turn
2026-09-16 23:01:57 -07:00
Brennan BensonandMerge Sim 5287c5cdbc fix(mobile): stop a created tab from jumping when the host snapshot lands (#20069)
* fix(mobile): stop a created tab from jumping when the host snapshot lands

Creating a tab from the mobile session strip painted the new tab at the end
of the strip and then visibly jumped it to a different slot a beat later.

The client asked the host to insert the tab after the active tab, but then
predicted a different placement for its own optimistic paint:

    afterTabId: activeSessionTabId ?? undefined   // host: splice(insertAfter + 1)
    ...
    return [...prev, { ...created, isActive: true }]   // client: append

Two independent placements that disagree, so the optimistic frame is wrong by
construction and the tab snaps to its real slot on the next published snapshot.

The disagreement dates to 57a70d2ac0 ("Fix mobile session tab authority"),
which introduced afterTabId and left the append in place. Before that the
client used terminal.create with no anchor, so both sides appended and agreed.

Rather than teach the client to re-derive the host's rule, both sides now call
one shared placeCreatedSessionTab, and the client captures a single afterTabId
for the request and the paint so they cannot drift apart again.

The host change is a pure refactor onto the shared helper; the 1260-test
runtime characterization suite is unchanged. The mobile route-parity hash pin
moves once because handleCreateTerminal's body changed - it is the only one of
the 12 extracted functions that differs.

* fix(mobile): keep split terminal placement stable

* fix(mobile): negotiate split tab placement

* fix(e2e): run worktree first-paint probe on a mapped window

* test(mobile): type tab placement updater

* test(mobile): model current host in create recordings

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-16 22:42:40 -07:00
Neil 6c3b97b950 fix(mobile): a scope refusal is not a missing method on the Relay pairing probes (#19952)
* fix(mobile): a scope refusal is not a missing method on the Relay pairing probes

The desktop's mobile allowlist gate runs before its RPC dispatcher, so a method an
older desktop predates is absent from both and the phone is answered `forbidden`,
never `method_not_found`. Keying the "too old for Relay, stay on LAN" fallback on
`method_not_found` alone therefore never fired against the exact desktop it exists
for: first-time pairing threw instead of committing a LAN host.

`isPairingRelayRpcUnavailable` accepts both codes at the three pairing probe sites.
It is pairing-scoped on purpose - `isMethodNotFoundRefusal` has four other consumers
that must keep reading `forbidden` as a refusal, not as absence.

The main-side test pins the claim the fallback rests on: the dispatcher really does
answer `forbidden` to a mobile-scoped device and `method_not_found` to a runtime one,
and this build allowlists both probes, so `forbidden` on either can only mean an
older desktop.

* fix(mobile): leave a breadcrumb when a desktop refuses relay pairing

The LAN fallback now commits a host instead of throwing, so the refusal code
was the only record of why a phone ended up without a relay endpoint and
nothing wrote it down. Log it on the path that swallows it.

Narrow `isPairingRelayRpcUnavailable` to the two codes it matches rather than
to `RpcFailure`: a plain failure guard would collapse the *false* branch to
`RpcSuccess`, which a refusal carrying any other code still reaches.

Rename the `'method-not-found'` sentinel in the direct-upgrade reader, which
stopped describing what it covers, and correct two comments that named a
`method_not_found` mechanism the desktop cannot produce for these methods:
both probes have been allowlisted and registered by the same commit since
Relay landed, and an unwired pairing provider answers `runtime_error`.

* docs(wire): record that the mobile surface refuses by scope, not by absence

Two comments cited this page for "a scope refusal is not a missing method" and
the page did not say it — the only nearby statement says the opposite, because
it describes the runtime-scoped surface, where the dispatcher does answer
`method_not_found`. The allowlist gate makes the mobile surface the exception,
and the harness does not run that surface, so this note is the only record.

* docs(mobile): name the pairing site the scope refusal actually reached

The comments and the wire-compat note said this fixed first-time QR pairing.
It cannot: the `relay` block on the pairing offer, both RPC handlers and both
allowlist entries all landed in 77b154d5dd, so a desktop old enough to refuse
the probe also omits the offer block, and that flow commits a LAN host without
ever probing. The site that reached is `upgradeDirectMobileRelay`, which
re-probes every LAN-only host on reconnect: the refusal threw into the
controller's swallowing catch, so the write-once journal it had just written —
and the pending resume secret in it — was never retired.

Also drop two overclaims: the phone's Files and Git fallbacks have read both
codes since they shipped, so this is settled practice rather than a new rule,
and the reason the allowlisted-but-unregistered case cannot ship is
mobile-rpc-allowlist.test.ts, not a convention about what lands together.
2026-09-16 22:22:05 -07:00
Neil 2569a71ce8 fix(deps): update vulnerable dependencies without new overrides 2026-09-16 20:36:24 -07:00
Jinwoo Hong f2e4d2fdb0 test(mobile): repin the RPC recording corpus to main after #21089 (#21123)
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 21:39:13 -04:00
Jinwoo Hong 4b876758d3 refactor(mobile): checked reply readers for the session domain (step 7) (#21089)
* test(mobile): record main's session reply behaviour at every unrecorded read site

Step 7 for the session domain changes how 51 RPC readers read a *malformed*
reply. Eleven of the session read sites had no recording family, so main's
answer to a malformed reply at those sites was undocumented and the reader
change would have had nothing to move. This commit is the before picture, taken
from main's own tree with no product edit in it.

Ten new families, twelve scenarios, twenty-five goldens:

- `session.review-file-diff` / `session.review-branch-diff` — `git.diff` and
  `git.branchDiff` read through the review projection, which the Changes
  screen's verbatim readers do not cover.
- `session.review-git-mutations` — the single-file `git.stage` / `git.discard`
  and the bulk stage sweep's second `git.stage`.
- `session.review-send-sheet` — `session.tabs.list` read for the agent
  terminals the send sheet lists, the third reader on that method. Needs an
  `open-send-sheet` action on the review-action adapter, which re-digests that
  family's eight goldens on `adapterSha256` and nothing else.
- `session.browser-tab-create` — `browser.tabCreate`.
- `agentSession.structured-create` — `agentSession.create`, whose family base
  only ever covered the support probe.
- `session.tab-rename` / `session.tab-close-session` — `terminal.rename` and
  `session.tabs.close`.
- `settings.new-tab-local-agents` — `preflight.detectAgents`, the arm the
  new-tab loader takes for a workspace with no connection.

`baseline` is repinned to main's tip because two commits (#20659, #21004)
touched a fenced path after the pilot's pin, so `--record` refuses on main's own
tree until it moves. The repin is what rewrites `baseline` on all 705 existing
goldens; nothing else about them moves.

Decoded against origin/main through the value pool: 705 header-only (`baseline`
on every one, `adapterSha256` on the eight review-action goldens), 0 body-moved,
25 added, 0 deleted.

Not covered, with the reason: the chunked clipboard upload's
`appendImageUploadChunk`, `commitImageUpload` and `abortImageUpload` cannot be
matrixed, because `replyMatrixSites` takes every completion in the base scenario
and the chain's later params carry the `uploadId` the start reply named. Driving
`clipboard.startImageUpload#1` therefore makes main send an append whose params
no scripted step matches, and the recorder raises `Request params mismatch:
clipboard.appendImageUploadChunk#1` instead of recording. The two families were
written, probed and removed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): checked reply readers for the session domain (step 7)

Fifty-one unchecked reply readers across nine files become checked zod readers,
so a malformed host reply surfaces as one readable error at the operation
boundary instead of a downstream `TypeError`, a rendered `undefined`, or a
screen left ready over garbage. Deliberately a behaviour change on malformed
replies only.

Eight schema modules, one per reply family, each recording the consumer line
behind every requirement and the host handler that publishes it:

- `clipboard-image-reply-schema.ts` — the upload slot's `uploadId`, the commit
  and single-frame path strings, and the two legs whose body nothing reads.
- `github-pr-mutation-reply-schema.ts` — the `{ ok, error }` status envelope as
  two variants, and the bare-boolean confirmation.
- `github-pr-entity-reply-schema.ts` / `github-pr-read-reply-schema.ts` — the
  seven PR sidebar reads. Every identity requirement the hand parsers had is
  kept, so a payload that degraded to null still degrades to null; what changes
  is a payload that is not the declared container at all.
- `diff-review-reply-schema.ts` — the normalized branch compare, the review
  notes on the worktree record, the three file-diff arms, and the file-level git
  mutations.
- `review-terminal-reply-schema.ts`, `session-launch-reply-schema.ts`,
  `session-read-reply-schema.ts`, `session-write-reply-schema.ts` — the review
  send sheet, the launch paths, the session screen's reads and its writes.

Requirements are exactly the members a consumer reads unguarded, everything else
is a salvaged optional with main's own default applied in the transform, and no
schema is `.strict()`: a member a newer host adds passes through untouched.
Enum arm sets that a reader compares against pass through or degrade to the arm
the reader handles most conservatively; the two closed sets — the committed
change status and the diff kind — are closed because main *dropped* an arm it
did not know rather than passing it through, and degrading them would draw a row
or render a diff main never did. No member is coerced on the way back to the
host.

`github-pr-parsers.ts`, `github-pr-comment-parsers.ts` and
`github-pr-value-readers.ts` are gone; their suite is now the parity record for
the schemas that replaced them, with the four cases that refuse rather than
degrade marked as such. Twelve call-site casts are deleted, and three dead
"response was invalid" branches with them: the reader refuses those replies now,
so the error names its method.

The nine session files come off `unchecked-rpc-reader-inventory.ts` entirely
rather than being lowered. `git show --stat` on this commit touches nothing
under `mobile/rpc-foundation`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): unit-pin every session reply schema's decision

Three kinds of case, one per kind of decision the schemas encode: a member a
consumer reads unguarded is required and its absence refuses, an arm set a
reader compares against degrades to the arm that reader handles most
conservatively, and a reply whose arms need different members is declared as
variants and each arm is read.

The last suite is the wire-compatibility claim: a member no reader knows passes
straight through, on the markdown document, the upload slot and the terminal
inventory alike, so a newer host is never refused for a field mobile does not
read.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refresh the corpus for the session domain's checked readers

Repins `baseline` to the last commit touching a fenced path and re-records all
730 goldens, which is the disclosed behaviour change taken as an observation.

Decoded through the value pool against the pre-refactor tree on this branch:
688 header-only with `baseline` the only key that moved, 42 body-moved, 0 added,
0 deleted. The 42 are seven named scenarios and thirty-five matrix goldens, and
every moved checkpoint's own reply is malformed or refused. Three `normal`
partitions appear in the list and none of them reads a well-formed reply
differently: the review file-diff family's base scenario drives three legs and
its third is scripted `{ kind: 'unknown' }`, so that leg's checkpoint moves in
every variant, the varied leg included. The same append-only-history effect puts
`pr-read-upstream-error`'s `no-pr` checkpoint in the list for the malformed PR
recorded before it.

What the corpus now records, in one sentence: a property read on null, a V8
destructuring message shown to the user, and four hand-written "response was
invalid" strings are replaced by one message that names the method, and four
screens that published a malformed payload as ready state now show an error
instead.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): split the expanded check run out of the PR read schemas

`github-pr-read-reply-schema.ts` was 328 code lines against the 300-line cap.
The expanded check run and the annotations, jobs and steps listed under it are
one reply with no reader in common with the other six, so they move to
`github-pr-check-reply-schema.ts` whole. A move, not an edit: no schema changes
and no golden moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the branch's last fenced-path commit

The schema-module split touched `mobile/src`, so `--record` refuses on the pin
the previous refresh left behind. Repins to that commit and re-records. Decoded
against the previous corpus: 730 header-only with `baseline` the only key that
moved, 0 body-moved, 0 added, 0 deleted — the split is a move, and the corpus
says so.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the worktree display-name cast's type import

The live-title read is typed by its schema now, so the cast it annotated is gone
and the import it needed with it. oxlint flags the leftover.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the branch tip

The unused-import removal touched a fenced path, so the pin moves with it.
Decoded against the previous corpus: 730 header-only on `baseline` alone,
0 body-moved, 0 added, 0 deleted.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): contain a refused prChecks reply to the checks section

The checks read was the one phase-1 dependency that could take the whole PR
sidebar down. `loadPrSidebarData` routed `!checksOutcome.ok` through
`failureState`, so a host whose `github.prChecks` shape drifted cost the user
the title, body, comments, reviewers and merge controls — everything they
opened the sidebar for — over a section that renders a row of icons. Main
never noticed because its unchecked reader answered `[]` for the same reply;
this branch's reader refuses it, which is correct, and which is what makes the
containment necessary.

Contained the way phase 2 already is: a failed read keeps `kind: 'ready'`,
empties `checks`, and carries the message in a new `checksError` so the checks
section can say what happened. The sidebar can no longer reach `error` or
`blocked` on the checks read alone.

Also pins the enum departure this PR makes deliberately. The degrading arm
sets go through `salvagedOptional(name, z.enum(...))` rather than `openEnum`
because `openEnum` refuses a non-string where main mapped it to the
conservative arm; nothing held that, and all 2477 tests stayed green against
the swap. Six cases now hold both halves: a non-string degrades on the three
open sets, and an unknown arm drops the row on the closed ones.

Four deletions the reviewer found: a reaction-token alias with no importers,
the `errorType`/`fetchedAt` the branch-lookup reader fabricated to satisfy a
type whose only consumer reads neither, two bare schema aliases, and a
quick-commands pass-through with two callers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the containment commit

`--record` refuses unless the product tree equals `baseline`, so the fix above
moves the pin. The corpus re-recorded in place against it: 730 goldens, every
one header-only on `baseline`, no observation moved.

No observation moved because no family reaches the code the fix changed. The
`github.pr-read` family calls the seven wrapper reads directly and records
their `{ ok, error }` outcomes; `loadPrSidebarData` sits a layer above that and
no scenario mounts it. The prChecks outcome is identical before and after —
what changed is what the sidebar does with it — so the unit suite is the only
oracle for the containment.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record the PR sidebar's checks containment

The containment landed with no golden: no scenario mounted `loadPrSidebarData`,
so the row in the delta table rested on unit tests alone. `PrSidebarLoadDeps` is
five client-taking functions, so a new adapter drives phase 1 directly and
records the `PrSidebarState` it resolves to — no React host, and no edit to an
existing adapter, so no recorded golden moves.

Two scenarios: a normal load, and one whose checks leg answers a shape the
reader refuses. The matrix over the base then drives all eleven partitions at
`github.prChecks#1`, and every one of them records `ready` with a `checksError`
where main took the whole sidebar to `error`. `pr-sidebar-checks-failure-state`
is the mutant that routes the refusal back through `failureState`; it moves both
`pr-sidebar-checks-refused` and the prChecks matrix golden.

Also pins two closed-and-required enum decisions that were free to become
defaults — an unknown check-summary state drops the summary block, an unknown
reaction content drops the reaction — deletes four exported type aliases and
five enum constants with no reader outside their own file, makes
`PRChecksSection`'s `checksError` required so a second caller cannot silently
lose the message, and stops the header reading "No checks" when the checks were
unreadable rather than absent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the pr-sidebar family commit

Six new goldens — two pilots and the four matrix sites the base scenario
scripts — and `baseline` on the 730 that already existed. No body moved and no
`adapterSha256`: the family is a new adapter module, so nothing recorded through
another one re-digests.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the corpus against the merged main

Repins `baseline` to the merge commit and re-records all 736 goldens in
place. Against `origin/main` the 705 shared goldens move only on
`baseline` (672 of them header-only), leaving the same 33 body moves and
the same partitions the branch carried before the merge, plus its 31
added goldens.

Every body also takes main's recorder shape from #21088: `sent` becomes
`ordinal` over one interleaved write counter, subscriptions record a
cleanup checkpoint, and a salvaging read now reports a `reply-salvage`
effect naming what it dropped.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep an explicit null on the two tri-state PR flags

`autoMergeAllowed` and `mergeQueueRequired` carry three answers, not two:
`null` is GitHub saying auto-merge is not allowed, `undefined` is the host
not carrying the member at all. The readers coalesced the null away, so a
well-formed reply read differently from the parsers they replaced, which
preserved it explicitly. Both shared types already declare `boolean | null`.

No consumer separates the two today — `pull-request-auto-merge-availability`
compares with `=== true` and `!== false` — so this is parity, not a visible
fix, which is exactly why it needed a test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the tri-state flag commit

All 736 goldens move on `baseline` alone: no scenario scripts an explicit
null on either flag, so preserving it changes no recorded screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): check the two session-write readers #21083 brought

Step 7 empties the session block of the unchecked-reader inventory, and
#21083 landed two readers into it after that: the New Tab create's member
read of `tab`, and the display-mode toggle's payload. Converting them is
what keeps the claim true — a session line reappearing would mean the
domain is not migrated.

`created-terminal-tab` requires `tab.id` and `tab.type === 'terminal'`,
because the strip keys the new tab on the id and spreads the rest into a
union whose arm `type` picks. `terminal`, `title` and `terminalTheme` stay
optional behind main's own guards, and unknown members pass through.
`terminal-display-mode-set` reads nothing, so it takes the same
`z.unknown()` the other five unread writes take.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record over #21083's corpus

All 736 goldens this branch already had move on `baseline` alone, and
#21083's 22 arrive beside them. One of the 22 moves against main's own
recording: `matrix-session.create-terminal-session.tabs.createterminal-1`,
where the New Tab create's five malformed partitions read
`Cannot read properties of undefined (reading 'tab')` and now read the
method's own message. Two of them also stop unsubscribing the terminal the
user was watching before the property read threw, so a create that never
happened no longer costs the live pane its subscription.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what carries a refused create reply to the catch

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 21:24:31 -04:00