#21674 re-recorded the corpus with `baseline` set to its own branch commit
`1006ad670a`, 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 `e8a7be4ce2` and re-record from that tree. Every
golden's body is byte-identical to the one #21674 recorded; only the two
header lines move: `baseline` (788 files) and `lockfileSha256` (778
files), the latter because #21652 changed `mobile/pnpm-lock.yaml` after
that branch recorded.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Merge fully verified: desktop/mobile focused suites, node and mobile typechecks, changed-code quality, hosted RPC recording pin, package checks, all test shards, and verify pass. This fixes#20389 across composer, drop, picker, and mobile clipboard-accessory paths.
#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
* 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.
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
* 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
* 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
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
* 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
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
* 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
* 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
* 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
* 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
#21088 landed product changes on two fenced paths — the mobile hosted-review
create params and the shared hosted-review contract — without moving the
manifest baseline, so the corpus stayed pinned to 97aa5ff19b and --record
refuses on main with "Product sources or lockfile differ from the pinned main
baseline".
Repin baseline to 9add08bb59, main's last commit
to touch a fenced path, and re-record the whole corpus in place against it.
No behaviour moved: decoding every golden through its own values pool against
origin/main classifies all 705 as header-only with baseline the single moved
key, and zero body moves, additions or deletions.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-recorder): one shared write ordinal for requests, payloads and effects
`sent` stamped each payload and effect with the number of requests sent at
write time, which orders those two lists against sends but never against each
other. A family that sends no requests therefore had every stamp at `0`:
moving `host-worktree-refresh.ts`'s two initial snapshot reads from after
`client.subscribe` to before it moved none of the 705 goldens.
One monotonic counter per recording now stamps requests, payloads and effects
alike at the moment each is written, so the three append-only lists are ordered
against each other. The same reorder now fails five goldens. A request is
stamped at the logical `sendRequest` call rather than when its physical payload
is published, so a send that waited for connected carries two distinct stamps.
Full re-record from the pinned baseline: 699 bodies moved, 6 header-only,
0 added, 0 deleted; the only moved JSON paths are `sent` leaving and `ordinal`
arriving on `sender`, `payloads` and `effects`. Decoding with those two fields
stripped leaves all 705 header-only.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile-recorder): observe streams still registered at teardown
Closing a stream only writes to the wire when its method has an unsubscribe
builder. `notifications.subscribe` has none, so a cleanup that forgets its
local `unsubscribeStream()` leaks a live registry record and nothing on the
wire changes. Until now that class was covered by one hand-written scenario
per method, which stops the stream and cuts over so the leak reappears as a
second subscribe payload.
Teardown now asks each session's `RpcClientStreamRegistry` what it still holds,
after the product's cleanup and before the transport disposes it, and records a
non-empty answer as a `streams-registered-at-teardown` effect carrying each
stream's method, subscribe payload and cancelled flag. The set is read off the
registry's own map: a mirror kept by the recorder would reproduce the product's
bookkeeping rather than observe it. Deleting `unsubscribeStream()` from
`mobile-notifications.ts` fails 7 goldens now, against 1 before.
Re-record: 4 bodies moved, 701 header-only, 0 added, 0 deleted. All four are
the two `runtime.clientEvents.subscribe` matrices, on partitions whose subscribe
reply is not a well-formed `ready`: with no subscription id to unsubscribe with,
the registry deliberately holds the cancelled record, which is why the
observation carries `cancelled`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-recorder): one host-client context exposure, anchored on the product source
Five adapter modules each carried `exports.recorderHostClientContext = Ctx;`
inside a source string appended to `client-context.tsx`. `Ctx` is a
module-private local, so the reference lives in a string no type checker
follows: renaming it typechecks clean and fails a recording with a
`ReferenceError` a hundred seconds in, five times over.
`hostClientContextExposure` and `loadHostClientContext` are the one copy, and
`adapter-seam.test.ts` asserts the declaration the exposure names still exists
exactly once in `client-context.tsx` and refuses a sixth inline copy. A rename
remains invisible to `tsc` — nothing but editing the fenced product module
makes a private local checkable — so the anchor is what turns it into one
failure that says what moved.
Also splits the subscription tests out of `recording-runner.test.ts`, which
items 1 and 2 had pushed past `max-lines`.
Re-record: 705 header-only, 0 bodies moved, 0 added, 0 deleted; `recorderSha256`
on all 705 and `adapterSha256` on the 23 goldens mounted through the five
modules.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile-recorder): record what a checked read salvaged
`collectSalvageDrops` builds a report on every decoded reply — which array
elements a `salvagingArray` threw away, which members a `salvagedOptional`
read as absent — and `classifyRpcReply` puts it on the outcome, where nothing
reads it. Which rows a reply lost was therefore visible nowhere, including in
a golden.
The recorder wraps `classifyRpcReply` on the mounted module, the one seam every
checked read passes through and the only one that knows the operation the drop
happened under, and records a non-empty report as a `reply-salvage` effect. No
product code changes; the report was already being built and discarded.
No golden carries one. All 19,384 checked reads in the corpus decode their reply
whole, because the reply matrix varies the envelope a host sends rather than the
shape of a row inside a result. The observation pins that absence, and moves the
first time a narrowed element or member schema drops a recorded row — including
where nothing downstream reads it. `salvage-observation.test.ts` is what keeps
the observation honest, driving a malformed row and a malformed optional through
the real `git.status` reply schema.
Re-record: 705 header-only on `recorderSha256`, 0 bodies moved, 0 added,
0 deleted.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(source-control): let hostedReview.create carry a provider token this build does not list
`HostedReviewCreate.provider` was a closed `z.enum`, so a client repeating back
a provider a newer host named in its own eligibility reply had its create
rejected at params validation. Mobile worked around it with a SAFETY-annotated
assertion: narrowing to `'unsupported'` before sending would have made the host
refuse its own provider, so the token was cast through instead.
The schema member is now `z.string()`, and both create handlers narrow through
`supportsHostedReviewCreation` before calling the runtime, so an arm this build
does not know answers `unsupported_provider` with readable copy rather than a
params error the client cannot act on. `createHostedReview`'s own refusal is
the single source of that copy. The mobile assertion is deleted.
Product change on a fenced path, so the goldens are not re-recorded: the whole
recording suite replays green against the corpus committed in the previous
commit, 825 passed, zero golden movement.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(source-control): annotate the runtime stub cast in the provider refusal test
The changed-code quality gate counts a new `as unknown as OrcaRuntimeService`
as a finding. A narrower stand-in does not exist: the interface has 1047
members and `Pick` of the three this test uses is not assignable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-recorder): pay React's lazy Math.random draw before the seeded run
React resolves `enqueueTask` by reading `module['require' + Math.random()]` and memoizes the
result, so a process draws exactly one `Math.random()` the first time it awaits `act`. The runner
drains through `act` after every step, so that draw landed inside whichever recording ran first and
ate the seeded sequence's first value: a family recording a `Math.random()`-derived param recorded
one value when it ran alone and a different one when it ran after any other family, and an adapter
could only dodge it by drawing in its factory ahead of the first drain.
The scheduler now pays that draw once per process, before it installs the seeded generator, so the
seeded sequence starts at the same value for every recording. Priming is awaited, which makes
`start` async.
Goldens re-recorded: 705 header-only, `recorderSha256` alone. No golden carried a first-in-process
value, so nothing moved in a body.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-recorder): drain before reading the streams left at teardown
The teardown observation read the registry after `dispose()` returned but before the scheduler
drained, so a cleanup that closes its stream on a due 0ms timer had not run yet and was recorded as
an uncancelled registration — the one shape this observation reserves for a cleanup that never ran.
A deferred close and a stream nobody ever closed were byte-identical.
The drain now runs before the read, with the transport still disposed after it. A second drain stays
after disposal: tearing the registries down rejects what the product still awaited, and an unhandled
rejection is an effect the cleanup checkpoint has to see.
Also: the registry size comparison in `registeredStreams()` could never fire, because `size()`
returns `this.streams.size` on the same object; `RECORDER_HOST_CLIENT_CONTEXT` is used only in its
own module and no longer exported; and `streamPayloads` now says what it holds, which is every frame
the registry publishes rather than only subscribes.
Goldens are stale in this commit and are re-recorded in the next one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the corpus after the baseline repin and the teardown drain
Recorded from a detached worktree pinned at 97aa5ff19b with this branch's recorder laid over it,
because two fenced product files still differ from the pin: the `hostedReview.create` provider
widening in `src/shared` and the mobile assertion it removes. `--record` in place refuses on that,
by design. A control run of the same harness with main's own recorder reproduced main's 705 goldens
byte-for-byte first, so anything below is attributable to this branch.
Against main, with `sent` and `ordinal` stripped: 701 header-only, 4 body moved, 0 added, 0 deleted.
The four are the two `runtime.clientEvents.subscribe` matrices already disclosed. Moving the drain
above the teardown read moved nothing: every non-empty set in the corpus is a cancelled record
waiting on a subscription id no drain can deliver.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): restore the type imports the recorder test split dropped
`subscription-recording.test.ts` annotated a mount with `RpcClient` without
importing it: vitest strips the annotation and mobile's tsconfig excludes
`**/*.test.ts`, so neither gate saw it. Typechecking the two moved suites under
a throwaway config that includes them also surfaced `sampleGolden` missing the
`adapterSha256` header the format has required since version 5.
The README's teardown claim is scoped to a due timer, since `flush()` only runs
work due at the current virtual time and a later timer is still registered at
the read.
Neither file feeds `recorderSha256`, so the corpus is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): scan the engine directory for a sixth exposure copy
The sixth-copy guard read only `adapters/`, so an inline copy appended to an
adapter failed and a new file under `adapters/` failed, but the same literal in
an engine file passed every assertion. Scan both directories, TypeScript
sources only, since the README quotes the string to document it.
`host-client-context-exposure.ts` holds the template with its interpolations
rather than the literal, so it still cannot match itself; a throwaway engine
file carrying the literal fails the test, and the file is otherwise green.
Also narrows the register's import statements before reading `moduleSpecifier`,
which drops a non-null assertion and the two TS2339 errors the `**/*.test.ts`
exclude was hiding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): ratchet the 201 unchecked RPC reply readers
Step 4 moved every call-site cast into an RpcOperation's `read`, but 201 of those
readers still answer `compatible: true` for any payload: `rpcUncheckedPayloadReader`
(163), `rpcReadUnchecked` (26 outside its own module) and `rpcUncheckedMemberReader`
(12), across 42 files. The cast moved; it did not become true.
Held as data with an AST boundary test, shaped on the raw-request-port ratchet: a file
that is not listed fails, a listed file that no longer has one fails, and a count that
rises fails. Only a call counts, so an import is not a reader and prose never is.
No behaviour change: this commit adds a list and a test.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): validate the source-control domain's RPC replies at arrival
Replaces all 17 unchecked readers in mobile/src/source-control/ with
`rpcResultVariant(variant, schema)`, so a malformed reply is an
`RpcIncompatibleReplyError` naming the operation instead of a TypeError three
frames downstream. The inventory drops 201 -> 184 and the five source-control
operations files leave it entirely.
This is a behaviour change, scoped to malformed replies. Six reply-matrix
goldens move; every named-scenario golden and every `normal` partition is
byte-identical, which is the parity claim.
Schemas live one module per reply domain, beside the operations that read them:
git-status, git-compare, git-history, hosted-review and worktree-metadata. A
member is required only where a consumer reads it unguarded, and each schema
records the consumer line that justifies it. Nothing is `.strict()`; every
reply a consumer publishes verbatim keeps `z.looseObject` so an undeclared host
member still passes through. Six replies have no reader anywhere in mobile and
get `z.unknown()`, which is the honest schema for them, not a holdout.
Three readers stay total by construction, because their contract is that an
unreadable reply is a value rather than an error: the `git.status` projection
(a null status three screens route on), the `session.tabs.list` reveal (a null
list means poll again) and the generated commit message (a screen's copy, never
a decode error in a text field). They gain the salvage report, not a verdict.
Consumers take the schema's output type, so `MobileGitStatusResult` and the
branch-compare aliases now name what mobile reads rather than the desktop
aggregate, and seven call-site casts are gone.
Three requirements came from the goldens, not from the host types:
`git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility`
sends a `reviewLookupOutcome` the shared union does not list, and the
`git.status` projection writes an absent member as a present `undefined`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the six source-control reply-matrix goldens step 7 moves
Six goldens, all on malformed partitions. Every named-scenario golden and every
`normal` partition is unchanged, which is the parity claim for this step.
git.history-read / git.history#1
result-absent, result-null, inner-ok-missing, inner-false-string-error,
inner-false-object-error: the load rejected with a TypeError reading 'items'
or 'map' off undefined/null; it now rejects with
`incompatible_reply: git.history-page (git.history)`.
hostedReview.eligibility + create-intent / hostedReview.getCreationEligibility
result-absent, result-null, inner-ok-*: the fetch fulfilled with the error
envelope itself, re-typed as an eligibility and published into the compose
prefill; it now rejects, and both callers already route that to the same
"eligibility unavailable" state a null answer produced.
hostedReview.create-chain + create-intent / hostedReview.create
result-absent, result-null, inner-ok-missing, inner-false-object-error: the
create form showed the raw TypeError text "Cannot read properties of
undefined (reading 'ok')"; it now shows the incompatible-reply message.
Every header digest is unchanged -- baseline, recorder, adapter, scenario and
lockfile all match -- so the diff is the behaviour and nothing else.
Recorded from this branch into a scratch directory and copied in, because there
is no scoped honest alternative: scripts/rpc-recording.mts refuses to run unless
the product tree equals the pinned baseline, and the README's remedy for an
intended behaviour change is to repin, which rewrites the `baseline` header of
all 667 goldens. So these six now carry a pin whose tree no longer produces
them. That is a real gap in the oracle's design for behaviour changes, not a
detail of this step, and it needs a decision before this lands.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the four reply-schema properties the goldens found
Each of these cost a reply-matrix golden while writing the source-control
schemas, and none of them follows from reading the consumers or the host types:
a newer host's undeclared members must still decode, `git.history` sends
`timestamp: null`, `hostedReview.getCreationEligibility` sends a
`reviewLookupOutcome` the shared union does not list, and the `git.status`
projection writes an absent member as a present `undefined`.
The `.strict()` case is the one worth stating twice: at the top level it rejects
the reply, and on the entry it drops the row, which shows a dirty worktree an
empty Changes list. The fifth test pins the salvage report that makes such a
drop visible instead of silent.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): give an unreadable reply a message a user can read
`RpcIncompatibleReplyError` put `incompatible_reply: <op> (<method>)` in
`message`, and `message` is what the screens hand to a toast. Step 7 is the
first change that can reach this error at all, so the token would have shipped
to users as its own error copy.
Fixed at the boundary rather than per site: `message` is now plain copy, and the
machine token moved to `code` (`incompatible_reply`) and `name`
(`RpcIncompatibleReplyError`), both readable by callers. The cross-bundle
fallback in `isRpcIncompatibleReplyError` matched on the old message prefix, so
it now matches on `name`, which a foreign copy of the module still carries.
No existing test pinned the old text. Two new ones pin the copy, the token and
the foreign-copy match.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to this branch and re-record
Commit adeb5f9531 recorded the six moved goldens into a scratch directory and
copied them back, which left them pinned to `e7206f62`, a tree that no longer
produces them. That is the one claim the `baseline` header exists to make, so
this replaces it with the README's remedy done in full.
`baseline` is now f741b2ea82, the last commit on
this branch that touches a fenced path, so the recording fence passes in place
and every golden is pinned to the tree that produced it. All 667 were
re-recorded through `scripts/rpc-recording.mts --record`; none were hand-edited.
Decoding every value pool against the branch point b8d4cde09f sorts the corpus
into 661 header-only moves where `baseline` is the only key that moved, 6 whose
body moved as well, 0 added and 0 deleted. The 6 are the disclosed step-7 delta,
unchanged at 69 moved observation fields across malformed reply partitions, plus
the readable incompatible-reply copy from f741b2ea82. No `normal` partition and
no named-scenario golden moved.
`scenarioSha256` hashes the derived scenarios, not the manifest, so the repin
moves no other header key; the README section this adds records that, the
scratch-copy failure mode, and the follow-up repin main needs after a squash
merge.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): narrow the incompatible-reply error by instanceof, not by cast
The two new tests in f741b2ea82 read the error through `as` casts, which the
changed-code casting gate rejects. An `instanceof` guard narrows the same value
and checks the class at the same time.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the branch tip and re-record
71d8c6a1e2 touched a fenced path (`mobile/src`), so the pin from 5f3f184fdf no
longer named the tree that produces these goldens. The fence compares the whole
of `mobile/src`, and a test file is inside it, so the pin follows the last commit
that touches a fenced path rather than the commit whose behaviour moved.
Re-recorded all 667 in place through `scripts/rpc-recording.mts --record`.
Decoding every value pool against the branch point b8d4cde09f still gives 661
header-only moves with `baseline` the only moved key, 6 body moves, 0 added and
0 deleted; the six and their 69 moved observation fields are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the four source-control reads that had no oracle
git.status (host payload), git.branchCompare, git.commitCompare and
git.branchDiff were migrated to checked readers with no recording observing
them, so a required member a host omits would have surfaced only in production.
Three families mount the owners rather than the senders, because each reply is
only visible in what the owner then publishes: the Changes screen's loader hook
(git.status, and the base-ref chain and git.branchCompare it triggers), the
history list screen (git.history and the per-commit git.commitCompare), and the
committed-diff opener hook (git.branchDiff). Ten goldens: three pilot recordings
and seven reply matrices.
Two adapter capabilities this needed. An inert FlatList never calls `renderItem`,
so the history adapter renders one row through the screen's own callback, both to
reach the handler that expands a commit and to read the file list back; without
that the commit-compare reply changes nothing observable. And `lowlight` joins
`react` and `zod` as a real library rather than a refusing proxy, because the
branch diff highlights on its success arm before the preview reaches state, so
the shipped text arm was otherwise unrecordable. No golden recorded its absence,
so only `recorderSha256` moves.
Recording the same scenarios against 4b0009d414, the pre-refactor tree, is the
before column. Decoding every value pool across the two gives 11 body moves and
666 header-only, 0 added, 0 deleted: the 6 already disclosed, plus the 5 new
matrices at 63 moved observation fields. What moved is the point. A malformed
git.status used to leave Changes `ready` over the malformed payload and go on to
fetch a branch compare; it now says the host sent a reply it could not read. An
absent git.branchDiff result used to put "Cannot read properties of undefined
(reading 'kind')" on the screen. An unreadable git.commitCompare used to spin the
expanded commit forever; it now says "No file changes".
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the merge commit and re-record
The merge is the last commit touching a fenced path, so it is the only tree
the recorder's fence can match. Every golden moves `baseline` and picks up
main's `recorderSha256` from #20920; the six the checked readers changed are
the only bodies that move against main.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): note the merge-commit pin and unwrap the recipe's record command
`format:check` from `mobile/` caught the wrapped inline command the recipe
had been carrying since it landed; pointing at the command above removes the
duplicate and the wrap together.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): open the source-control reply enums so a newer host's arm degrades
A closed `z.enum` in a reply schema is a version claim, and it refused replies
every declared reader could have rendered: a `git.branchCompare` summary status
of 'shallow-base' failed the whole Changes compare, a 'codeberg' provider failed
the whole eligibility, and a 'typechange' entry status dropped the row. Main
passed all three through.
`openEnum` in zod-salvage declares the arm set open: an unrecognised arm reads as
a member the consumers already handle, while absence and a non-string stay fatal.
Not `.catch()`, which would swallow those two as well.
`area` stays closed and says why: every arm grants stage, unstage or commit, so
there is no member to degrade to that would not offer an action against a row
this build cannot place. Main rendered such a row in no section either.
Also drops two claims the code does not back. Nothing reads the salvage report,
so the two comments promising a dropped entry "arrives as salvage.droppedPaths"
are gone, and `hostKind` on the non-text diff arm had no reader.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs: write down the open-enum rule and the header keys a branch moves
Rule 4 in the wire-compatibility page, beside the three rules it belongs with:
an enum arm set is a wire surface, unknown arms degrade rather than reject, and
leaving one closed is a decision to state where the schema is declared.
The recorder recipe's step 4 said `baseline` would be the only moved header key,
which is only true of a branch that never touched the recorder. It now names the
three digests a branch's own edits move, so a reader recognises a clean result.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop the recorder's own timeout killing a full re-record
The corpus records in ~110s warm and 160s under load, against a 120s budget, so
a full re-record was killed roughly half the time. A killed run wrote a partial
reporter banner and exited 1, which reads as a failing scenario rather than as a
run that never finished — it cost two investigations here. The budget is now ten
minutes, and a killed run says so.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the open-enum commit and re-record
`baseline` is the only header key that moves and no golden body moves: no matrix
partition scripts an unknown enum arm, so the corpus cannot see this change. The
eight schema unit tests are its only oracle.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop an unresolvable eligibility claiming the branch is not ready
Both fallback prefills set `canCreate: false`, which is a determination nobody
made. It short-circuits getMobilePrCreateBlockMessage before reviewLookupOutcome
is read, so a malformed, refused or rejected eligibility told the user "This
branch is not ready for a pull request yet." instead of asking them to retry.
Dropping it leaves `canCreate` undefined, which is what "unproven" means here.
Only a host that determined `canCreate: false` still gets the blocked copy.
`area` now degrades to absent rather than staying closed. Dropping the row also
dropped it from the unresolved-conflict gate, which grants create on a conflicted
worktree; absent withholds stage, unstage and commit while keeping the row, since
every area reader is an equality check. Its four consumers narrow explicitly: the
diff-review queue filters unplaceable rows, the opener withholds the route, and
the commit-failure prompt pins 'staged' where its own filter already did.
`git.branchCompare` entries are nullish, matching the `?? []` its consumers use.
Deletions: `MobileGitStatusProjection` and `uncheckedReaderCount` lose `export`,
the boundary test drops its dead inventory self-file (the AST counter finds zero
calls there, only prose), and `isRpcIncompatibleReplyError` is gone — it had no
caller in mobile, desktop or e2e.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): formatting and a thrown rejection in the round-2 tests
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the round-2 tip and re-record
The round-2 eligibility fix is a behaviour change, so the corpus has to be
re-recorded at a pin that includes it. Four goldens move body: the two
create-intent eligibility matrices on every non-normal partition, and the two
prefill scenarios that lose the fallback's `canCreate: false`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the main merge and re-record
The merge is now the last commit touching a fenced path, so the corpus has to
carry its sha. No body moves against the pre-merge corpus: main's engine change
shifts `recorderSha256` on every golden and nothing else, and main's fifteen
step-6 goldens re-record byte-identical.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): admit the three unchecked readers #20954 landed
The ratchet is a ceiling against this branch adding readers, not a claim about
what main may land. #20954 brought `notification-stream-closed`,
`native-chat-session-page` and `terminal-buffer-cleared`, so the merge has to
raise those lines and say where they came from.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the inventory commit and re-record
The ratchet inventory is a fenced path, so admitting #20954's three readers
moved the fence head again. Baseline only; no body moves against the merge
re-record.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): send the host's own provider token back instead of a fallback
`provider` is not a member mobile only reads. The eligibility reply names it and
the create call returns it, so `openEnum(..., 'unsupported')` did not soften a
reading — it rewrote the bytes, and a host that had just named `codeberg` refused
its own provider as unsupported. The action-sheet Create path has no provider
gate, so nothing caught it.
Passes the token through as a string from the reply to the create params. The
allow-list that decides whether mobile may create stays supportsHostedReviewCreation(),
which already answers no for a token this build does not know; its parameter
widens to `string`, since answering for an unknown token is the whole job. The
worktree-link switch gains a default, which also fixes an older hole: an
unrecognised provider used to fall out of the switch as `undefined` params.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the provider pass-through in the corpus
Repins to the provider fix and records `sc-create-intent-unlisted-provider`,
whose eligibility reply names `codeberg` and whose recorded `hostedReview.create`
params carry it back unchanged. Restoring the old enum fallback fails that
golden on `Request params mismatch: hostedReview.create#1` and nothing else.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the three step-6 families at the pin, and record a stream listener that dies
Step 6 migrates the requests step 4 left behind because they share an effect with a
`client.subscribe`. This records them first, from the pinned baseline, so the refactor that
follows has a parity oracle.
Three new families, one adapter module each:
- `session.native-chat-page` — the older-history page. The read is a callback, but only the
mount effect's `nativeChat.subscribe` arms what it pages against, so the frames are the setup:
the snapshot's `beforeOffset` decides whether the request carries a cursor or asks for a
growing tail. A cutover and a second snapshot pin the reconnect replay merging into paged-in
history instead of collapsing the window.
- `notifications.desktop-stream` — the desktop notification socket: the subscribe, the catch-up
read its `ready` arms, the tray dismissals its events drive, and the server unsubscribe the
disposer sends. Split in two so the base scenario's matrix sites all have partition-stable
params: a variant that answers the second `ready` differently leaves the unsubscribe carrying
the first subscription id, which the base's scripted params could not assert.
- `session.terminal-gesture-input` — the debounced gesture flush and the menu's clear-buffer.
Neither rides a subscription; a mount holding no terminal ref reaches both.
The engine change is what makes the first two recordable at all. `ScriptedRpcTransport.frame`
now returns what the product listener threw instead of throwing it on, and the runner records it
as a `stream-listener-crash` effect. Only the two `runtime.clientEvents` listeners check that a
frame payload is an object before reading its `type`; every other subscribing family took the
matrix's `result-absent` and `result-null` partitions as an uncaught TypeError, which failed the
suite rather than recording what a malformed frame does to a subscription. That is the same rule
the crash boundary already holds for a screen and the unhandled-rejection window for a detached
effect. The scenario's own faults stay loud: a missing subscribe payload, a params mismatch and a
closed stream are all raised outside the caught region.
`recorderSha256` therefore moves, so all 679 pre-existing goldens are re-recorded from the pin
with this branch's recorder laid over it. Every one of them moves exactly one line and that line
is `recorderSha256`: no `adapterSha256`, no `scenarioSha256` and no observation moved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6)
Five references over four files leave the raw request port. Each was held out of step 4 because a
request-only recorder could not mount it; the recordings landed in the previous commit and no
golden moves here.
- `use-live-worktree-name.ts` — `worktree.show` inside the focus effect that opens
`runtime.clientEvents`. It reuses `sessionWorktreeRecordRead`, which is the diff-comment loader's
reader renamed: both consumers read the `worktree` member whole and narrow their own field off
it, so a second family would have been a second name for the same wire. The resolution still
comes off the raw reply, because `selector_not_found` is what proves the worktree is gone and no
acceptance policy carries a refusal code; the skip that follows is the same verdict main's
`!response.ok` reached, since a refusal is the only reply this policy declines.
- `use-mobile-native-chat-session.ts` — `nativeChat.readSession` in the paging callback. The
payload stays whole because the reply is a union: an older runtime answers `{ error }` in place
of a window, and the caller discriminates before reading a message list.
- `mobile-notifications.ts` — `notifications.unsubscribe` in the `ready` branch of the
subscription callback, in its own module rather than beside the push-route sends: one is the
route this device holds with a gateway, the other the socket the paired connection holds.
- `use-mobile-session-terminal-input.ts` — the gesture flush reuses `terminalInputSend`, which
already carried the four other terminal-input call sites and the same accepted-verdict, and the
menu's clear gets `terminalBufferClear` beside it. The clear is a skip because main never read
the envelope: it toasted success on any fulfilled reply, so only a transport rejection reached
the failure toast. That is preserved, not repaired.
`mobile-session-route-parity.test.ts` refreshes three pins with their reason: the callback bodies
and the twelve nested-function bodies moved where those send expressions were rewritten, and the
runtime-string count drops by two because `terminal.send` and `terminal.clearBuffer` are now fixed
at their operation's definition instead of spelled at the call site.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): hold the subscription coverage as a checked inventory instead of a README paragraph
Every product `client.subscribe` is now an entry in
`mobile/src/transport/rpc-subscription-inventory.ts`, classified as recorded (naming its family),
an unwritten scenario, or walled with the wall named. `rpc-subscription-boundary.test.ts` fails on
a new site with no entry, an entry whose file no longer subscribes, an entry naming a method the
file does not open, and a `recorded` entry whose family the scenario manifest does not have. Both
the unlisted-site and unresolved-family gates were checked by removing an entry and by misspelling
a family; each fails on its own assertion.
The paragraph this replaces said nine sites when there were ten. It counted over `mobile/src`, and
the host screen's `accounts.subscribe` lives under `app/` — so the scan here covers both roots, the
way the raw-port ratchet next door does. Ten sites today: four recorded, two unwritten scenarios,
four walled (two on the webview ref, one on the multi-host client context, one on two unsubstituted
view members).
Unlike the raw-port inventory this list does not count down to zero. A typed operation fixes one
method, one acceptance and one reader for one reply; a stream has many, and replacing a subscribe
is not what this is asking for. The question it holds is the other one — which stream a golden
actually has, and for the rest, what exactly stops it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the notification stream close, which writes nothing to the wire
Deleting `unsubscribeStream()` from the notification cleanup — the local close,
not the `notifications.unsubscribe` RPC beside it — survived all 810 tests.
Neither unsubscribe builder in the stream registry knows `notifications.subscribe`,
so closing that stream sends no frame; the mutant leaks a live subscription record
instead, and the leak only surfaces when the logical client replays it onto the
next session. `notifications-desktop-stream-closed` stops the stream and then cuts
over, where the leak becomes a second `notifications.subscribe` payload.
Recorded at the pin. No existing golden moves: the new scenario is appended, so it
is not the family's matrix base, and every notification matrix site already had a
fulfilled reply to replay.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the accounts screen's real wall, which is ScrollView and Alert
The entry blamed `expo-router.useFocusEffect`, which is substituted, and the
inventory's own `use-live-worktree-name` is recorded while importing it. Probed
by mounting the screen through the trap: the first refusal is
`Unsubstituted native member: react-native.ScrollView`, and `Alert` refuses too.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the terminal-send response reader that lost its last caller
`isTerminalSendRpcAccepted` read the verdict off a whole envelope, which is what
the raw call site did. Both callers now send through an operation and read the
admitted payload, so the response form had only its own test left. The three
cases move onto `isTerminalSendResultAccepted`, with the refusal envelope's
missing result standing in for the failed response.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): attribute a frame crash to the listener that threw, not to the registry
The try wrapped `stream.deliver`, so anything the registry raised on its way to
the listener was recorded as a `stream-listener-crash` effect and blamed on the
product. A reply like `{ok:false}` with no error object throws reaching for
`error.message` before any listener runs, and that is a scenario that stopped
matching, not an observation.
Only the product's own `onData` is wrapped now. The throw is stashed and
rethrown unchanged, so the registry still sees it the way a device's message
handler does and what it skips after a dead listener stays recorded rather than
invented; `frame` reports it only when the error it caught is the one the
listener raised. `FrameListenerCrash` is local to the file again.
Engine change, so every golden re-records: 694 files, every changed line the
`recorderSha256` header, no body movement. Against main the set is 679 modified
header-only and the same 15 added.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): read the frame listener stash through a method, not a narrowed field
`this.listenerCrash = null` before the try narrows the property to `null` for
the rest of `frame`, so the catch compared against `never` and mobile's own
`tsc --noEmit` failed. A private taker returns the declared type.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): abort a registry throw that stashed nothing, and fold the last native-chat read module in
The frame catch compared `crashed?.error !== error`, which is false when nothing
was stashed and the registry threw `undefined`, so that abort was swallowed and
`frame` reported a clean delivery. It now asks whether a listener crashed at all.
Also: `nativeChatSessionPageRead` moves beside the three other `nativeChat.*`
reads and its one-export module goes; the session read header names the whole
`worktree.show` record rather than review notes; the guarded-listener count is
three, not two; the README names the ten subscribing sites blur is unrecorded
across; and the gesture flush reads the send verdict as `=== true` like the
other four sites.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* chore(mobile): drop an oxlint disable the rule never needed
`no-throw-literal` is not enabled here, so the directive read as unused and
failed the changed-code quality gate.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): let the RPC recorder open a subscription and script its frames
The request-only runner threw on `client.subscribe`, which is why seven raw-port
holdouts read "the recording runner refuses to open one". It no longer does.
`ScriptedRpcTransport` drops the real `RpcClientStreamRegistry` into each physical
session, the way it already reuses `RpcClientRequestTracker` for requests, so
subscribe params, frame routing and the unsubscribe wire all come from product
code. Per session, not shared: a frame is routed by the session that published
its subscribe, and after a cutover the retiring registry is what holds a
cancelled subscribe long enough to unsubscribe it once its id arrives.
A subscribe writes to `payloads` through the same hook a request does, named by
per-method occurrence, and frame ids come from the transport's existing counter
because the real `DirectRpcClient` shares one counter across requests and streams.
New scenario step kind `frame`: it names a subscribe payload, asserts its params
the way `complete` does, and hands a whole host response to the real
`handleResponse`, so `ready`, a data event, the host's end-of-stream pair and a
refusal are one step kind rather than four.
Every `payloads` entry now carries `sent`, the request count at write time, the
same stamp `effects` already use. Without it, swapping `client.subscribe` and the
first `sendRequest` in a product source moves zero bytes: a subscribe publishes
synchronously while a request waits for connected, so the payload order is
identical either way and only `sent` moves.
The reply matrix now drives frames as sites, named by payload and occurrence
because one subscribe carries many frames. Nine of the eleven partitions apply;
the two transport rejections are what a request promise fails with and a
subscription holds none. Success shapes keep the scripted frame's `streaming`
flag, which is what routes a response to the open stream.
`useFocusEffect` is substituted as `useEffect`, so a route's focus cleanup is
recorded at unmount and a blur-triggered unsubscribe stays unrecorded; the README
says so rather than a driven focus substitute no recording reads.
Four tests, each killing a named mutation: routing a frame through the current
session instead of the publisher, delivering a frame to the request tracker,
dropping the `sent` stamp, and reading only `'complete' in step`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the two runtime client-event stream consumers
Two families, both driven through the new frame step, as the capability proof for
the subscription recorder.
`session.live-worktree-name` mounts `use-live-worktree-name.ts` end to end:
subscribe, `worktree.show`, a `ready` frame, the fulfilled name, a
`worktreesChanged` frame, the follow-up `worktree.show`, then unmount and the
`runtime.clientEvents.unsubscribe` its focus cleanup sends.
`worktree.host-refresh` mounts `startHostWorktreeRefresh`, whose whole output is
when it calls the two fetches it is handed. It sends no request of its own, so it
is also the family that would have thrown `No scripted reply to drive a matrix
over` before a frame was a matrix site. The 3 s foreground poll is driven by an
`advance` step, which puts `WORKTREE_REFRESH_MS` under recorded time.
Both adapters live in one new module, registered like every other domain, so the
two families' goldens are pinned to a file that holds only them.
No product source changes and no call site migrated: the seven raw-port holdouts
and the `client.subscribe` zero-reference assertion belong to the migration PRs.
`accounts.subscribe` in `use-mobile-home-host-connections.ts` is left out. Its
snapshot decoder is re-exported through a React Native screen module the loader
cannot reach, which is the same wall the accounts read has always been behind, so
it needs a substitute beyond what these two read.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record every golden for the subscription recorder
Engine files changed, so `recorderSha256` moves and every header re-digests, and
`payloads` entries carry a new `sent` key. Nothing recorded moved.
Recorded from a detached worktree at the pinned baseline with this branch's
recorder laid over it, per the README's awkward case; `baseline` is unchanged.
Decoding both sides through the value pool and ignoring `recorderSha256` and the
new `sent` key: 641 compared, 6 header-only (the six goldens with no payload at
all), 635 sent-only, 0 other, 9 added, 0 deleted.
The 9 added are the two new families: a pilot golden each, four reply-matrix sites
for the live title (two requests and two frames) and three for the host refresher
(three frames, and no request of its own).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): take the broad object parameter out of the frame partitions
`audit:anti-slop`'s no-object-parameters rule fires on a parameter typed `object`,
which the frame-partition helper took to spread a success envelope. One function
narrowing `unknown` to a spreadable envelope replaces the two that split the
check, and the streaming flag is now read as `=== true` rather than by key
presence, matching `isStreamingOpenerReply`.
An engine edit moves `recorderSha256`, so every golden re-digests again. Decoded
through the value pool, all 650 differ on that header alone and on nothing else.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): refresh the recorder's own scenario and golden counts
The paragraph still claimed 78 scenarios and 153 goldens over 210 tests, which
went stale across the domain additions since. It is 330 scenarios, 650 goldens and
757 tests as of this branch. The figures quoted further down are measurements of
the change each one describes, so they stay as written; a line now says so.
Prose is excluded from `recorderSha256`, so this moves no golden.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): take the inert optional off a frame, and pin the replay re-read
Review of #20920 found four things the first pass got wrong.
The `optional` flag on a frame step never gated anything: the registry routes every streaming
response to the id that opened the stream, retired or not, so `frame()` only ever throws for a
non-streaming reply. Dropping the parameter, the step field and the downstream marking moves the
scenario digest of two matrix goldens and no recorded byte.
The session comment claimed a mechanism that is not there. The re-send after a cutover comes from
the logical client's own subscription replay, not from the registry being per-session; a shared
registry is byte-identical. What being per-session buys is a frame routed through the session that
published its subscribe, which is what `DirectRpcClient` does too.
The host-refresh scenario now cuts over and answers a second `ready`, so the reconnect replay
branch is recorded: deleting its re-read moves this family. Before, that branch was source no
golden reached.
README over-claimed the subscribe port as covered. Nine product call sites subscribe, two are
recorded, and the other seven are now named with what stops each.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record for the frame flag removal and the replay cutover
644 goldens move on `recorderSha256` alone, from the engine edit. Two more also move
`scenarioSha256`: the live-worktree-name matrix variants that used to carry `optional: true` on a
downstream frame. Four bodies move, all in `host-worktree-refresh` — the pilot and its three matrix
goldens now record the cutover, the re-subscribe payload, the retiring unsubscribe and the extra
worktree/repo read the replay branch does. One golden is added, for the matrix site the second
subscribe payload opens.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): count the golden the second subscribe payload adds
Prose only; moves no golden.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the live-worktree-name replay re-read too
The same cutover treatment as host-refresh: the scenario now migrates the logical client, answers a
second `ready` on the re-sent subscribe, and answers the title read the replay branch makes. Before
this, deleting that re-read from `use-live-worktree-name.ts` moved no golden.
No engine file changes, so `recorderSha256` holds and 646 goldens are byte-identical. Five bodies
move with their scenario digest, all in this family, and two matrix goldens are added for the sites
the second subscribe payload and the third title read open.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what a request count cannot order, and name the accounts wall
`sent` counts requests, so it orders payloads and effects against sends and not against each other.
A family that sends none has no ordering at all: `host-worktree-refresh` keeps `sent` at 0 through
every checkpoint, and moving its two initial reads across the subscribe moves no golden. The fix is
one write ordinal shared by all three lists, which forces a full refresh.
The `accounts.subscribe` wall was misdiagnosed. The loader reaches `decodeAccountsSnapshot` and it
throws its own domain error; what the runner cannot supply is the multi-host client context
`useAllHostClients` reads.
Also honest about the record recipe: where a branch must not repin `baseline`, the detached-pin
worktree is the only one that runs, merged main or not.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): file only a subscribe as an open stream, and drop three unused seams
The registry sends its unsubscribes through the same `sendEncrypted` hook as its subscribes, and the
hook filed every payload under `openStreams`. A frame aimed at an unsubscribe name therefore routed
at that wire id, matched no stream, recorded nothing and reported success — where the README promises
`Missing subscription payload`. A latch around the session's `subscribe` wrapper files only what a
subscribe published. Its test fails without the latch.
Three seams no caller varies, the same shape as the `optional` flag: `frameReplyPartitions` took a
`scripted` reply to copy `streaming` from, but every frame site scripts a streaming reply, so the
flag is stamped and a non-streaming unary closer as a base frame is called unsupported; the
divergence map's three-deep ternary is early returns, since `index > divergence` already implies
`index !== divergence`; and `MatrixSite` is no longer exported.
Body-inert: re-recording into a scratch dir at this tree moves all 653 goldens on `recorderSha256`
and nothing else, decoded through the value pool. The goldens are left stale for the merge re-record.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record every golden after the main merge
One record at the pin, with this branch's recorder, scenarios and driver script overlaid on a fresh
detached worktree. Decoded through the value pool against `origin/main`: 667 shared goldens, 6
header-only on `recorderSha256`, 661 also gaining the `sent` stamp this branch puts on every payload
entry, nothing else moved, and 12 added — the two client-event families and their matrices. No
`adapterSha256` moved, so main's adapter work was already recorded against its own goldens.
Those 12 are byte-identical to their pre-merge bodies, `recorderSha256` aside, so the merge changed
nothing this branch recorded.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record six screen-mounted call sites before migrating them
Five new mount adapters and six scenarios, recorded against the pinned baseline's
product code so the goldens are main's behaviour, not the refactor's.
Each site is a screen the recorder could not previously mount:
- `home.host-accounts` mounts `fetchMobileHomeAccounts`, whose decoder is
re-exported through `AccountUsage.tsx`. That module loads under the mount
loader, so the inventory's "no recording can load it" was already stale.
- `notifications.display-test-screen` mounts the settings push probe and presses
its button by reading the handler back off the rendered inert `Pressable`.
- `aiVault.history-screen` mounts the history panel, which is where the last
`worktree.ps` lives. Split in two: the base stops once the worktree list has
seeded the scopes, because a reply partition there changes the scopePaths the
downstream `aiVault.listSessions` carries, and a matrix variant cannot assert
params it moved. The full chain is a second scenario, driven as a pilot only.
- `tasks.route-repo-list` mounts the tasks screen-root hook and calls its own
`ensureLoaded`, which is the only thing that fires `repo.list`.
- `linear.select-workspace-picker` calls the render helper the tasks surface
calls and invokes the `onSelect` on the element it returns. The picker draws
inside `BottomDrawer`, whose reanimated timing driver and gesture builder the
recorder would have to impersonate for a row to exist; the closure is the same
either way, and the workspace a selection carries comes from the scenario.
Five substitute members are added, each with the recording that reads it:
`react-native-safe-area-context.useSafeAreaInsets` and
`expo-router.useLocalSearchParams` for `tasks.route-repo-list`, and
`react-native.TextInput`, `.SectionList` and `.RefreshControl` for
`aiVault.history-screen` once its list renders. `useLocalSearchParams` answers one
pinned route for the same reason the window size is pinned: a screen's own address
is not a device reading, and the one screen that reads it sends `repo.list`, which
takes no params.
Touching the substitute table moves `recorderSha256`, so all 641 existing goldens
are re-recorded. Recorded from a detached worktree at the pinned baseline with this
branch's recorder laid over it: every pre-existing golden is header-only, verified
by resolving both sides through the value pool — 641 header-only, 0 body, 0 deleted,
one distinct `recorderSha256`, `baseline` and `lockfileSha256` across all of them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the linear workspace picker's model fixture
`mobile/tsconfig.json` covers the recorder, and the fixture's setters were written
with the argument the product happens to pass rather than the `SetStateAction` the
model declares. Typing them moves `adapterSha256` on the two goldens recorded through
this module, so they are re-recorded here rather than in the refactor commit, which
must move none.
Re-recorded at the pinned baseline: `linear-select-workspace` and its reply matrix,
header-only, bodies unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): send six screen-mounted call sites through typed RpcOperations
Nine references off the raw request port, across six files. Every one is proven
against the goldens recorded in the previous commit from the pinned baseline's
product code: this commit moves no file under mobile/rpc-foundation/goldens.
Reused rather than redefined:
- `worktree.ps` in the history panel sends through `worktreeCatalogRead`. Same
question, same acceptance — a refused list leaves the screen on what it holds.
- `repo.list` in the tasks screen-root hook sends through `newTabRepoListRead`.
Its policy raises the host's message and its reader takes `repos` off the payload
while preserving the property-read exception a null result used to throw at the
cast, which is what this call site did by hand. Its name still says new-tab; a
third consumer does not make renaming it this bucket's business.
Four operations are new, each because no existing reader on the method takes this
consumer's input:
- `files.read-directory-or-skip` and `files.legacy-explorer-list-or-skip` for the
explorer. Both skip, because neither refusal is the operation's to decide: the
readDir refusal code selects the legacy fallback and the list refusal supplies the
message. The existing `files.list-or-skip` reads the `files` member alone, and the
explorer also needs `truncated` for the "Showing first 5000" note.
- `accounts.home-snapshot-or-skip` for the Home card, decoded by
`decodeAccountsSnapshot` at the call site as before.
- `notifications.test-push-or-skip` for the settings probe, whose `forbidden` and
`method_not_found` refusals mean "try the next desktop".
- `linear.select-workspace-or-skip` for the filter sheet.
Two behaviours are preserved rather than repaired, both recorded:
- The workspace switch never read its reply. `.then(() => loadLinearContext())` runs
on a refusal exactly as on a success, so only a transport rejection reaches the
error copy. Interpreting the operation here would surface a refused switch for the
first time; that is a product change with its own re-record.
- `app/terminal-settings.tsx` still reads `ms` off the reply envelope instead of off
its result, so the value is always undefined. It did not migrate, and the inventory
now carries the defect as its own note.
Four mutants are added, one per new family that admits a state-only one:
the Home snapshot, the push test result and the tasks repo list each decoded one
level above the envelope, and the workspace switch with its context reload dropped.
`aiVault.history-screen` gets none and says why in the suite: everything
`worktree.ps` publishes also moves the `scopePaths` the next scripted completion
asserts, so a mutant aborts the sequence instead of diverging from it. Its evidence
is the reply matrix at that request.
The tasks source-parity ratchet moves with the family it guards: hook, statement,
declaration, render and style counts are unchanged, and the semantic source is a pure
deletion of four lines — two `rpc:` call signatures and the two method literals they
carried.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): matrix the six new screen families' replies
One golden per scripted reply, eleven partitions each, recorded at the pinned
baseline alongside the pilots. Seven sites: `accounts.list`, `notifications.testPush`,
`repo.list`, `linear.selectWorkspace`, and all three of the history screen's —
`worktree.ps` and the two `status.get` reads its scan chains off the worktree list.
The history matrix is also that family's defect evidence in place of a mutant: every
partition at `worktree.ps` changes the `scopePaths` the downstream `aiVault.listSessions`
carries, and the sender args are recorded with it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct three operation and mutant comments
Comment-only, no product behaviour and no golden movement.
- `worktreeCatalogRead` says two readers; there are three. Names the third
(the agent-history panel's `scopePaths` seed) and drops the stale count from
the module header, which described call sites rather than the two operations.
- `newTabRepoListRead`'s census counted the two operations over `repo.list`, not
its own two callers, and claimed both read a workspace's connection id. The
tasks route keeps the whole list for its repo pickers. The split from
`nativeChatRepoListRead` stays where it belongs: acceptance.
- The `aiVault.history-screen` mutant note pointed at the reply matrix as the
accepted-vs-refused oracle. Decoding
`matrix-aivault.history-screen-worktree.ps-1.json` through the value pool
shows `normal`'s projected state is identical to all seven non-crashing
partitions (spinner, two labels, zero rows). The real oracles are the next
request's `scopePaths` (`["/repo/feature"]` vs `[]`) and the crash channel the
three `inner-*` partitions land in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): give the second files.list reader its real reason
Comment-only, no product behaviour and no golden movement.
`legacyFileListRead` claimed "the member reader rejects this consumer's
input". Nothing rejects: `rpcUncheckedMemberReader` returns the member,
and reusing it here would simply drop `truncated`. The reason the explorer
declares its own operation is the other direction. Widening
`files.list-or-skip` to a payload reader would split the `workspace-files`
variant it shares with `nativeChatFileSearchRead` over
`files.searchPaths`, whose only caller feeds both through one
`extractPaths` in `use-mobile-native-chat-file-search.ts`, so the member
read would move into that hook rather than disappear.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): indent the six scenario entries spliced during the merge
The conflict on `pilot-scenarios.json` was resolved by id rather than by
hunk, splicing this branch's six entries into main's text at the array's
close. The splice started at the entry's `{` instead of at its line, so
those six lines lost their indentation. oxfmt's only change is those six
lines; the parsed document is identical, and the recording suite still
matches all 667 goldens, so no scenario digest depends on the raw text.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the merged goldens once at the pin
One record for the whole merged tree, at the unchanged baseline
e7206f62a8, through a detached worktree reset
to that pin with this branch's rpc-recording tree, scenarios and recorder
script overlaid. Product source in that worktree was proven identical to the
baseline before the run, so the recordings describe the pre-refactor product.
13 goldens move, all of them the ones #20915 added. They arrived carrying the
recorder digest from before this branch edited `screen-native-substitutes.ts`,
and `recorderSha256` is the only key that moves on any of them; every
recording body is identical after decoding through the value pool. The other
654 were re-recorded byte-for-byte and are not in this commit.
All 667 goldens now carry one `recorderSha256`, one `baseline` and one
`lockfileSha256`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state the real gates on two screen holdouts
Comment-only, no product behaviour and no golden movement.
The accounts route said "the screen now mounts". It does not, at this
commit: it reads `expo-router.useFocusEffect` and `react-native.ScrollView`,
neither is a substituted member, and the trap refuses before any effect
runs. The note now names that as the first gate and the `accounts.subscribe`
effect as the second, and says why the two members are not added here.
The host-screen overlay note blamed a "reanimated timing driver" for
deciding when the drawer's children exist. Nothing gates them:
`resolveBottomDrawerMounted` returns `visible || mounted`, `BottomDrawer`
renders `MountedBottomDrawer` on that, and that component renders its
children unconditionally inside its `Modal`. The blocker is the module's
own imports of reanimated and gesture-handler, neither substituted.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the tasks route adapter's unreachable reload action
No scenario names `reload-repos`, and no schedule driver can generate it:
the drivers emit only disconnect, cutover, reset, unmount, blur and remount.
Every other action on this adapter is reached by a scenario. Deleting the
branch leaves the remount and unmount branches, which are driven.
Re-recorded once at the pin e7206f62a8 with
the product source in that worktree proven identical to the baseline first.
Two goldens move, both in the `tasks.route-repo-list` family, with
`adapterSha256` the only moved key and both recording bodies identical after
decoding through the value pool. The other 665 re-recorded byte-for-byte.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the session domain's remaining call sites before migrating them
Freezes main's behaviour for what is left of `mobile/src/session/`: the AI Vault resume pair, the
clipboard image upload and its two attachment surfaces, the native-chat terminal writes, session
tab activation and reconciliation, the terminal-path tap, the structured agent launch, and the
session screen's own reads, tab creates/closes, review actions, notes, markdown save and quick
commands. 87 scenarios over 22 new families, recorded from the pinned baseline
`c6a72169843ececf3a21da370ac50c5c5a4e6462` through a detached worktree, before any product edit.
Ten new modules under `adapters/`, so each domain's goldens are pinned by `adapterSha256` and no
existing family re-digests. `native-mounting-substitutes.ts` gains `expo-haptics` (inert: every
caller is already fire-and-forget), `expo-clipboard` (a per-recording pasteboard cell, because
these screens read back what they wrote) and `BackHandler`/`Keyboard`. That is an engine file, so
all 509 pre-existing goldens move on `recorderSha256` and on nothing else.
Two families cannot be matrixed at their first request: the clipboard upload chain puts the start
reply's `uploadId` into the params of every later call, so a partition that answers the start
differently changes a downstream assertion rather than a recorded observation. Their base scenarios
stop at that first reply instead, and the fallback arm carries the upload family's second site.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the new session mount fixtures against the product model
The ten adapter modules added with the session recordings typed several fixtures structurally —
loose maps, a local `Terminal`/`Tab` shape, `unknown` for the review screen state — which
`pnpm --dir mobile typecheck` rejects: `tsconfig` covers `src/test-support`, so an adapter is
checked like product code even though no test file is. Each one now names the product type it
stands in for (`MarkdownDocState`, `TerminalRecord`, `MobileSessionTab`, `ReviewScreenState`,
`MobileDiffReviewQueueItem`, `DiffComment`) and supplies its members through `mountFixture`.
Only one of those changed a recording: a `DiffComment` requires `side`, so the review actions now
put it on the wire and the two `review-mark-reviewed-*` goldens carry it. That is the fixture
becoming a real subset of the type it claimed, not a behaviour change — no product source moves in
this commit, and the parity claim the next one makes is against these bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the session mount adapter and the recording manifest
`pnpm exec oxfmt --check .` from `mobile/` flagged both after the previous commit's type fixes.
The adapter is pinned by `adapterSha256`, so the eight goldens recorded through it are re-recorded
from the same baseline; the manifest is not pinned by its bytes — `scenarioSha256` canonicalises
the parsed scenarios — so no golden moves for it. Header-only either way: no observation changes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): send the rest of the session domain through typed RpcOperations
Thirty files and 58 references leave the raw request port. Every send now names a declared
operation with a fixed method, one acceptance policy and one reader, and no call site casts a
reply payload: the AI Vault resume pair, the clipboard image upload with both its attachment
surfaces, the native-chat terminal writes, session tab activation and reconciliation, the
terminal-path tap, the structured agent launch, and the session screen's reads, tab
creates/closes, review actions, notes, markdown save, quick commands and file search.
Five new operation modules, plus two readers added to existing ones. Three methods get a second
reader, each argued where it is declared: `files.resolveTerminalPath` (the tap branches on five
members the grant refresh hands back whole), `files.open` (the tap's miss is silent, the Changes
screen raises the host's message) and `session.tabs.list` (the send sheet keeps only terminal
tabs, which the reveal poller and the reconciliation controller both drop). `git.stage` carries
two acceptances for the same reason `repo.list` does: a tapped file raises its refusal, a bulk
sweep counts it. No new acceptance policy.
The AI Vault resume launch and the review send sheet now share
`mobile-review-terminal-operations.ts` with the PR triage launch instead of re-deriving the same
create/send pair, and the file-search hook's `extractPaths` moves into the reader it belongs to.
One latent behaviour is preserved rather than fixed and wants a ticket:
`mobile-session-tab-activation.ts` decides whether to replay an activation with
`error instanceof LogicalClientCutoverError`, not with the message-matching
`isLogicalClientCutoverError` that exists because "instanceof can miss across bundle copies". Under
a second copy of the module the retry silently does not happen, which the recorder reproduces. No
golden scripts a cutover for this family, so none records the wrong behaviour.
The offender floor in the port ratchet comes down from 50 to 20: it is an anti-vacuity guard on a
list this migration is driving to zero, and 39 files still reach the port.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep main's malformed-reply message and re-anchor two mutants
Three findings the reply matrices surfaced that the pilot scenarios could not.
The clipboard upload's `uploadId` read goes back to the raw result. A success carrying no result
throws a destructuring TypeError there, and V8 puts the destructured expression's source text in
the message — which the composer shows. Reading the slot off the interpreted payload rewrote that
sentence for every user who hits a malformed reply, on three families' `result-absent` and
`result-null` partitions. The cast is the one main made, kept for the message alone.
`race` and `new-tab-refusal-order` both anchored in text the migration rewrote, so each matched
zero sites. Re-anchored at their new homes; the defect each injects is unchanged, and
`probe-hole-witness.test.ts` still shows the probe killing the reorder while every pre-probe
scenario survives it.
`native-chat-send-delivery-unknown` is added as this domain's own mutant: dropping the
delivery-unknown arm of a chat send makes an ack lost after the frame was written read as a
definite rejection, which invites the user to send the same message twice. A second candidate —
swapping `terminal.list` from skip to throw — survives every golden and is not registered: the
inventory hook wraps its whole read in `catch { return false }`, so a refusal and a throw leave
the same strip.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): matrix every reply the new session families script
Thirty-four reply-matrix goldens, one per scripted reply across the twenty-two families the session
migration added, each running the eleven partitions in `reply-matrix.ts`. Recorded from the same
pinned baseline as the pilot goldens through the detached worktree, so they freeze main's answer to
a result-less success, a null result, an inner refusal envelope, a message-less outer refusal and a
transport drop — not the migrated code's.
They are laid down after the refactor because they are what found its three remaining divergences,
each fixed in the previous commit rather than recorded around: the clipboard upload's destructuring
message, and two mutation anchors the rewrite left matching zero sites.
Two families matrix only their first request. The clipboard upload chain puts the start reply's
`uploadId` into every later call's params, so a partition that answers the start differently moves
a downstream assertion instead of a recorded observation; their base scenarios stop there, and the
upload family's fallback arm carries its second site.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): keep the new session readers inside the operation cast fence
`rpc-operation-cast-fence.test.ts` refuses a type assertion anywhere in the region reachable from
`rpc-operation-contract.ts`, and four of the new operation modules asserted their own result type.
Each of those reads goes back to `rpcUncheckedPayloadReader`/`rpcUncheckedMemberReader`, and the
shape the call site expects is named at the call site, which is where every migrated domain already
puts it. `extractPaths` and `readQuickCommands` return to their hooks for the same reason — both
were only movable by carrying a cast with them.
No golden moves: the readers hand back the same values, and all 705 recordings still compare clean.
The two frozen source-parity suites over the session route family are refreshed for the migrated
text: three hashes, one runtime-string count, and the inventory hook's acceptance check, which now
reads `!isCurrent() || !response.accepted`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop three assertions the changed-code casting gate rejects
`pnpm run check:code-quality:changed` reports `consistent-type-assertions` separately from oxlint's
own pass, and three sites had no rationale: the resume preparation's payload read, which gets the
standard SAFETY line, and two adapter refs whose `null as string | null` is just an annotation
written the wrong way round. The adapter is pinned by `adapterSha256`, so its thirteen goldens are
re-recorded from the baseline — header-only, no observation moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): answer round 1 on the session migration
Reuses `RpcOperationSender` for the file-tap client instead of respelling it as
`Parameters<typeof fileTapPathResolve.request>[0]` at two sites; the type is the same by
construction, and the handlers file no longer imports an operation only to name its first parameter.
Names why `worktree.set-review-notes` stays separate from source-control's identical
`worktree.set-review-link`, and why the new-tab loader's two preflight reads do not share the task
drawer's readers on the same methods.
Corrects the `callAgentSession` holdout count: five call sites across two hooks plus one inside the
module's own mutation wrapper, not seven callers across five files.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state what actually gates the native chat paging read
The `nativeChat.readSession` send sits in the paging callback, not in the mount effect. What blocks
recording it is that the mount effect's `nativeChat.subscribe` is what arms the offset and
generation the callback pages against, and the request-only runner refuses to open a subscription.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): answer round 2 on the session migration
The tapped-path resolve is now a re-export of the preview screen's `terminalArtifactPathResolve`
rather than a second definition: same method, same skip, same whole-payload read, so the only thing
the old comment could claim was a difference that was not there. `fileOwnershipRuntimeStatusRead`
already re-exports for this reason.
`worktree.show` has four readers, not three. The one the notes read is closest to is
`fileOwnershipWorktreeRead`, which reads the same member whole, and acceptance is all that separates
them: a file mutation throws rather than write to the wrong host, a session screen without its notes
shows none and keeps working.
`interpretOrThrowRefusalMessage` is generic, so a caller keeps the interpretation's own type, and
eight longhand copies of its try/catch across five files now call it. Three more copies wait on a
frozen source hash; see the report.
Three operations sit in the module matching their direction: the quick-command save with the writes,
the review-notes read and the markdown read with the reads. The quick-command reader is shared
across that line, which is what keeps the save from adopting `[]` on a payload the parser rejects.
The native-chat readability probe imports `MobileRuntimeRepoSummary` instead of redeclaring it, and
a stray mutant comment that described the race entry is gone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): finish the refusal-interpretation helper across the session hooks
Twelve try/catch blocks across the session hooks now call the generic
interpretOrThrowRefusalMessage instead of rethrowing
refusedRpcMessageOrFallback by hand. Each one throws the same message on the
same inputs, and the request stays outside the catch, so a transport rejection
keeps its delivery-unknown identity.
Two frozen parity hashes move for that reason alone:
- HEAD_CALLBACK_BODY_SHA256, for the one converted block that sits in a
useCallback (use-mobile-session-diff-comments.ts)
- HEAD_NESTED_FUNCTION_SHA256, for the three that sit in plain nested functions
(use-mobile-session-content-create-actions.ts)
Every other parity hash and every count is unchanged: hooks 269, callbacks 77,
effects 24, nested functions 12, plus the callback identity, effect, main-hook,
hook-binding, content-hook, native registration/removal and timer hashes.
Copies that stay longhand, by design:
- the action-level try blocks that wrap the request as well as the interpret and
surface the failure to the UI, in the diff-review comment, git and send hooks
- the two catches that call setActionError or setError and return instead of
throwing, in use-mobile-diff-review-interactions.ts and use-quick-commands.ts
- the call sites in files this branch does not touch (pr-ai-triage-launch.ts,
mobile-diff-review-loaders.ts, github-pr-rpc.ts,
github-pr-mutation-outcome.ts)
No golden moves: this touches no golden, adapter or recorder engine file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the goldens at the new baseline pin
One re-record of all 641 goldens against pin e7206f62a8 from a detached
worktree at that commit, with this branch's rpc-recording/ tree,
pilot-scenarios.json and scripts/rpc-recording.mts overlaid and only the
differing files copied in. The recorder's own fence passed: product sources
under mobile/src outside the recorder, src/shared and the lockfile were
byte-identical to the pin, with nothing untracked under the guarded trees.
Every golden now carries one baseline, one lockfileSha256 and one
recorderSha256. recorderSha256 moves on all 641 because this branch's recorder
tree adds adapters to main's, so main's own value could not survive the merge.
baseline moves on exactly the 121 goldens this branch adds, from c6a7216984
to e7206f62a8.
595 goldens are header-only. The 46 bodies that move are all among this
branch's 121 and all gain the `sent` ordinal that #20884 added to effects,
verified field by field: each effect is byte-identical apart from the new key,
and no effect was added or dropped. None of main's 509 bodies move, and the
six-golden agentSession.structured-launch family does not move either.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mount screens, declared device stores and declared OS state in the RPC recorder
Three recorder capabilities, each with a test, plus four holdout sites recorded against main's
product code to prove them. No product source changes.
- JSX compiles through the automatic runtime, which is what product sources use; the classic
`React.createElement` emit threw `React is not defined` on the first render of every screen.
- An unlisted package answers `__esModule` as undefined, so a default import loads and the refusal
defers to the first real member read instead of killing the module at load.
- The substitutes table gains the inert view packages a screen needs, split into
`screen-native-substitutes.ts` behind the same rule: only what a screen reads is listed.
- A scenario may declare `deviceStore` and `deviceState.notificationTray`. Reads resolve the
declaration or null and never a write; writes are recorded as effects. Undeclared is unchanged.
- `screenMount` mounts a component with a crash boundary, so a reply partition that takes a screen
down is a recording rather than a suite failure.
Every pre-existing golden re-records byte-identical except `recorderSha256`, recorded from a
detached worktree at the pinned baseline.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): declare the crash boundary's state type instead of asserting it
The changed-code casting gate counts `null as string | null` as a type assertion. Re-records every
golden from the pinned baseline, because the edit moves `recorderSha256`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): refuse an unlisted native package by the member a mount reads
Both emitted interop helpers short-circuit on `__esModule`, so answering `true`
hands the refusing trap back unwrapped to `__importDefault` and `__importStar`.
All three import forms now load the importer and throw the named refusal at the
first member read, instead of a namespace import silently yielding `undefined`
and failing later at the call.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): run a task deferred past interactions instead of dropping it
An inert `runAfterInteractions` swallows whatever send the screen deferred, and
the recording then claims the screen sends nothing. It runs the task on a
microtask and returns the RN-shaped handle, so a cancel before the task runs
still prevents it. `Alert.alert` stays inert.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record every golden for the two recorder engine changes
Only `recorderSha256` moves, from c58067de to a59b30e7. Recorded from a detached
worktree at the pinned baseline c6a7216984 with this branch's recorder and
scenario file overlaid, so no golden body is a claim about migrated source.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop exporting three recorder symbols with no importer
`projectScreenTree`, `DeviceEffect` and `DeclaredNotification` are only used by
the file that declares them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record every golden for the dropped exports
Only `recorderSha256` moves, from a59b30e7 to 4df43aef. Recorded from a detached
worktree at the pinned baseline c6a7216984 with this branch's recorder and
scenario file overlaid.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): list only the native members a recording is known to read
Instrumenting `partialNativeModule` across all 522 recordings found 68 declared
members and 23 read. The unread ones are gone: the whole react-native-svg,
react-native-reanimated and react-native-gesture-handler entries, two expo-router
hooks, and twelve react-native members including `InteractionManager` and
`Alert`. A member listed before a recording reads it turns a refusal that would
have forced a decision into a silent stand-in, which is how an inert scheduler
swallows a deferred send. Wave 3 re-adds each one with the recording that reads
it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): order each effect against the sends with a send count
Sender and effects are two independent lists, so a send reordered ahead of a
device write moved neither. Scheduling the codex journal write on a timer instead
of awaiting it left all 520 goldens byte-identical; with `sent` it moves two.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): report a boundary crash through the effect sink
A hook mount projects the hook's own value and never a crash, so an adapter that
never reads `crash()` recorded a screen that quietly stopped rendering. The
boundary now reports to the effect sink as well, which forces a cleanup
checkpoint and puts the crash in the golden with no adapter cooperation. Two
reply-matrix goldens gain the effect. Also folds the two near-identical tree
walkers into one `walk(tree, visit)`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): state the __esModule interop rule once
It was restated at four sites with four different answers. The canonical
paragraph is in `native-module-traps.ts`; the loader and the declared tray point
at it in a line. The loader comment also said the refusal names the member the
product read, which for a default import is `.default` instead.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record for the send ordinal, the crash effect and the pruned table
`recorderSha256` moves on all 520. Of the 509 pre-existing goldens, 260 are
header-only and 249 gain `sent` on their effects and nothing else; two of this
branch's own reply-matrix goldens also gain a `screen.crash` effect. Recorded
from a detached worktree at the pinned baseline c6a7216984.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(lint): drop Reflect.get from the native traps
main adopted anti-slop's `no-reflect-get` in #20874 and fixed the copy that lived
in `native-mounting-substitutes.ts`. This branch moved both traps into
`native-module-traps.ts`, so the rule lands here instead and the static-analysis
gate is red without the same narrowing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record for the Reflect.get narrowing
`recorderSha256` only, 8ca81bc6 to 0ddc0dc4. No golden body moves, which is the
claim the narrowing makes: the trap reads the same member it always did.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record every golden at main's new baseline pin
One record from a detached worktree at `e7206f62a8` — main's pin since #20895 —
with this branch's `rpc-recording/`, `pilot-scenarios.json` and
`scripts/rpc-recording.mts` laid over it, so the goldens are recorded against
main's product tree rather than a branch that edits the engine.
All 520 goldens now share one `recorderSha256`
(`0ddc0dc472e2efbe58004c6dfaf5360e368fed321e40dfe15c11a9ad1817dea4`) and one
`baseline`. Against main, 262 move on the header alone and 247 also gain the
`sent` ordinal on an effect, which is the whole of the body change; the nine
goldens #20895 re-digested carry a byte-identical body, and the six the new
worktree catalog adapter (#20873) recorded keep main's observation.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(lint): merge duplicate type imports in the mobile RPC recorder adapters
The native code-quality audit rejects a module imported twice in one file, so
main's static-analysis job is red for every open PR.
* test(mobile): re-record RPC goldens against the merged adapters
The duplicate-import fix changed two mount adapters, so the nine goldens that
pin them by adapterSha256 needed re-recording. The recorder fence requires the
pinned baseline to match the product tree, so the baseline moves to current main,
which rewrites that header in all 509 goldens. Every recording body is identical,
which also shows the commits between the two baselines changed no observed
behavior.
* fix(lint): replace Reflect.get with typed property access in native mounting substitutes
main's tip fails `pnpm run audit:anti-slop` (the `static analysis` CI gate) on
`no-reflect-get` in mobile/src/test-support/rpc-recording/native-mounting-substitutes.ts,
blocking every open PR. The Proxy get trap's key is `string | symbol`; branch on that to
keep typed bracket access for strings and a symbol-indexed cast for symbols, preserving the
existing throw-on-unsubstituted-member behavior exactly.
* test(rpc-recording): re-record goldens for the recorderSha256 shift
native-mounting-substitutes.ts changed bytes, so recorderSha256 (which
pins every non-adapter file under this directory into every golden's
header) moved. Re-recorded all 509 goldens; only recorderSha256 differs
in any of them, confirming the checkpoint content is unchanged.
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's
`recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR
that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its
merge with main conflicted on that one line in 153 files; every future domain PR would collide
with every other in flight the same way.
Split the directory at a real seam instead of a filename convention: `adapters/` holds one module
per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers
the engine only. A new `adapterSha256` covers the source of the module that mounts each operation
a golden's scenarios drive, read off the same `mounts` calls that build the table the recording
runs against, so the pin cannot name a file the runner did not use.
Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the
goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file
inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file,
and an adapter importing a sibling each fail.
The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which
leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new
header field; the goldens re-record in the next commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens under the split recorder/adapter digest
Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers
`adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and
recording ran against the same pinned product tree.
git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
| grep -vE '^(\+\+\+|---)' \
| grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l
0
The seven `adapterSha256` values partition the 153 goldens by the module each was recorded
through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory,
9 tasks, 8 workspace settings.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): stop pinning goldens to recorder inputs no recording can read
The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the
per-family mutant registry beside it, and the probe-hole witness. None can change a recording --
the loader consults a mutant only when a mutant test asks for one, and no suite but the two
recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have
and charged every domain a full re-record for it.
`mutants/` now holds the table, the registry, the reference states, the mutant suites and the
probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can
reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by
name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts`
checks exactly that, and fails if an engine file names the directory or anything outside imports
from it.
`recorderSha256` also pins only the suites in `recording-drivers.ts`, which
`scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or
writes one to a scratch directory, is no longer provenance for a recorded file.
`OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold
the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own
exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden.
Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to
`root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed
anything. Each call now spells the root differently.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens under the mutant and driver exclusions
Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153
because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that
module now carries its own exposure declaration.
git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
| grep -vE '^(\+\+\+|---)' \
| grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l
0
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): restore the preferences actions the merge resolution dropped
#20568 added `resume` and `trust` actions to the `settings.task-preferences`
adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already
moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the
`pilot-mount-adapters.ts` conflict in favour of the registry merge silently
discarded them and `tw-task-preferences-resume-write` failed to record at all
("Missing or completed request: ui.set#1").
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens at main's tip after the merge
All 208 goldens, header-only. `baseline` moves from 50e752fc66 to main's tip
c6a7216984, `goldenFormatVersion` from 4 to 5, `recorderSha256` to the value of
the engine with `adapters/` and `mutants/` carved out, and `adapterSha256` is new
on every file. Nine distinct adapter digests over 208 goldens: each golden now
pins only the module that mounts it.
No observation moved. The whole-diff census against origin/main reports exactly
four changed keys and nothing else:
208 "adapterSha256": 416 "baseline":
416 "goldenFormatVersion": 416 "recorderSha256":
Recorded in place rather than through the README's detached-baseline dance: this
branch changes no product file, so its tree at the merge is byte-identical to
c6a7216984 under mobile/src, src/shared and the lockfile, and the parity claim
stays non-circular. README says so now.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): hold the recording drivers to the engine's mutant-seam rule
The name scan exempted every `.test.ts` on the ground that a test cannot change a
recording. Two of them can: the recording drivers are the recording path. A driver
that read the mutant table by path rather than importing it passed both seam checks
— the import scan sees no import, and the name scan waved it through as a test:
const table = resolve(import.meta.dirname, 'mutants/operation-mutations.ts')
console.log(readFileSync(table, 'utf8').length)
at the top of `pilot-recordings.test.ts` gave 2 passed before, and after this change
fails with ["pilot-recordings.test.ts"].
Only non-driver tests are exempt now. This file lives in `mutants/`, which
`recorderSha256` skips, so no golden moves: the recorder suite is green on the
existing 208 with zero dirty.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the registry parameter no caller varies
`pilotMountAdapters` took `registered` so a caller could mount a different module
set; all six callers take the default. The header-digest tests vary the registry
through `goldenRecording`, which keeps its own parameter and is where the stub
roots need it. Engine source, so `recorderSha256` moves and the goldens follow in
the next commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens after the registry parameter came out
All 208, `recorderSha256` only. The re-record against the previous commit moves
416 lines, every one of them that field:
416 "recorderSha256":
Against origin/main the picture is unchanged from the merge: 208 goldens, 0 added
or deleted, 0 non-header lines, and exactly four keys differing —
208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256"
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): wrap the recording README at the width the rest of it uses
Seven lines this branch added ran past 100 columns, worst 124. No wording changed.
Markdown is outside `recorderSha256`, so no golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the worktree overlay, not the archive that cannot work
`git archive` was offered alongside a detached checkout as a way to lay this
branch's recorder over the pinned baseline. It cannot work: the fence in
scripts/rpc-recording.mts runs `git diff --quiet <baseline>` and an untracked-file
check, both of which need a real `.git`. In an archive tree git exits non-zero for
lack of a repository and the script reports "Product sources or lockfile differ
from the pinned main baseline", which reads as a product mismatch that is not
there. The transport agent lost time to exactly that.
Names `git worktree add --detach` only, and says what the misleading failure looks
like if someone tries an archive anyway. Markdown is outside `recorderSha256`, so
no golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): close two ways an adapter module escapes its own digest
Two holes, one class: the seam was checked by how an import was spelled and by
what the register's values evaluated to, never by where they resolve or where they
were written.
Inward imports: the scan dropped every specifier starting with `..`, so
`'../adapters/settings-mount-adapters'` climbed out of the directory and back into
it unseen. A reviewer had `new-tab-agent-mount-adapters.ts` project a value read
from the settings module, edited that module, and watched the mounted state change
while the new-tab adapter digest held. Specifiers now resolve against the
directory and anything landing back inside it fails:
["new-tab-agent-mount-adapters.ts imports ../adapters/settings-mount-adapters"]
The register: `adapters/mounted-operation-modules.ts` is pinned by nothing —
`recorderSha256` skips the directory and `adapterSha256` reads each entry's
`source`. An `exposes` written inline there drives the mounted product module with
no digest covering it. The same reviewer replaced the new-tab entry's `exposes`
with a literal overriding `loadMobileNewTabAgentOptions`; twelve fence tests
passed. Both `mounts` and `exposes` must now be identifiers the register imports
from that entry's own module:
["new-tab-agent-mount-adapters.ts writes exposes inline instead of importing it"]
Checked on the register's syntax, not its values, because an inline literal and an
imported binding are indistinguishable once evaluated.
Pinning the register in the engine digest would also close it, and is the wrong
trade: every domain adding a register line would re-digest all 208 goldens, which
is the conflict this PR exists to remove. Keeping the register an index costs
nothing and keeps a domain's line local.
Both fixes live in a `.test.ts` outside the drivers, so no golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): prove the mutant seam from the drivers out, not by spelling
The seam rested on a grep for the literal `mutants`, which the exported
`MUTANT_DIRECTORY` spells without containing. A reviewer had
`pilot-mount-adapters.ts` read the mutant table through that constant and both
checks passed. The README's claim — that nothing on the recording path names the
directory — was false as written.
Three changes, in order of strength:
Reachability is now proved forward. The suite walks the static import graph from
the two recording drivers and fails if any module under `mutants/` is in it. That
answers the real question, what a golden's bytes can depend on, instead of the old
inward scan's question, who mentions this directory. Non-emptiness is asserted on
both sides so a graph that resolved nothing cannot pass by reaching nothing.
The name scan covers both spellings, for paths a module can be read by rather than
imported. The reviewer's probe now fails as ["pilot-mount-adapters.ts"].
`MUTANT_DIRECTORY` is no longer exported. Its two consumers were both tests of the
digest, and they now spell the path instead, which is strictly better for them: a
test that imports the constant follows a rename silently, while one that spells it
fails on a rename — and that specific directory name is the whole soundness
argument. This edits `recorder-digest.ts`, so the goldens re-record in the next
commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens after MUTANT_DIRECTORY stopped being exported
All 208, `recorderSha256` only. Against the previous commit the diff is 416 lines
and every one of them is that field:
416 "recorderSha256":
Against origin/main, unchanged: 208 goldens, 0 added or deleted, 0 non-header
lines, four keys differing —
208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256"
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state the mutant seam's actual argument, and its edge
The README claimed nothing on the recording path names `mutants/`. That was the
old inward scan's claim and a reviewer falsified it with the exported constant. It
now describes what the check does: a forward walk of the import graph from the two
recording drivers, plus a name scan in both spellings for read-by-path, plus the
constant no longer being exported. It also names the case neither closes — a path
assembled from fragments at runtime.
Markdown is outside `recorderSha256`, so no golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): prove the engine/adapter seam in both directions
The inward scan only held adapters to the seam. An engine file importing an
adapter executes code its own digest skips and that every golden recorded
through another domain leaves out of `adapterSha256`, so the register is now
the only crossing allowed from the engine side.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name what the driver walk missed instead of counting it
Seeding `seen` with the drivers made the driver-presence check true by
construction, and the size bound compared a graph inflated by `typeof import`
product modules against a recorder-sized number. Both go; the walk now reports
the recording files it failed to reach, which is empty today and names an
orphan engine file the moment one appears.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): reflow four paragraphs left ragged by the rewrap
Orphan fragments only, no wording change: the golden-schema field list, the
mutant-evidence paragraph, the probe-witness sentence and the re-anchor note.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the dictation, terminal, notification and browser domains against main
Adds scenarios and mount adapters for six small feature areas before any product file
moves, so the migration that follows has a frozen main oracle to be measured against:
dictation setup and session, agent history, terminal input and viewport refit, push
registration and the hosted browser's pointer, keyboard and dialog commands.
Five new adapter modules, one per domain, each pinned by its own `adapterSha256`. Two
declare exposures: `sendRegister`/`sendUnregister` are module-private in push-registration
and their exported callers read the keychain host catalog first, and the history hook
reaches its client through the shared per-host context rather than a parameter, so the
provider is the mounting boundary.
`native-mounting-substitutes.ts` is copied verbatim from the transport migration (#20667)
so the two branches merge, extended with the members these hooks read: `AppState` and
`useWindowDimensions` on react-native, the two-way audio module, `expo-keep-awake`, and
`buffer`. Every device event source is inert — no listener is fired — because each send is
driven through the operation's own API instead.
Wiring the substitute table into the loader moves `recorderSha256`, so every golden's
header re-digests. The product tree is unchanged, so `baseline` is unchanged and the 208
existing goldens move exactly one line each; no observation moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations
Replaces 42 raw `sendRequest` reaches across six feature areas with declared operations:
dictation setup and session, agent history, terminal input and viewport refit, push
registration and the hosted browser's page commands. No wire change — every golden
recorded in the previous commit still compares byte-identical, which is the evidence.
Acceptance is preserved site by site rather than unified. Every dictation and browser
refusal already raised the host's message with a screen fallback, so those share one
policy and keep their own copy at the call site. The terminal's two input sites read one
boolean off an object result, which `object-result-or-null` gives them without a throw.
The worker-takeover report and the three enrichment reads in the resume sheet are skips.
Two latent behaviours are preserved deliberately rather than tidied. `repo.list` reads
`.repos` at the return statement, so a null result throws a raw TypeError there and not a
wrapped refusal message; the three enrichment reads beside it are optional-chained and
tolerate the same null. `speech.dictation.finish` checks its refusal before the staleness
guard and reads `.text` after it, so the member read stays at the call site.
`RpcSendArguments` now admits an explicit `null` where the catalog declares no params.
Four shipped senders put `params: null` on the wire for such a method, and a frame carrying
a null is not the frame that omits the key; without this the migration would have rewritten
those bytes. `src/settings/native-voice-settings-operations.ts` widens its client type for
the same reason — it holds the port only to hand it to dictation.
Three references are left behind, each listed in the inventory with what blocks it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): matrix the dialog-dismiss site and name the three reaches left behind
The reply matrix drives every reply a family's base scenario scripts, and the browser dialog
family scripted only the accept leg, so repointing `browser.dialogDismiss` at another method
survived the whole suite. A scenario for the dismiss leg closes it; the mutation is killed now.
The inventory loses five section headers that no longer list anything, and the three entries
that resisted migration each carry what blocks them: a `worktree.ps` inside the history screen
component's own effect, an unsubscribe closed over inside a `subscribe` callback, and a
`notifications.getMissedSince` gated behind the OS notification tray and the keychain host
catalog. Faking either of the last two would record device state, not a wire.
Eighteen goldens move on `adapterSha256` alone: removing two inner casts from the browser and
history adapters re-digests the goldens mounted through them and nothing else. All 263 were
re-recorded from the pinned baseline and compare byte-identical to the previous recording
apart from those headers and the new file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): fold five refusal-to-message spellings into one helper
The same three lines appeared five times in this PR: interpret a reply, and turn a refusal into a
plain Error carrying the host's message or the screen's copy. `interpretOrThrowRefusalMessage`
lives beside `refusedRpcMessageOrFallback` and takes the interpretation as a thunk, so the caller
still awaits the request outside the catch and a transport rejection reaches it as the object the
transport threw, delivery-unknown mark intact.
That removes the browser hook's own `assertBrowserCommandAccepted`, whose first parameter named an
operation the helper then used only to call `interpret`. Every browser page command is built by one
factory with one acceptance, so substituting one operation's `interpret` for another's was
unobservable and the parameter read as load-bearing when it was not. The call sites now name the
operation where they request and where they interpret, the shape the dictation sites already used.
Main's eight source-control spellings are deliberately untouched; folding those in is its own PR.
No wire change and no golden moves: `git diff --name-status origin/recorder-adapter-digest...HEAD
-- mobile/rpc-foundation` is byte-identical before and after at 55 added and 209 modified.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the history screen holding for a late worktree list
The hook holds loading when a scoped tab has no active worktree and the worktree list has not
arrived, rather than firing an unscoped scan that would briefly show unrelated host history. The
adapter hardcoded `worktreesLoaded` to true and its `worktrees-loaded` action was dead, so no
golden reached that branch and the hold was unrecorded.
The list and the flag now move together, which is how the screen learns them, and `mount` takes
`worktreesLoaded: false` to start unloaded. The new scenario records the hold, then the late load.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the goldens at the pinned baseline
Recorded from a detached worktree at c6a7216984
with this branch's rpc-recording/ and pilot-scenarios.json overlaid, per the
README's migration-branch procedure. Only header fields moved; no body line in
any golden changed.
recorderSha256 moved on all 208 pre-existing goldens: this branch adds
native-mounting-substitutes.ts at the top of rpc-recording/ and routes
operation-module-loader.ts through it, and both are recorder-engine inputs.
That resolves itself when #20667 lands the same substitution on main.
adapterSha256 moved on exactly four goldens, all from the late-worktree-list
scenario's edit to adapters/agent-history-mount-adapters.ts:
aivault-history-scan-fulfilled, aivault-history-scan-unsupported,
matrix-aivault.history-aivault.listsessions-1,
matrix-aivault.history-status.get-1.
aivault-history-scan-worktrees-late.json is new.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): ask these call sites for a request port, not a whole client
The migration widened seven files from `Pick<RpcClient, 'sendRequest'>` to the
full `RpcClient` for no reason: a bound operation's `request` takes
`UnvalidatedRpcRequestPort`, which is structurally that same single member.
Name the port instead, so the signature says what each function actually needs
and a caller holding only a port still satisfies it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make these adapters reject an action no scenario dispatches
Each of the five new mount adapters ended its `action` with an unnamed default,
so a misspelled or engine-introduced name silently ran the last branch: probing
`remount` on the history adapter ran `onRefresh()` and put `status.get` on the
wire. Twelve pre-existing adapters throw `Unknown X action` instead; these now
do too.
Deleted with it: every branch no scenario can reach. `unmount` is dispatched
only by the lifecycle derivation in derived-goldens.ts, which is restricted to
LIFECYCLE_BASES, and none of these scenarios is in it; teardown goes through
`dispose`, which already unmounts. Same for the history adapter's `retry`,
`select-scope` and refresh, the dictation start flow's `disable`, and the
viewport adapter's `reconnect`. None of them handled `remount`, which the
schedule driver always pushes after `unmount`, so the pairing was never whole.
The three single-action entries keep `_name`, matching six pre-existing
entries that do the same.
Goldens move on adapterSha256 only and are re-recorded separately.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop four fallback strings no catch can read
Both pointer paths wrap their whole sequence in `catch {}` with an empty body,
so the message the refusal helper builds is discarded. `interpret` already
throws on a refusal under `require-result-or-throw-message`, and the helper only
rewrote the text, so removing it keeps the same control flow: the sequence still
stops at the refused leg, the later commands still go unsent, and `setError`
still does not run. The mousemove matrix golden records `error: null` under
every refusal and transport shape either way.
The helper stays where the throw reaches a caller: dictation and agent history.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): read the viewport outcome where the reader is
`readTerminalUpdateViewportOutcome` had one caller, the reader that wraps it, so
the name bought a second file to open and nothing else. Inline the two
comparisons and keep the outcome type where it was.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record this branch's goldens after the adapter cleanup
Recorded from a detached worktree at c6a7216984,
this branch's rpc-recording/ and pilot-scenarios.json overlaid, per the README's
migration-branch procedure. 266 recordings, all reproduced.
adapterSha256 moved on all 56 goldens this branch owns, because the named throws
and the deleted unreachable branches changed all five adapter modules. No other
header field moved, and no body line in any golden changed: the wire, the
effects and the state snapshots are identical, which is the claim the five
review deletions rest on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): reach the narrow port type without naming the port module
The previous commit named `UnvalidatedRpcRequestPort` by importing the port
module, and that fails the boundary ratchet: it counts an import of
`unvalidated-rpc-request-port` as reach, so all six unlisted files became
offenders and the history panel went from 1 reference to 2.
Main's `Pick<RpcClient, 'sendRequest'>` fails it for the same reason, by a
different rule: a bare `'sendRequest'` string literal is counted too. That is
why the migration widened these signatures in the first place, so the review
finding's premise that it was done for no cause is wrong. Only the full
`RpcClient` scored zero.
Re-export the port type from `rpc-client` instead. An export declaration with no
module specifier is not counted, the seven signatures still say they need one
sender rather than a whole client, and the inventory does not move. Holding a
client already carries the same reach, so nothing new is opened.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* Revert "refactor(mobile): reach the narrow port type without naming the port module"
This reverts 92e679f587 and 16c9c232fc as one commit, returning the seven
client signatures to the full RpcClient the migration gave them.
unvalidated-rpc-request-port-boundary.test.ts:192 pins an import of the port
type as one reach by design: naming the type is exactly what the ratchet
retires, so re-exporting it from rpc-client opened an uncounted path for every
future file. Widening to the client is the design's intended end state.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): restore this branch's native substitutes and re-record at the pin
Taking #20667's landed `native-mounting-substitutes.ts` verbatim dropped the substitutes this
branch's domains need, and five scenarios stopped recording: `speech-audio-chunk-acknowledged`,
`speech-dictation-session-transcript`, `speech-dictation-session-cancelled`,
`terminal-viewport-refit-applied` and `terminal-viewport-refit-legacy-desktop` all failed with
`Missing or completed request`, because the hook throws on the native member before it sends.
Both branches created that file independently; neither is a version of the other. Main's structure
is kept whole — `partialNativeModule`, the `__esModule` rule, the async-storage trap — and this
branch's boundaries are added inside it: `buffer`, `AppState` and `useWindowDimensions` on
`react-native`, `@orca/expo-two-way-audio`, `expo-keep-awake`, and `expo-secure-store` as a second
unusable store. Each is inert; no listener is ever fired and no audio is produced.
That is an engine edit, so every golden re-digests. Recorded at main's pin
c6a7216984 from a detached worktree with this recorder overlaid, and
the product tree there was byte-identical to the pin. All 397 goldens moved on `recorderSha256`
alone and nothing else:
git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
| grep -vE '^(\+\+\+|---)' \
| grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion|baseline|scenarioSha256|lockfileSha256)":' | wc -l
0
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record at the pin after the #20685 merge
The merged recorder is a third engine: main's post-tasks-2 files plus this
branch's native-mounting-substitutes.ts, so recorderSha256 moves once and every
golden re-digests. Recorded from the pinned baseline c6a7216984 in a detached
worktree with this tree's recorder laid over it, so the product source is still
main's pre-migration tree. All 509 goldens move on recorderSha256 alone; no
recorded wire byte changes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record main's task provider item, detail and board RPC behaviour
35 scenarios over 22 of the 25 files left in src/tasks/, recorded from main so the step-4
migration of the provider half has a frozen answer to compare against. Every one of the 70
references this branch will migrate reaches a recorded wire here, which is the check the
workspace-creation half added after it lost three sites to fixtures that short-circuited
before the call.
Scenario params are observed, not written: a generator drove each adapter with nothing
answered, read the projected sender calls back, and emitted the completion steps from them,
so no `params` in the manifest is a guess about what the screen sends.
Five adapter modules, split the way the screens are: one item's reads, the list and composer,
the item mutations, the board's reads and the board's row mutations. `mountModelHook` holds
the mount/dispatch/project boilerplate these twenty-two hooks share, so each adapter is only
its fixture, its actions and its projection. Two fixture modules hold the task items and the
project rows, shared so the same pull request looks the same to the comment hook, the merge
hook and the checks hook — which is what makes their recordings comparable.
`baseline` moves from 50e752fc66 (#20562) to fc525c355d (#20568), the commit this records
from. The pinned baseline had drifted from main again when the workspace-creation half
landed, and recording refuses to run against a tree that does not match it. This is main's
product source, not the branch's: no product file changes in this commit.
The 208 existing goldens change header-only — `baseline` and `recorderSha256`, the latter
because any adapter is inside the recorder digest. Verified field by field: nothing else
moved on any of the 208.
Goldens: 208 -> 317, 3.8M -> 7.7M. 74 new matrix sites over 35 new families.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): send the task provider, detail and board domains through typed RpcOperations
22 of src/tasks/'s 25 remaining raw-port files now send through a declared operation instead of
the raw request port: 70 references to 0, leaving 3 files and 3 references. No golden moved —
`git show --stat` on this commit touches nothing under mobile/rpc-foundation/, which is the
parity claim, and the 317 goldens recorded in the previous commit all pass against this tree.
56 operations over 58 methods, in five modules named for what they send: one item's detail
reads, the list's provider loads, item comments and replies, item state/merge/check writes, and
the GitHub Projects board. Five more operations are reused from the workspace-creation half
rather than redeclared, because the list asks github.listWorkItems, gitlab.listWorkItems,
linear.searchIssues, linear.listIssues and settings.update with the same acceptance the Smart
picker does.
Three methods carry two policies each, and all three pairs are named. `linear.status`: task
hydration cannot list without the workspace and surfaces the host's message, the home probe
degrades to "not connected". `linear.listTeams`: hydration reconciles a saved selection and
needs it, the composer's picker just empties. `github.repoSlug`: the Projects board must tell
"no slug" from "the ask failed" and caches the failure for retry, the paste lookup caches a
refusal as "no slug" and carries on. Each pair shares one reader, so no method has two. No new
acceptance policy.
Ten sites picked a method with a ternary. Nine were a literal pair — a provider or an item type
choosing between two methods — and each now selects between two operations instead, which also
types each arm's params separately. Two of those were listed as unmigratable `{ method, params }`
multiplexers: `use-mobile-tasks-project-file-merge-actions.tsx` and
`use-mobile-tasks-hosted-metadata-actions.tsx` both assign `method` and `params` from local
ternaries over `item.source.type` in the same function, not from a step a picker hands them, so
both migrated and both reach zero.
The Linear detail barrier keeps raw requests inside its `Promise.all`. main's group rejects as
soon as one leg's transport does, and interpreting only after both settled is what lets the
comments rejection win over the issue refusal — the b3 seed. `startRpcOperation` would wait for
the slower peer. Every loading hook's `stale` or generation guard stays where it was, between
the request and the state commit.
Two preserved oddities, both recorded rather than repaired:
- `gitlab.todos` keeps its payload spelled `response.result`. A reply that is neither an array
nor nullish crashes in `.map`, and the message the screen shows is that expression's source
text; renaming the local moved a golden, which is how this was found.
- `github.listWorkItems` keeps sending `before`. The list's pagination cursor is not in that
method's params schema, so the host has always dropped it and mobile's GitHub "load more"
re-asks for the same page. Sent verbatim with a cast; making the host honour the cursor is a
product fix with its own recording. Worth a ticket.
`github-project-host-routing-source.test.ts` pinned method literals that have moved into the
operation modules. It now pins the same guarantee in two halves — the board site carries the
host or the row's `prRepo`, and the named operation still sends that method — so neither half
can drift alone. The board's issue/PR update repeats its params rather than hoisting them, so
each send textually carries its own host, which is what that test reads.
The Mobile Tasks source-parity hashes move for the same reason the workspace half's did. The
diff is evidence rather than a re-pin: `semantics` is a pure deletion, 148 lines out and none
in — 70 `rpc:` call signatures, 75 method literals over 58 methods, and three duplicated
`item.source.type` comparisons that only existed because one `sendRequest` had to pick both a
method and a matching params shape from the same test. Statement, declaration, render and style
counts are unchanged, and the render, declaration and style hashes are byte-identical.
`b3: kills order` fails at this commit and only this commit. Its anchor names the send this
migration rewrote, so it matches zero sites; the next commit rehomes it at the same defect and
re-digests. Every other test passes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): rehome the b3 barrier mutant and name the recorder's fixture cast
The `order` mutant anchors the defect that the b3 seed exists to record: interpreting the issue
leg inside the request chain instead of at the barrier, so the group rejects early and the
sibling comment request is abandoned out of order. Its anchor named `client.sendRequest(
'linear.issueComments'`, which the previous commit rewrote, so it matched zero sites.
Rehomed at the same defect in its new shape — a `.then` that interprets inside the chain — per
the recording README, rather than deleted. It still kills, and for the same reason: the recorded
error becomes the issue refusal instead of the comments transport drop.
The adapters also stop casting per action. Sixty-five `as never` casts became one named
`mountFixture`, which says once why these fixtures are deliberately partial: they carry only the
members the mounted hook reads, and completing them into full domain objects would invent data no
scenario observes. `check:code-quality:changed` is clean on all 39 changed files.
Both edits are inside `recorderSha256`, so all 317 goldens carry a new digest and nothing else —
verified field by field, `recorderSha256` is the only key that moved on any of them, and no
golden was added or removed. Recorded from the pinned baseline fc525c355d in a separate worktree
with this branch's recorder laid over it, so the goldens stay attributable to main's product
source rather than to the migration. The suite is green here with the migrated source, which is
what makes the previous commit's "no golden moved" claim mean something.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record a Linear detail whose issue leg is answered
The acceptance census found one operation whose declared policy no golden observed: swapping
`linear.issueComments` from skip-on-refusal to throw-on-refusal survived every test.
The reason is the b3 seed. Its scenario refuses `linear.getIssue`, and the detail hook
interprets the issue leg first, so the issue error is raised before the comment leg's policy is
consulted — and the reply matrix drives one site per golden against the base scenario's other
replies, so every partition at `linear.issueComments` still had a refused issue beside it. The
comment leg's acceptance was unreachable, not merely untested.
`tasks.item-detail-linear` mounts the same hook with the issue answered. Its matrix drives both
legs with the other one fulfilled, which is what makes "a refused comment list leaves the sheet
with no comments" an observation rather than a claim. The policy swap now kills it on two
goldens. b3 is untouched: it still pins the defect it was written for.
Goldens: three added, and the other 317 carry a new `recorderSha256` because the adapter gained
a registration. Nothing else moved on any of them. Recorded from the pinned baseline
fc525c355d with this branch's recorder, as before.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-pin the two task parity hashes the import-form fix moved
The migration commit pinned the hook and statement hashes before `oxlint` asked five task files
to write `import type { X }` rather than `import { type X }`. Both readers walk import
statements, so both hashes moved; the fix landed after the hashes and the suite was left red.
Nothing observable changed. Hook, statement, declaration, render and style counts are all
unchanged, and the declaration, render, style and `semantics` hashes are byte-identical —
`semantics` is still the same pure 148-line deletion against main.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the real second consumer and the real reason for deferred interpretation
Two comments from the migration described code that does not exist.
The `linear.status` note credited the skip policy to the home screen. The home screen does send
`linear.status`, but through an unmigrated single-flight request in mobile-home-host-requests.ts,
so it is not the other policy at all. The only consumer of `taskLinearStatusRead` is the Tasks
runtime hydration hook, which is what actually treats an unanswered probe as "not connected".
Naming the wrong caller makes the two-policy claim unverifiable for the next reader.
The Linear detail group said "raw requests inside the group" while the code calls
`linearIssueRead.request`. The requests are operations; what stays inside the group is the
deferred interpretation. The reason is unchanged and still the point: this `Promise.all` rejects
as soon as one leg's transport does, and interpreting only after both settled is what keeps the
issue error winning over the comments error.
Comment-only, so no golden and no recorder file moves. The two parity hashes do move, because
`normalized()` captures a statement's full text and these comments sit inside the effect
callbacks it captures; both element counts are unchanged at 350 and 417, which is what shows
nothing structural shifted.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): move the task provider adapters into the per-golden adapter seam
#20662 pins each golden to the one adapter module it was recorded through, and pins the engine
directory to every golden. This branch's adapters sat in the engine directory, so leaving them
there would have re-digested all 208 goldens main already has. They move into `adapters/` and
register themselves, and the engine directory is now byte-identical to main's: `recorderSha256`
computes to 2e90933db32e, which is the value main's goldens already pin.
The seam forbids an adapter importing another file in the directory, and the register test requires
every file there to be a registered module, so the shared fixtures and the shared mount helper
could not follow the adapters in. Each module now carries the fixtures it actually mounts and its
own copy of `mountModelHook`, which is how main's nine modules are already written. That is real
duplication, about 55 lines of helper per module, and it is the price of a golden naming one file
as its provenance. Five modules became eleven for the same reason: a self-contained module carrying
its own fixtures crosses 300 lines, so each split at a hook boundary rather than taking a
`max-lines` bump.
One behaviour note. `task-mount-adapters.ts` mounts `use-mobile-tasks-item-detail-loading.tsx` for
its own family, and this branch mounts the same hook for three more. With a loader per module, both
modules' loaders applied the `order` mutant anchored in that file and `assertMutationApplied` saw
two applications where it requires one. Deferring this module's load to mount time fixes it, and
matches how `task-mount-adapters.ts` already loads it. The general hazard is worth an engine guard
and is reported separately: any future module that eagerly loads a mutant-anchored file breaks that
count, and nothing fails until someone runs the mutants.
Goldens are untouched here. They still carry the pre-merge header and the re-record is the last
commit in this sequence.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the recorder's fixture helper as a checked subset of what it stands in for
`mountFixture<T>(value: unknown): T` accepted anything, which is what let three fixtures record a
value the product cannot produce. It now takes `PartialRecorderFixture<NoInfer<T>>`: every member
optional at every depth, but no member the real type lacks and none with the wrong type. `NoInfer`
is what makes the parameter's type the target rather than the fixture's own.
The type lives outside `rpc-recording/` because every golden pins that directory and the helper is
copied per adapter module. A type cannot change a recording, so keeping it out is what stops eleven
copies of a recursive conditional type from existing.
Two deliberate allowances, both stated in the type. Functions pass through whole, since a stub with
optional parameters is one the hook cannot call. And a member may be `null` where the product type
says only optional, because these fixtures stand in for JSON the host sent and JSON spells an absent
object `null`; four Linear fixtures rely on that, and rewriting them to `undefined` would move them
away from what a host sends rather than towards it.
The two `mountFixture(model.client)` calls become `context.client`, which is typed `RpcClient` and
needs no cast at all. The model holds that same object under an `unknown` fixture record, and
`observableModel` returns it unwrapped, so this is the same client read from the side that knows its
type.
No fixture value changes here, so this moves nothing a golden records. The three divergences the
signature exposes are the next commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fix the three fixtures that recorded values the product cannot produce
Each of these was caught by the checked `mountFixture` signature in the previous commit, and each
made a golden record a path no user can reach. Fixture changes, so the goldens they drive move at the
re-record, and each moved golden is a claim listed there.
The single-select field mutation sent `{ singleSelectOptionId: 'option-1' }`, which is not a member
of `GitHubProjectFieldMutationValue`. `optimisticProjectFieldValue` fell through to the text
fallback, so the golden recorded `{kind: 'text', text: ''}` for a SINGLE_SELECT field and the
single-select branch was never exercised. The value is now `{kind: 'single-select', optionId:
'option-1'}`.
That alone was not enough: the branch also tests `field.kind`, and `STATUS_FIELD` carried only
`dataType`, so `kind` was undefined and the fallback still won. The field now carries its
discriminant, and the option it selects is present in `options`, because a board that loaded a
single-select field has its options and an empty list contradicts a user picking one. Without it the
optimistic value would record the not-found `'Selected'` / `'GRAY'` fallback instead of the option's
own name and colour.
`ownerType` was `'ORGANIZATION'` against `'organization' | 'user'`. The value reaches wire params
unchanged, so no branch was skipped, but six goldens pinned an owner type the product cannot send,
and `githubProjectIdentityKey` interpolates that field without normalising it while it does lowercase
`owner` and `host` — so the recorded settings key was one the product cannot produce either. The same
file already spelled it `'organization'` in one of three places, which is how it went unnoticed.
The issue-type fixture was missing `color` and `description`, both of which `GitHubIssueType`
requires and neither of which is optional.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): move the fixture-shape type inside the recorder, where recording can see it
`mobile/scripts/rpc-recording.mts` fences `mobile/src` against the pinned baseline and exempts only
`mobile/src/test-support/rpc-recording`, by tracked diff and by an untracked-file check. A type file
one directory up therefore fails recording outright as an unpinned product source, which is not a
judgement about the type, just where the fence is drawn.
So it lives in the engine directory. That has a cost worth naming: `recorderSha256` covers the engine,
so all 208 goldens this branch shares with main now carry a new digest. That is the one thing #20662
removed and this is the case it cannot remove — a genuinely shared recorder input has nowhere to go
that is both inside the fence and outside the whole-directory digest. `adapters/` is not available:
its seam test requires every file there to be a registered module, and forbids one module importing
another.
The alternative was a copy of the type in each of eleven modules, which would also have forced a
twelfth split, since the conversation module is already at 295 of its 300 lines. One shared type and
one re-digest is the cheaper trade, and the re-digest is a single header line per golden with no
recorded value moving.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record every golden from the pinned baseline with the merged recorder
Recorded from c6a7216984 in a detached worktree with this branch's recorder laid over it, per the
README's migration-branch procedure, because this branch's product tree is migrated and recording in
place would make the parity claim circular.
Two adapter loads became lazy first, and that was not cosmetic. `golden-header-digest.test.ts` builds
a temp tree holding only the product files one family needs, then calls `adapterSourceByOperation`,
which invokes every registered module's `mounts`. Eight of this branch's factory functions loaded
their hook while the table was being built, so they tried to read files that tree does not have and
five engine tests failed. The same eager load made `assertMutationApplied` count two applications for
the two mutants whose anchor file another module also mounts. Every factory now loads inside the
mount, which is how main's modules were already written.
Header movement, all 208 goldens this branch shares with main: `recorderSha256` only, from
2e90933db32e to 202244bdc6c5. Zero non-header lines. The cause is one added engine file, the
fixture-shape type, explained in its own commit.
Ten goldens moved beyond the header, all in the two families whose fixtures were corrected, and no
family outside them moved:
tk-project-row-fields and its updateitemfield, clearitemfield and updateissuetypebyslug matrices
send `value: {kind, optionId}` where they sent `{singleSelectOptionId}`, which the host's
`graphqlValueForFieldMutation` would have rejected as an unknown kind, and now record a
single-select field value where they recorded the text fallback. The field carries its `kind`
discriminant and its option, so the recorded value carries the option's name and colour.
The issue-type row gains `color` and `description`, both required and neither on the wire.
tk-project-board-load and its listaccessible, listviews, viewtable and resolveref matrices
spell `ownerType` `organization`. The host derives that value from GraphQL `__typename` and only
ever lowercases it, so the uppercase form was unreachable in both the reply and the params.
`baseline` also moves on this branch's own goldens, from fc525c355d to main's c6a7216984, which the
merge commit explains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin Project host routing to the declared method, not the identifier prefix
The guard matched `githubProject*.request(` sites, so an operation renamed off that prefix left
the prefix match empty and the host could go with the rename: renaming `githubProjectFieldUpdate`
to `projectFieldUpdate` at its definition and its one call site and deleting
`host: activeGitHubProjectHost` from the `github.project.updateItemField` request kept all three
tests and `tsc` green, and `host` is optional in the params type so nothing else caught it.
Derive the list from the board module by the method each operation declares instead, and scan every
product file under `mobile/src` rather than a hand-listed eight, so a site that moves stays covered.
Coverage goes from 13 matched sites to 17 across all 16 declared operations, because the old regex
also missed the `op\n .request(` form four of them use. An operation that stops being requested at
all now fails too.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): share the recorder's fixture helper instead of copying it into eight adapters
The seam forbids one adapter importing another, not an adapter importing the engine, and the
adapters already take `hookMount` and `observableModel` from there. So the eight byte-identical
copies of `mountFixture` bought nothing: eight doc comments and eight cast suppressions for one
four-line function that has no per-domain part.
Export it from `recorder-fixture-shape.ts`, next to the type it checks against, and leave one
suppression instead of nine. `adapter-seam.test.ts` 7/7 and `pnpm --dir mobile typecheck` stay clean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fence what the recorder fixture shape accepts, and drop the one branch that is dead
Review read the function branch and the `ReadonlySet | ReadonlyMap | Date` branch as dead because
typecheck stays at zero without them. Zero was the wrong oracle: no fixture in the tree carries a
callback, a set or a map, so nothing exercised them. Dropping both lets a `3` stand in for a
callback the mounted hook will invoke, and lets `{}` stand in for a set.
So pin them instead of asserting them. `recorder-fixture-shape-compile-fence.ts` is a non-test file,
which is the only kind `pnpm --dir mobile typecheck` covers, and each case fails as an unused
`@ts-expect-error` if the branch it stands on is removed: the callback case on the function branch,
the set and map cases on the second branch, and the accepted case on `| null`, whose removal is 3
errors in the adapters.
`Date` really was dead and is gone: its members are all methods, so the function branch already
refuses a structural stand-in for it, and the fence keeps that honest.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fail an adapter that loads product source while its table is built
Nothing caught a `modules.load` hoisted out of `useHook` into the table literal, and the two things
it breaks both report as engine faults far from the edit: a mutant anchored in a file two families
share gets applied twice and `assertMutationApplied` reports the wrong count, and
`golden-header-digest.test.ts` builds tables in a tree holding one family's files and throws
`Module not found` for every other family. This PR hit both while splitting the task adapters.
Build every registered module's table with a loader whose `load` throws, and assert none did.
Hoisting the `use-mobile-tasks-item-detail-loading` load in `task-item-detail-mount-adapters.ts`
fails it by name; `adapter-seam.test.ts` builds the same tables with a real loader and stays green,
which is why it never saw this. The suite records nothing, so `recorderSha256` excludes it and no
golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the fixture-shape fence in the file it fences, not beside it
A compile fence as its own file is an orphan the seam already rejects: `recorderSha256` pins every
file in the recorder directory, and `mutant-seam.test.ts` requires each pinned file to be reachable
from a recording driver, because anything pinned and unreachable re-digests all 320 goldens while
being unable to move one. The separate file failed that check by name.
Fold the cases into `recorder-fixture-shape.ts`, which the adapters already import, and drop the
directory literal from the comment so the seam's name scan stays clean. Removing a branch still
fails: function branch 2 errors, set-and-map branch 2, `| null` 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record every golden for the shared fixture helper
Recorded from the pinned baseline c6a7216984 with this branch's recorder laid over it, per the
README's migration-branch procedure. Two header fields move and nothing else does: `recorderSha256`
on all 320, because the engine now carries `mountFixture` and the cases that fence its type, and
`adapterSha256` on the 93 goldens recorded through the eight adapters that gave that helper up.
Non-header lines changed: 0. The candidate suite is 387 passed, 3 skipped.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record every golden after merging main's recorder
Recorded from the pinned baseline c6a7216984 with the merged recorder laid over it, per the
README's migration-branch procedure. One header field moves on all 453 goldens and nothing else
does: `recorderSha256`, because this branch adds `recorder-fixture-shape.ts` to the engine that
main's copy does not have. `adapterSha256` holds everywhere, since no adapter changed in the merge.
Non-header lines changed: 0. The candidate suite is 523 passed, 3 skipped.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb