Commit Graph
7 Commits
Author SHA1 Message Date
Jinwoo Hong 6a11a0b8e6 test(mobile): pin each RPC golden to the recorder inputs that can reach it, not the whole directory (#20662)
* 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
2026-09-14 20:11:21 -04:00
Jinwoo Hong fc525c355d refactor(mobile): send the task workspace-creation domain through typed RpcOperations (#20568)
* test(mobile): record main's task workspace-creation RPC behaviour before migrating it

28 scenarios over nine task senders, recorded from main so the step-4 migration of
the workspace-creation half of src/tasks/ has a frozen answer to compare against.
Four senders mount as plain exported functions; three are model-chained hooks
mounted the way the settings adapters mount theirs.

The 153 existing goldens change header-only (`baseline`, `recorderSha256`): any new
scenario re-digests the recorder, and the pinned baseline had drifted from main
because the source-control migration landed. Content is byte-identical on all 153 —
verified field-by-field against HEAD.

`operation-module-loader.ts` now shares src/transport/rpc-delivery-ambiguity.ts with
mounted modules instead of evaluating a second copy. The mark is a WeakSet keyed on
the rejection object, so the copy the loader built had an empty registry and every
delivery-unknown rejection read as a definite failure inside the operation under
test — worktree.create's whole replay path was unreachable. With one registry,
`tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms, while the unstamped-create
scenario records the same rejection surfacing at 0 ms. No existing golden moves:
no other mounted module consumes the mark.

`task-preferences-optimistic` is re-anchored above the send rather than across it,
so migrating this file does not have to move the anchor. It still kills, and for
the same reason: the preset the screen shows no longer follows the tap.

Scenarios deliberately pin the empty-message refusals (`*-refused-empty-message`,
`*-empty-message`), because a refusal with no message falls back to the screen's
copy while a transport error with no message does not, and the two paths are easy
to collapse when a call site moves behind an acceptance policy.

Goldens: 153 -> 201, 2.9M -> 3.7M.

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

* test(mobile): format the recording manifest and re-digest the goldens

`oxfmt --check` from mobile/ collapses a one-element `sites` array in each new
scenario. The JSON value is unchanged — verified by comparing both files parsed
and key-sorted — but the manifest is inside `recorderSha256`, so all 201 goldens
carry a new digest. Every other field, header and observation alike, is
byte-identical.

Re-recorded in a separate worktree at the previous commit so the goldens stay
attributable to main's product source rather than to the migration that follows.

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

* test(mobile): separate the goldens from the migration, and re-digest

The previous commit accidentally carried the product migration alongside the
manifest format, which both broke the commit that is supposed to prove parity and
left the suite red: the digest was recorded without a comment move that a lint fix
had made inside the adapter, so all 201 goldens failed their `recorderSha256`
header.

This backs the product half straight out again — the next commit re-applies it
byte-for-byte — and re-records from the pinned baseline in a separate worktree
carrying this branch's recorder, per the procedure in the recording README. Every
field except `recorderSha256` is byte-identical to the previous commit's goldens on
all 201 files, so no observation moved in either direction. The suite is green here
with main's product source, which is what makes the next commit's "no golden
changed" claim mean something.

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

* refactor(mobile): send the task workspace-creation domain through typed RpcOperations

12 of src/tasks/'s 37 raw-port files now send through a declared operation instead of
the raw request port: 36 references to 0, leaving 25 files and 73 references for the
provider item/detail/mutation half. No golden moved — `git show --stat` on this commit
touches nothing under mobile/rpc-foundation/, which is the parity claim.

Twenty-two operations over twenty methods, in four modules named for what they send:
workspace create (create, PR/MR base resolution, create-time capabilities), workspace
source (SSH connect/state, agent detection, repo hooks, sparse presets, ref search),
task runtime (status, ui.get/ui.set, preflight, Linear status, settings.update) and the
Smart picker's provider reads.

Two methods carry two policies each, and both pairs are named. `status.get`: the Tasks
screen cannot hydrate without it and surfaces the host's message, while create-time
capability probing degrades to "no capabilities" and creates anyway — so one throws on
refusal and one skips. `ui.set`: two sites await it, one is fire-and-forget and never
interprets the reply at all. Both pairs share one reader, so no method has two readers.
No new acceptance policy.

worktree.create keeps its delivery-unknown contract. `request` returns the transport
promise itself, so the retry loop catches the object the transport marked; two new tests
assert `toBe(marked)` in one direction and that an unmarked rejection stays unmarked in
the other, because a mark added on the way out would replay a create the host never
received. `tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms.

Three sites still read the raw refusal envelope before interpreting, because the code or
the message decides the route and no acceptance policy carries either through: the create
retry needs the message for `isRetryableWorktreeCreateConflict`, and the paste lookup
needs `method_not_found` to retire the slug probe host-wide. Both are documented at the
site.

The hydration barrier keeps raw requests inside its `Promise.all`. main's group rejects as
soon as one leg rejects; `startRpcOperation` + `interpretAtRpcBarrier` would wait for the
slowest peer and let a later policy surface a different error. Interpretation stays after
the `stale` guard, where it was.

`WorkspaceCreateParams` is now `RpcSendParams<'worktree.create'>` rather than
`Record<string, unknown>`, which types the builder and the operation together; every field
the three builders already sent typechecks against the host schema unchanged.
`RpcSendArguments` now also makes params optional for a method whose params type has no
required field, because `preflight.check` is such a method and main sent it none —
requiring `{}` would have put a new object on the wire.

The Mobile Tasks source-parity hashes move for the same reason bound settings requests
moved them: the method string and the envelope read leave the screen. The signature diff
is evidence rather than a re-pin — `semantics` is a pure deletion of 22 `rpc:` call
signatures and 22 method literals with nothing added, statement/declaration/render/style
counts are unchanged, and render tokens, styles and declarations are byte-identical.

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

* test(mobile): split the task workspace adapters at the sender/hook seam

The single adapter file reached 344 lines against mobile's 300-line limit. CI lints
every file, so this is red there even though the changed-code gate does not report it.
Split along the seam the recording README already draws: exported async senders that
take a client and need no React host, and the drawer's three model-chained hooks.
No adapter body changed.

Both files are inside `recorderSha256`, so all 201 goldens carry a new digest. Every
other field is byte-identical, verified file by file. Re-recorded from the pinned
baseline in a separate worktree carrying this branch's recorder, so the goldens stay
attributable to main's product source.

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

* refactor(mobile): give the one-key unchecked reader a name

Four readers were the same three lines: read one property off the reply, wrap
it unchecked. `rpcUncheckedMemberReader` is the one-key sibling of the existing
`rpcUncheckedPayloadReader`, so the annotation and the closure go away at each
site. The pilot's `commitCompareEntriesReader` is converted too, so the helper
has no longhand twin left to copy from.

No behaviour change: the helper composes the same `rpcReadUnchecked` over
`rpcPayloadMember`, including the property-read exception on a null result.

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

* test(mobile): record the local arm of workspace agent detection

`preflight.detectAgents` was the one migrated operation with no recorded
coverage: the ssh adapter hardcoded `connectionId: 'ssh-1'`, so the detection
effect's ternary only ever took the remote arm and the local call site could be
repointed at another method without a golden noticing.

The adapter now takes the connectionId as a parameter and registers twice;
`tasks.workspace-ssh-local` mounts the same hook with no connection, which is
the only difference the effect branches on. Recorded at the pinned baseline
with this branch's recorder laid over it, so the new golden is main's
behaviour and the migrated code has to reproduce it — it does.

Goldens: two added (`tw-workspace-ssh-local-agents` and its reply matrix). The
other 201 changed on `recorderSha256` only, because the adapter edit moves the
recorder digest every golden pins.

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

* test(mobile): drive workspace create and the Linear list to a recorded wire

Two operations passed a policy swap unnoticed, both because no golden reached
their acceptance branch.

`worktree.create`: the create hook's fixture resolved setup to a prompt, so all
three settings.task-workspace scenarios stopped before the request and the only
consumer that hands a refusal to interpret was never recorded. The adapter now
takes the setup resolution as a parameter and registers a second family that
resolves it, so createWorkspace runs to the wire. Two scenarios: a Linear item
that creates directly, and a GitHub pull request that resolves its base first,
which also puts this hook's built params — start point, generated display name,
agent launch fields — in a golden for the first time. The existing prompt
family is untouched, so its recordings still pin that branch.

`linear.listIssues`: it appeared only in a non-base scenario, and the matrix
reads the family base, so the family had no partition for it. The base now
lists assigned issues after searching.

Goldens: five added. Five moved beyond the digest, all derived from the
smart-search base that gained the list leg. The other 198 changed on
`recorderSha256` only. Recorded at the pinned baseline with this branch's
recorder laid over it, so every new golden is main's behaviour.

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

* test(mobile): drop the unreachable unmount branches from the task adapters

Nothing dispatches `unmount` to these three adapters: the only producer is
`lifecycleSchedules`, driven from a hardcoded five-id list that names no
task-workspace family, and it pushes a `remount` right after, which these
adapters would throw on. The branch read as lifecycle coverage that was never
wired up. `dispose: hook.unmount` already tears the mount down.

Goldens re-recorded at the pinned baseline because the recorder digest moved;
`recorderSha256` is the only line that changed in all 208.

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

* test(mobile): re-record the goldens at main's post-squash baseline

Recorded from a detached checkout of e53f1557e1 (main's unmigrated
product code) with this branch's recorder laid over it, so the parity
claim stays non-circular.

- `baseline` repinned to e53f1557e1 on all 208 goldens; main pinned
  5ec0b2698f, a pre-squash branch commit not reachable from main.
- `recorderSha256` moved on all 208 because this branch's adapters are
  in the whole-manifest digest.
- 55 task-workspace goldens re-recorded at the new baseline.
- No other line in any of main's 153 goldens changed.

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

* test(mobile): re-record the goldens under #20562's per-scenario digest

Baseline repinned to 50e752fc66 and all 208 goldens recorded from that
commit's unmigrated product tree with this branch's recorder laid over it.
recorderSha256 moves on every golden because the task-workspace adapters
live in the recorder directory. scenarioSha256 does not move on any of
main's 153: the manifest only adds 31 scenarios and edits none, which is
the property #20562 was built to give.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-14 13:36:16 -04:00
Jinwoo Hong 50e752fc66 test(mobile): pin each RPC golden to its own scenario input, not the whole manifest (#20562)
* refactor(mobile): pin each golden to its own scenario input, not the whole manifest

`recorderSha256` covered the recorder directory plus `pilot-scenarios.json`, so every golden's
header was a function of every other family's scenarios. Adding a family for one domain re-digested
all 153 goldens and put a conflict on that line in every domain branch in flight, which serialized
the step-4 fan-out.

Split the two things it conflated. `recorderSha256` now covers the recorder directory only, with
unchanged semantics: a recorder edit still forces a full, deliberate re-record. A new
`scenarioSha256` pins the scenario input that golden was recorded from — every scenario
`runRecording` consumed for it, in order — canonicalised through `captureValue` so an
explicit-undefined param stays distinct from an absent one. `goldenRecording` takes that list
instead of just its first member.

The variants are hashed rather than the base they expand from because they are what was recorded: a
matrix site, its replayed normal result and its partition replies are all visible in them without
the derivation having to be restated. `derived-goldens.ts` is that derivation, extracted from
`family-recordings.test.ts` so the digest and the recording agree by construction — a property test
that restated how a matrix or schedule expands could agree with itself and with nothing else. It
reproduces exactly the 153 golden ids on disk, and the census the suite already ran (every family
matrixed, no stale normal-result inventory entry) now reads off its output.

`golden-header-digest.test.ts` pins the four properties:

- a new family in the manifest moves zero existing goldens' headers, and derives two of its own
- editing one field of `b1` moves exactly `b1` and its family's four matrix goldens — not the two
  other `legacy-inventory` scenarios, and not the goldens that expand from `inventory-lifecycle`
- editing a recorder file still moves every golden's `recorderSha256`, and no `scenarioSha256`
- `recorderSha256` is unchanged by the manifest's contents, and no longer reads the file at all

`GOLDEN_FORMAT_VERSION` goes to 4: a version-3 header has no `scenarioSha256`, and `compareGolden`
walks the expected header's keys, so a reader that accepted one would compare that golden's own
scenarios as though they were unpinned.

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

* test(mobile): re-record the 153 RPC goldens for the split digest

Recorder edit, so every golden needs rewriting. Recorded from the pinned baseline
`16d1ab81d3` with this branch's recorder overlaid, per the README's procedure: main has
moved past the baseline, so recording in place would have failed the product-source fence.

Three header fields moved and nothing else did:

- `recorderSha256` 6a12160a87… -> 2fda557f58…, one value across all 153 files
- `scenarioSha256` added, 153 distinct values
- `goldenFormatVersion` 3 -> 4

No observation, checkpoint, value-pool entry, `baseline`, `lockfileSha256` or `platform` changed:

    git diff -U0 -- mobile/rpc-foundation | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' \
      | grep -vcE 'recorderSha256|scenarioSha256|goldenFormatVersion'
    0

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

* refactor(mobile): certify the pilot goldens from the derivation that digests them

`pilot-recordings.test.ts` restated `[scenario]` instead of consuming `pilotGoldens`, so the claim
that a golden's `scenarioSha256` is a function of the same derivation that records the file held
only for the 75 family goldens: dropping a scenario from `pilotGoldens` left the whole suite green
and put that golden outside the header oracle. The pilot suite now iterates `pilotGoldens`, and a
census fails if the derivation and the goldens directory disagree in either direction — which also
closes the pre-existing orphan-golden gap.

Also from review: pin the cross-sibling replay that hashing the generated variants buys (a matrix
golden's `normal` partition replays a sibling's recorded reply, so editing that sibling must move
it); state the real reason for the format bump, which is the diagnosis a version check gives rather
than a rejection the byte compare already made; drop the fourth property test, which re-proved what
tests 1 and 2 and `recording-runner`'s digest test already fail on; and drop a guard in
`scenarioSha256` that its only caller reaches after an identical one.

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

* test(mobile): re-record the 153 RPC goldens for the review edits

Recorder files changed, so `recorderSha256` moved. Recorded from the pinned baseline with this
branch's recorder laid over it, per the README's migration-branch procedure. That one header field
is the only line that moved in all 153 files: `scenarioSha256` and `goldenFormatVersion` are
unchanged, and no observation moved.

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

* docs(mobile): correct the recording suite's test count

Round-2 review: the README said 200 tests; the suite is 209 after the five
added here. Markdown is outside recorderSha256, so no golden moves.

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

* test(mobile): re-record the 153 goldens on the merged baseline

Four header fields moved and nothing else. Proven against origin/main: every
changed line in all 153 files is one of these, and the file set is unchanged.

- `recorderSha256` 70aa6f59e0 -> 58a461dbc9: this branch's recorder, and it now
  digests only the recorder directory, not the scenario manifest.
- `scenarioSha256` added, 153 distinct values over 153 goldens.
- `goldenFormatVersion` 3 -> 4 for that added field.
- `baseline` 5ec0b2698f -> e53f1557e1, the merge's repoint onto the real main
  commit. #20563's value was a branch commit the squash left unreachable, so the
  record fence's `git diff <baseline>` could not resolve it.

No checkpoint, value pool, effect or settlement byte moved, so main's recorded
behaviour is carried over intact.

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

* docs(mobile): account for main's added recorder test in the suite count

The merge brought in `unhandled-recording.test.ts`, one test, so the recording
suite is 210 rather than the 209 this branch documented. Markdown is excluded
from `recorderSha256`, so no golden moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-14 13:10:22 -04:00
Jinwoo Hong e53f1557e1 fix(mobile): two known main bugs the RPC migration preserved (#20563)
* fix(mobile): two known main bugs the RPC migration preserved

A malformed host `error` and a null settings result both reach a property read that throws.
Both are deliberate behaviour changes; the goldens move in the follow-up commit.

`hostReplyErrorTextOrFallback` passed a truthy non-string through under a `string` annotation.
Its one caller is the in-band `git.commit` failure, and every consumer of that text is display or
prompt copy: `use-mobile-create-pr-runner` and `PrSidebarCreateEmptyState` record it as a commit
failure, `use-mobile-commit-failure-recovery` hands it to `summarizeCommitFailure`, which starts
with `raw.slice(...).replace(...)`. So no consumer needs the value, and the decision is the
fallback rather than `String(value)` — the relay handler declares
`commit(): Promise<{ success: boolean; error?: string }>`, so a non-string is a malformed reply,
and `generatedCommitMessageReader` in the same domain already reads a non-string host error as
absent. The parameter stays `unknown`, which it honestly is, and the `SAFETY` cast is gone.

`useNewWorkspaceRuntimeContext` read settings through `settingsRead`, whose reader preserves
main's `boxed!.settings` throw, so a `null` or absent result threw a TypeError out of the effect —
losing the trusted-hooks publish and the available-provider computation that follow it, not just
the settings. It now uses `optionalSettingsRead`, the operation that already reads a null or
absent result as absent settings, so the reply degrades exactly the way a reply with no `settings`
member does. Reply-side only: same method, same params, same barrier, no wire change.

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

* test(mobile): re-record the goldens the two bug fixes move

Baseline bumped to 3f71999237. Two goldens move an observation; the other 151 move only
`baseline` and `recorderSha256`, which `pilot-scenarios.json` is still digested into.

Observation moves, one claim each:

- `matrix-hostedreview.create-intent-git.commit-1`, partition `inner-false-object-error`:
  `settlements.run.value.error` and `state.outcome.error` go from `{"message":"inner refused"}` to
  `"Commit failed"`. A non-string in-band `git.commit` error is a malformed reply and now reads as
  the screen's copy, converging with `result-absent`, `result-null` and `outer-refused-no-message`,
  which already reported the fallback. The other ten partitions at this site are unchanged.
- `matrix-settings.workspace-context-settings.get-1`, partitions `result-null` and `result-absent`:
  the `unhandled-rejection` TypeError effect (`reading 'settings'`) is gone and `state.providers`
  goes from `[]` to `["github"]`. The effect no longer aborts the rest of the hook, so the
  provider computation runs; `state.settings` stays null because nothing was published, which is
  how a reply with no `settings` member already degraded. The other nine partitions are unchanged.

Header-only moves:

- 9 goldens of the `settings.workspace-context` family rename `namedDeltas` from
  `new-workspace-runtime-context-null-settings-typeerror` — the name now lies, the TypeError is
  fixed — to `new-workspace-runtime-context-null-settings-degrades-to-absent`.
- All 153 move `baseline` and `recorderSha256`. The digest covers `pilot-scenarios.json`, so the
  baseline bump and the rename re-digest every file.

No sender recording moved: both fixes are reply-side, and no golden's `sender` or `payloads` field
differs. The README paragraph that claimed the settings TypeError was preserved is updated, and
now records that the `ui.get` leg of the same hook still is.

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

* fix(mobile): degrade a null ui.get result the way the settings leg now does

One host answers both legs of useNewWorkspaceRuntimeContext, so fixing only
settings.get left the likelier failure in place: a null or absent ui.get result
still threw `reading 'ui'` out of the effect, skipping the provider commit.

Review follow-ups on the same files: reply() returns the literal uncast and the
stub client is FakeSession, dropping two assertions and their SAFETY disables;
the degradation cases now assert absolute state instead of comparing mounts.

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

* test(mobile): re-record the goldens the ui.get leg fix moves

Observational move, 1 golden:

- matrix-settings.workspace-context-ui.get-1: the `result-absent` and
  `result-null` partitions drop their `reading 'ui'` unhandled-rejection effect
  and their state commits `providers: ["github"]` instead of `[]`, because the
  effect no longer throws before the provider commit.

Header-only moves, 153 goldens: `baseline` to the fix commit and `recorderSha256`,
which covers `pilot-scenarios.json` and so re-digests on the delta rename.

The delta is renamed `new-workspace-runtime-context-null-settings-degrades-to-absent`
-> `new-workspace-runtime-context-null-results-degrade-to-absent` (9 goldens): it
now covers both reads, not just settings. README updated to match.

No sender recording moved: resolving the value pool across all 153 goldens shows
`sender` and `payloads` byte-identical everywhere.

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

* refactor(mobile): name the ui.get result shape so the changed cast carries a rationale

The inline union wrapped over four lines and tripped the changed-code casting gate
as a new assertion; a named alias keeps the cast on one line under a SAFETY note.
No behaviour change.

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

* test(mobile): repin the golden baseline to the cast-rationale commit

Header-only: `baseline` on all 153 goldens. The re-record is inert — no golden
moves observationally and no field other than `baseline` changes.

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

* test(mobile): pin the ui.get trust blank, and correct two stale acceptance comments

The goldens cannot catch a regression to `if (uiResult?.result)`: the scenario's
success reply is `{"ui":{}}`, so every partition of
matrix-settings.workspace-context-ui.get-1 records the same `trust:{}` state. The
new case answers once with real trust and again with a null result on a fresh
client, which is the only shape where skipping the blank is observable —
trustedOrcaHooks gates the setup-hook approval prompt in
use-new-workspace-create-submit.ts, so a stale value would skip it.

settingsRead's comment still claimed workspace context, which this branch moved to
optionalSettingsRead; both comments now name their real callers.

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

* test(mobile): repin the golden baseline to the trust-blank commit

The record fence rejected the previous pin ("Product sources or lockfile differ
from the pinned main baseline"), so the branch was no longer re-recordable.

Header-only: `baseline` and `recorderSha256` on all 153 goldens — the digest
covers pilot-scenarios.json, whose only edit is that baseline. The re-record is
inert: 0 goldens move observationally and no other field changes.

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

* docs(mobile): name the right operation per refuse-after-data probe

Three of the five probes read through optionalSettingsRead, not settingsRead:
repo metadata and resume metadata already did, and workspace context does as of
this branch. The sentence now splits them and states why the split does not move
what the probes record.

Markdown is excluded from recorderSha256, so no re-record.

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

* test(mobile): pin the recorder's unhandled-rejection capture

This branch removed the last two goldens that recorded an unhandled-rejection
effect, so nothing exercised unhandled-recording.ts any more: gutting the emit to
`void captureError(error)` leaves all 153 goldens comparing clean. The unit test
drives a detached rejection through the window and asserts both the effect and
the listener restore. README says so where it describes the capture.

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

* test(mobile): re-digest the goldens for the new recorder test

Header-only: `recorderSha256` on all 153 goldens, which covers every non-markdown
file under rpc-recording/ and so moves for the added test file. The re-record is
inert: 0 goldens move observationally and no other field changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-14 11:48:28 -04:00
Jinwoo Hong 7d98c8e2f3 refactor(mobile): send the source-control domain through typed RpcOperations (#20544)
* test(mobile): record main's source-control RPC behaviour before migrating it

45 scenarios over 11 source-control senders, recorded from main so the step-4
migration has a frozen answer to compare against. Adapters mount the real
exported senders as plain functions, so no React host or device is needed.

The 73 existing goldens change header-only (`baseline`, `recorderSha256`): any
new scenario re-digests the recorder, and the pinned baseline had drifted from
main in `src/shared` so recording required bumping it. Content is byte-identical
on all 73 — verified field-by-field against HEAD.

Scenarios deliberately pin the empty-message cases (`sc-*-refused-empty-message`,
`sc-*-rejected-empty-message`), because a refusal with no message falls back to
the screen's copy while a transport error with no message does not, and the two
paths are easy to collapse when a call site moves behind an acceptance policy.

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

* refactor(mobile): send the source-control domain through typed RpcOperations

13 of the domain's 14 files now send through a declared operation instead of the
raw request port: 44 references to 0. The holdout is use-mobile-git-requests.ts,
whose single reference is a `(method: string, params)` dispatcher that five other
hooks feed `{ method, params }` action steps at runtime; typing it is a step model
change, not a call-site move, so its line stays at 1.

Fifteen operations over fourteen methods. Two of them read git.status, and that is
deliberate: the Changes screen publishes the host payload verbatim while
hosted-review preparation reads the normalized projection, which returns null when
`entries` is not an array and drops entries missing a path. Sharing the projecting
reader would change what the Changes list renders, so both are named.

Four loads still read the refusal envelope before interpreting, through
readMobileGitRefusal: two degrade to a capability-missing screen, one retries a
selector that is not visible yet, and one falls back from files.openDiff to
files.open. `isMobileGitUnavailable` consults the code *and* the message and no
acceptance policy carries either through, so the alternative was parsing a code out
of a message. No new acceptance policy was added.

Every migrated site keeps two error paths where it had two: a refusal with no
message falls back to the screen's copy, a transport rejection surfaces its own
message verbatim and keeps its delivery-unknown mark. Collapsing them into one catch
is what would have turned an unknown mutation into a failed one.

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

* test(mobile): re-digest the goldens after a lint fix in the new adapter

recorderSha256 only, all 118 files; every recorded observation is byte-identical.
Re-recorded from c57de48fd0 in a separate worktree so the goldens stay attributable
to pre-migration product source — recording from this branch would have made the
parity claim circular.

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

* test(mobile): drive the reply matrix over every scripted reply, and fail closed

The matrix picked its driven request from a hardcoded prefix list and `continue`d
past any family the list did not name. That was 10 of 23 families — every one the
source-control migration added — with no red test to say so, which is why that
migration's mutation evidence came down to single hand-written scenarios.

`replyMatrixSites` now takes every completion step in a family's base scenario:
61 sites instead of 13, one golden per site, no judgement about which request is
the "real" one and nothing to edit when a domain is added. A family that scripts
no reply throws, a repeated request name throws, and a census test asserts every
family in the manifest has a matrix. A variant's downstream replies are marked
`optional` and answered only if the request is outstanding, so a diverged reply
that ends the chain records the truth instead of failing on an unsent request.

The `normal` partition replays the first fulfilled reply the family records for
that request, rather than a payload the test file invented per family. Absent and
null do not count — each is already a partition — so four sites with no other
recorded success are inventoried in REPLY_MATRIX_NORMAL_RESULT_INVENTORY with a
reason each, and an entry whose family later records a success fails.

Two partitions added: a refusal and a transport rejection with no message. That
is the axis that separates a refusal falling back to the screen's copy from a
transport drop surfacing its empty message verbatim; without it the two paths
produce the same text and collapsing them is invisible. Every source-control
family carried a hand-written `*-empty-message` scenario for exactly that.

13 hand-written scenarios the matrix now covers are deleted: 8 `*-empty-message`
cases plus sc-history-rejected, sc-commit-message-null-result, sc-eligibility-
refused, sc-create-stops-on-push-refusal and sc-base-ref-rejected. Kept, with
reasons, are the ones the matrix cannot reach: a different action or action args
(sc-review-commit-*, sc-prefill-*, sc-create-{refused,rejected}-empty-message,
sc-prerequisite-{publish,force-with-lease,skipped}), a payload shape rather than
an envelope shape (sc-review-status-entries-not-array, sc-create-existing-review),
and multi-request combinations (sc-base-ref-{unavailable,repo-fallback},
sc-reveal-timeout).

Goldens: 118 -> 153. All 92 survivors changed by their `recorderSha256` line only;
no recorded observation moved. Re-recorded from the pinned baseline in a separate
tree so the goldens stay attributable to pre-migration product source.

`matrix-hostedreview.create-intent-git.commit-1` fails on this branch, and it is
a true positive: `hostReplyErrorTextOrFallback` stringifies a non-string in-band
host error where main returned `result?.error || fallback` and passed the object
through. Left failing — the fix is a product change, documented in the README.

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

* fix(mobile): keep main's in-band commit error pass-through

The expanded reply matrix caught a real divergence the nine original partitions
missed. Main returned `result?.error || 'Commit failed'`, passing a truthy
non-string straight through under a `string` annotation; the migrated helper
stringified it to "[object Object]".

Stringifying is arguably better — downstream does `result.error.replace(...)`,
which throws on an object and merely looks ugly on a string. But this migration's
contract is that no behaviour changes, and shipping an unannounced improvement
inside a refactor is exactly what the parity evidence exists to prevent. Restores
the pass-through; the latent throw is its own ticket.

No host sends this today (`git.commit` is typed `{success, error?: string}`), but
nothing validates it and mixed client/host versions are normal.

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

* test(mobile): re-digest the goldens for the merged recorder

Main inverted two guards in family-recordings.test.ts and pilot-recordings.test.ts.
No behaviour change, but both files are inside recorderSha256, so all 153 goldens
failed the header check after the merge.

Re-recorded from 16d1ab81d3 in a separate worktree carrying main's product source
and this branch's merged recorder, so the goldens still capture main's behaviour
rather than the migration's. `baseline` moves from 7ce8e18d07 to 16d1ab81d3 because
main touched src/shared/skills*.ts, which the record guard compares; that change
moved no recorded observation. Every field except `baseline` and `recorderSha256`
is byte-identical across all 153 files.

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

* test(mobile): intern each observation entry instead of the whole field

Making the reply matrix fail closed took the goldens from 118 files / 1.53 MB to
153 / 5.35 MB, because a per-site golden replays the chain across 11 reply
partitions and every checkpoint's sender, payloads, settlements and effects
re-state the whole history that came before them. Format version 2 pooled those
fields whole, so the shared prefix was stored once per checkpoint, and once per
partition again.

Version 3 pools each entry of a list or map field instead. `golden-value-pool.ts`
declares the container per field rather than sniffing it from the value, so a
projection that changes one fails loudly instead of silently switching encodings.
153 files / 5.35 MB becomes 153 / 2.78 MB; the family that drove this,
hostedReview.create-intent, 2.0 MB over 12 sites becomes 792 KB.

This is a re-encoding, not a re-observation. Every one of the 153 goldens resolves
to the recording its version 2 file resolved to, checked field by field, and every
header field except recorderSha256 and goldenFormatVersion is byte-identical. The
three mutations this branch's coverage rests on fail exactly as before: the
gitStatusProjectionRead acceptance policy 16 (13 matrix, 3 hand-written),
interpret inside the request chain 5 (all matrix), and the rewrapped transport
rejection 4 (all matrix).

It also makes diffs smaller, which is the opposite of what version 2's note
predicted when it rejected this. Adding a timeoutMs to the first git.status of the
create-intent chain touches the same 16 goldens either way, but version 2 moves
17,100 lines / 1.03 MB and version 3 moves 3,764 / 0.20 MB, because a changed
entry no longer rewrites every field value containing it.

`readGolden` now also refuses a pool entry that does not hash to its own key, and
one no checkpoint reads. Content addressing is what makes an entry shared between
checkpoints safe to share; an unreferenced entry would be content in the file that
nothing compares.

Recorded from 16d1ab81d3 with this branch's recorder laid over it, per the
README's flow. The record fence is unchanged.

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

* test(mobile): make the matrix census and inventory checks able to fail

Three review findings, all in the recorder, none in product code.

The family census pushed every family unconditionally, so it could never differ
from the manifest keys; it now records a family only when a site generated a
test, which is independent of replyMatrixSites throwing on an empty list.
REPLY_MATRIX_NORMAL_RESULT_INVENTORY was only consulted for a live site, so a
stale entry retired silently; a new assertion fails on any entry that names no
live (family, request). Both verified by mutation: an empty site list and a
renamed inventory request each fail the suite. The value pool resolves hashes
with Object.hasOwn so a malformed golden cannot read an inherited key.

Re-recorded from 16d1ab81d3 with this recorder laid over main's product source,
per the README. All 153 goldens move on recorderSha256 only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-14 01:02:28 -04:00
Jinwoo Hong c21c083224 fix(auth): report callback failures instead of cancellation (#20535)
* fix(auth): distinguish failed sign-ins from user cancellation

* test(mobile): fix conditional registration lint and refresh recorder fingerprints
2026-09-13 22:15:15 -04:00
Jinwoo Hong 7ce8e18d07 test(mobile): consolidate the RPC migration's verification infrastructure (#20521)
* test(mobile): record main RPC hooks and regression schedules

Add scripted sender recordings, guarded main goldens, reply matrices, lifecycle schedules, settings caller fixtures, and targeted B-seed mutants.

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

* test(mobile): flush recording user actions through React act

Keep lifecycle updates in separate act boundaries while wrapping direct stateful user actions.

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

* test(mobile): compile recorded modules with the Node VM API

Use the same trusted-source execution boundary as existing mobile VM test harnesses.

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

* test(mobile): pool golden values and hoist pre-divergence checkpoints

Golden format version 2 stores each distinct observation field value once in
a `values` map keyed by a 12-hex sha256 of its sorted-key JSON, and a
checkpoint references five hashes. Output stays pretty-printed; the reader
rejects any other format version, resolves hashes back to values, and reports
the scenario, checkpoint, field and JSON path on a mismatch.

Generated variants now declare where their distinguishing input lands, so
checkpoints observed before that point are recorded once in a `.prelude`
scenario instead of once per reply partition. Reply matrices, interruption
schedules and lifecycle schedules share the primitive, which asserts each
variant's pre-divergence prefix matches the base. Equal-but-differently-reached
checkpoints are untouched.

17.71 MB / 3,599 checkpoints / 58.4% intra-file duplicates becomes
4.21 MB / 1,961 checkpoints / 23.6%, with every file's set of distinct
observations unchanged.

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

* test(mobile): make the recordings sense deadlines, the recorder, and every family

The goldens carried no temporal information, so a request deadline could be cut
to a third and all 61 files stayed byte-identical. Every threshold is now
straddled by two advances with a checkpoint between them: the 30 s request
deadline in both schedule drivers, the 120 ms search debounce in b1, and the 60 s
repo-metadata cache TTL. Shortening any of them moves an observation.

The record fence pinned product sources but excluded the whole recorder, so
--record could rewrite every golden from a modified runner and report the
baseline intact. Goldens now pin recorderSha256 over every non-markdown file in
the runner plus pilot-scenarios.json, and the fence exemption shrinks to the one
directory that digest covers.

Mutation evidence covered 3 of 13 mounted operations. There is now one anchored
mutant per adapter family, covering 11 operations and 51 of the 61 goldens; the
two omitted are the pure async loaders whose entire output is their settlement.
Anchors are asserted to match exactly one site, which caught the acceptance
mutant silently half-applying against three identical guards.

The archived-tree assertion pins each seed's visible state instead of merely
differing from main, and error observations carry code and cause when present.

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

* test(mobile): record settlement times instead of straddling deadlines

The previous commit made the reviewer's divide-by-three deadline mutant fail by
placing checkpoints on each side of the 30 s deadline. That is a patch: a timing
change that does not cross a hand-placed boundary stays invisible. Those
scenario edits are reverted, and pilot-scenarios.json and schedule-driver.ts are
byte-identical to what they were before them.

The real defect was that the projection had no temporal dimension, so every
settlement now carries startedAt and settledAt in virtual milliseconds on the
pinned fake clock. Any transition the product schedules for itself is recorded
at the time it actually fires, so a deadline or debounce change of any size, in
either direction, moves a recorded number.

A checkpoint's own clock is not recorded. It is always the sum of the scripted
advances, so it is a function of the scenario rather than of the code under
test; run-recording.ts asserts that equality at every checkpoint instead, which
costs no bytes and fails loudly if it ever drifts.

projectionVersion is 2 and all 61 goldens are re-recorded. With the added
timestamps stripped, the distinct-observation set is identical to the previous
recording, so the change is purely additive.

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

* test(mobile): probe the repo-metadata cache inside its TTL window

Recorded settlement times cover thresholds the product schedules for itself, but
not one it only consults when something else makes it act. The repo-metadata TTL
is the single such case: with probes only at 0 s and 60 s, a 20 s TTL and a 60 s
TTL are both expired at 60 s and record identically, so a 3x cache-lifetime
regression was invisible.

settings-repo-cache-expiry now probes the cache at 59 s as well. This is
coverage, not a substitute for recorded time: it bounds how small a TTL
reduction is visible rather than making the reduction itself observable, and the
README says so.

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

* test(mobile): record the reply shapes a host can send, not a cross product

The reply matrix froze ~26 malformed envelopes crossed against every consumed
field and three boundary kinds, which is 163,925 lines of JSON pinning accidents
on inputs no desktop produces. `successResponse` always sets `result`, so a JSON
wire has no explicit-undefined slot, and no mounted handler returns a number, a
string, an array, a bare `{}` or a boolean: `settings.get` returns
`{settings: ...}`, and the seed methods return an object or nothing.

Each family now runs nine witnessed partitions once, with no field cross: a
normal result, an absent result, `null`, an inner `{ok: false}` envelope with a
string or an object error, an inner envelope missing `ok`, an outer refusal,
`method_not_found`, and a transport rejection. `null` stays because
`linear.getIssue` returns it for a missing issue and b2 is a shipped null-result
bug; it is also what carries the one named delta these goldens record.

`run-step1-exit.ts` had zero callers and shelled out to the same two Vitest
files as `rpc-recording.mts`, so it and its README paragraph go, along with
`MUTATION_NAMES`, which only it read.

In the module loader, the `rpc-delivery-ambiguity` escape is measured dead: over
every scenario, mutant and reference run it was taken once, by the test that
existed to take it. Golden comparison already fails loudly if a mounted module
ever imports the marker, so both go. The history-panel exposure moves into a
declarative table beside the mutation anchors, leaving the loader with one
source-text mechanism and no per-file branch.

The VM stays. Mount adapters load product sources from an arbitrary `root`, and
the archived bcba08b3e4 tree is bare `mobile/src` and `src` with no package.json
and no node_modules, so no bundler-resolved import can reach it and the seed
rejection gate cannot run without it. Direct import also swaps a 38-module lazy
graph for a 338-module eager one behind 20 native mocks, because
`mobile-tasks-dependencies.ts` re-exports from `react-native` and four other
native packages and `export *` enumerates.

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

* fix(mobile): treat the recorded lockfile hash as provenance, not an oracle

Every golden pinned `lockfileSha256`, so any dependency bump on main failed all
61 comparisons on the merge commit while the traces were identical. A dependency
that changes behaviour changes the trace itself; one that does not must not fail
a candidate. `platform` already had this exemption — `lockfileSha256` joins it.

Recording still refuses to run unless the lockfile matches the pinned baseline,
so goldens are still produced under frozen conditions.

Verified against main's lockfile: 85 passed, previously 61 failed. The declared
mutation set still reports every mutant killed.

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

* test(rpc): cover repeat queries and settings refresh boundaries

Add three scenarios, preserve existing traces, remove unreachable archived checks, and document observed mutation kills and remaining adapter limitations.

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

* docs(rpc): keep the known-open holes, drop the review transcript

The audit file was mostly a point-in-time record of mutation runs that had already
happened, in an artifacts directory, where it would go stale on the next scenario
change. The durable part is which holes are still open and why they cannot be
reached, which belongs beside the runner it describes.

Markdown is outside recorderSha256, so no re-record; 88 passed | 3 skipped.

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

* chore(mobile): stage the nine RPC probe scenarios and goldens

These existed only on one machine's /tmp. Landing them verbatim first so a
reboot cannot lose them; a follow-up commit moves them into the suite.

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

* test(mobile): fold the nine probe scenarios into the recording oracle

The probes were env-var invocations over loose /tmp manifests. They now live in
pilot-scenarios.json and rpc-foundation/goldens, so `pnpm --dir mobile test` runs
them with no flag to remember.

Re-records every golden against main (22f56f7c2a). Two causes:

- #20280 gave LogicalClientCutoverError the delivery-unknown mark and its cause,
  so nine cutover/interruption goldens now record `isRpcDeliveryUnknown: true`
  plus a `Connection closed` cause. The other 55 are byte-identical after 260
  commits of main.
- #20499 replaced the five anchored raw-envelope reads with typed operations, so
  those mutation anchors matched zero sites. Each is re-anchored at the same
  defect's new home; bot-overrides moves to the shared reader that now owns it.

probe-hole-witness.test.ts pins hole and closure together: a probe must kill its
mutation and every pre-probe scenario of the same operation must still survive
it, so a redundant probe fails instead of accumulating.

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

* test(mobile): list the recording harness in the raw-port inventory

main's #20026 boundary test fails on any non-test file that reaches the raw
request port and is not inventoried. The oracle's scripted transport is exactly
that — it drives the real tracker and logical client — so it belongs in OWNERS
beside the supervisor fakes, not in the step-4 pending backlog.

Also states what the oracle covers, the two holes it was blind to until the
probes, and the step-4 runbook.

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

* test(mobile): pin the goldens to the tree that recorded them

The inventory entry is a fenced product-tree edit, so --record refused against
main's sha. Baseline now names the branch commit the goldens were recorded from;
the next re-record after this lands bumps it to the merge commit.

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

* test(mobile): carry a SAFETY rationale on every recorder cast

main added a changed-code casting gate after this branch was cut, so 45 `as`
sites in the recorder read as new findings. Each now states why the assertion
holds; they cluster into five reasons — recorded observations are RecordedValue
by construction, parsed manifests and goldens are validated on the next lines,
interned pools resolve their own hashes, a VM-evaluated module has no static
type, and the mount adapters supply only the members each hook reads.

Re-records the goldens: the comments move recorderSha256.

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

* docs(rpc): state the measured blindness, not the assumed one

Applying each mutation to real product source shows the two holes are not equal.
The reorder is invisible to 83 of 84 tests and only a probe sees it. The refusal
blanking is also caught by the family reply matrix, because a refusal from cold
publishes null over a non-null initial value — an observational gap, not a
detection gap. Says so rather than letting the stronger claim carry both.

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

* test(mobile): close three ways the oracle could pass without checking

All four review findings were real; three let the oracle report green while
verifying less than it claimed.

- The baseline guard used `git diff --quiet`, which ignores untracked files, so
  an untracked module under mobile/src or src/shared could change resolution
  while a golden still recorded a pinned baseline header. Adds a
  `git ls-files --others` check over the same paths, recorder still exempt.
- The determinism loop read `Number(env ?? 2)` unvalidated, so
  RPC_FOUNDATION_DETERMINISM_RUNS=0 skipped the body and 57 tests passed having
  recorded and compared nothing. Now requires an integer >= 2.
- Cleanup-time observations were dropped: every checkpoint clones the effects
  array, so anything appended during dispose or the final flush never reached a
  golden. Warns and documents the six scenarios that hit it today; recording
  them changes every golden and is its own change.
- Two SAFETY rationales described each other's assertion. Swapped.

Goldens re-recorded for the recorder-digest change: 73 files, one header line
each, no recorded observation moved.

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

* test(mobile): record teardown observations as a cleanup checkpoint

Each checkpoint clones the effects array, so a rejection or state write produced
by dispose, the transport teardown or the final flush landed after the recording
was built and never reached a golden. An unmount leak is exactly what this
oracle exists to catch, so teardown now runs on the recorded path and anything
it observes becomes a checkpoint with id `cleanup`. State is captured before
dispose, since the operation is gone afterwards.

Six scenarios were dropping observations, across five goldens: projectRowDetailError,
projectMutating, hostLabelById, hostPlatform, workspaceAgent, workspaceAgentOverridden,
creatingKey, selectedAgent, agentOverridden and error. Those five gain a cleanup
checkpoint; the other 68 goldens change by their header line only, so no existing
observation moved.

Also fixes the README's own formatting, which failed `format:check`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-13 20:56:21 -04:00