* 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 Commitadeb5f9531recorded 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 nowf741b2ea82, 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 pointb8d4cde09fsorts 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 fromf741b2ea82. 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 inf741b2ea82read 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-record71d8c6a1e2touched a fenced path (`mobile/src`), so the pin from5f3f184fdfno 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 pointb8d4cde09fstill 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 against4b0009d414, 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
18 KiB
Remote wire compatibility
Orca's remote-server feature pairs a desktop client to a remote Orca runtime, and users update the two independently. Mixed versions are the normal state, not an edge case. This page is the contract for changing anything a paired client and host exchange: the runtime RPC envelope, the terminal binary stream, and the content either side publishes over them.
src/shared/protocol-version.ts says when to bump RUNTIME_PROTOCOL_VERSION. This
page covers the changes that do not bump it and are therefore easy to get wrong.
Rule 1 — a new optional JSON field on an existing frame is safe
Every JSON payload is parsed with a decoder that ignores unknown keys (zod .strip()
on RPC params, JSON.parse on stream frames). An older peer that has never heard of
the field simply does not read it.
Safe:
// host adds a field; older clients ignore it
encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason })
The field is safe only for as long as every reader treats it as optional. The moment a newer client requires it, that client is broken against every host that predates the field — which is the same defect as removing a field, just discovered later. If new behavior depends on the field being present, that is Rule 2: negotiate it, or make the reader fall back.
Rule 2 — a new stream opcode is NOT safe; negotiate it
decodeTerminalStreamFrame returns null for an opcode it does not know, and
runtime-rpc.ts drops that frame without an error:
const frame = decodeTerminalStreamFrame(bytes)
if (!frame) {
return // silently dropped — the sender never learns
}
So a new opcode sent to an older peer does not fail loudly. It vanishes, and the feature behind it appears to hang. Input sent under a new opcode is swallowed.
A new opcode must be announced in the subscribe handshake and sent only after the
peer confirms it. The existing pattern is SetOutputPaused (opcode 16):
- the client advertises support in the
Subscribeframe'scapabilities; - the host echoes
capabilities: { outputPause: 1 }on thesubscribedevent; - the client sends opcode 16 only after that echo (
stream.supportsOutputPause); - the host only acts on opcode 16 when it negotiated it (
stream.supportsOutputPause).
Reuse an existing opcode with a new optional payload field (Rule 1) whenever that expresses the change; reach for a new opcode only when framing genuinely differs.
Opcode numbers are permanent. See the Ack = 13 and ClaimViewport = 14 comments
in src/shared/terminal-stream-protocol.ts for why a shipped number cannot be
reused even if the feature behind it is removed.
Rule 3 — changing what the host publishes breaks old clients with no wire change
The frame shape can be untouched and the skew still real, because clients react to frame content. PR #12641 is the worked example: the host stopped synthesizing a finished agent status, and clients running older code saw different content in an identical frame.
Treat these as wire changes even though nothing in the codec moves:
- a field the host stops populating (an old client reading it now sees
undefined); - a value whose meaning, units, or nullability changes;
- content the host stops synthesizing, trims, or starts deriving from a new source;
- a frame the host stops sending, or starts sending, on an existing path.
If old clients cannot interpret the new projection correctly, gate it behind a runtime capability the same way Rule 2 gates an opcode.
Rule 4 — an enum arm set is a wire surface; unknown arms must degrade, never reject
A closed z.enum in a client-side reply schema is a version claim: it asserts the host
will never send an arm this build has not heard of. A newer host that adds one arm then
has its whole reply refused, or has the row carrying it silently dropped, even though
every member the client actually reads is present and well-formed.
Declare the arm set open instead, with openEnum in src/shared/zod-salvage.ts:
// unknown arm degrades to a member the reader already handles; a non-string stays fatal
status: openEnum(GIT_BRANCH_COMPARE_STATUS, 'error')
Do not reach for .catch(). It swallows absence and the wrong type as well, which turns
a member the reader depends on into a silent default.
A fallback does not have to be an arm. git.status's area is the worked example:
staged, unstaged and untracked each grant an affordance, so coercing an unknown area
to one of them offers stage, unstage or commit against a row the client cannot place. It
degrades to absent instead, which withholds all three — every reader is an equality check
against a known arm, so the row lands in no section — while keeping the row itself. That
last part is the point. Dropping the row would also drop it from the unresolved-conflict
gate, and a conflicted worktree that looks clean is granted a hosted-review create it
should not have. Withholding an affordance is a degrade; removing the evidence a gate
reads is not.
A fallback is only ever allowed to shape a reading. If the member is sent back to the
host — a token the client echoes into a later call's params — pass it through as
z.string() and let the send site keep it verbatim. hostedReview's provider is the
case: the eligibility reply names it and the create call returns it, so an
openEnum(..., 'unsupported') there does not soften how the client reads a newer host's
provider, it puts unsupported on the wire and makes that host refuse its own. A
reply-schema fallback must never shape a param. Gate on the token instead, where the
client decides what it is willing to do with an arm it does not know.
Enforcement
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts runs the real
host RPC methods and the real renderer multiplexer from two builds against each
other — current working tree against the newest release tag, in both skew
directions — over one scripted terminal journey (subscribe, input, hide/reveal
snapshot, drop, reconnect).
Run it with:
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
It fails when a frame is refused by the receiving build's decoder (Rule 2), when the observed frame sequence changes (Rule 3), or when published snapshot content or negotiated capabilities differ from the contract. Repeated frame shapes are compared by corresponding journey occurrence (initial, reveal, reconnect), so a field removed from one occurrence cannot hide behind a sibling that still publishes it. Adding an optional field keeps the suite green (Rule 1); making a client depend on that field turns the new-client/old-host pairing red.
Never write down what the old side has
The baseline is whichever release tag is newest, so it moves on every cut. An
expectation of the form "the old side does not have X" — a not.toHaveProperty, a
not.toContain, a hard-coded field list — stops being true the first time a release
ships X. The suite then reddens on whatever pull request is in flight, with no code
change anywhere, and the job trains people to ignore it. That is worse than no test,
because a rolling baseline eventually contains every additive field the wire has, and
adding one is the sanctioned way to evolve it.
Derive the expectation from the baseline that was actually checked out:
- for a published frame, pair each build against a client of its own version and
compare the skewed pairing against that same-version reference, so the expectation
is whatever that build publishes today. Compare repeated frames by corresponding
occurrence with
comparePublishedFieldOccurrencesintests/e2e/cross-version-wire/published-field-shape.ts; never union keys across initial, reveal, and reconnect frames, because a sibling can mask one occurrence's removed field; - for a negotiated surface, read the old build's advertised capabilities and registered method names from its checkout, and assert they agree with each other rather than asserting the old build lacks them;
- for a "client too old to know X", derive that client's advertised list by removing X from the baseline's own list, so the gate stays exercised after X ships.
Name the direction in the assertion. new client against old server and old client against new server fail for different reasons, and the host is the only side that
authors a published frame — the terminal terminalOwner false positive on 2026-08-29
was misread as a new client sending an unknown field when the old server was
publishing it. Two things are still safe to state literally: the current build's own
contract, and an invariant that holds for every version.
Pinning a legacy ref is the fallback when a contract genuinely needs a release from
before a feature shipped, as cross-version-browser-placement.unit.test.ts does with
LEGACY_BROWSER_PLACEMENT_RELEASE_REF. It does not rot on a cut, but it is
hand-maintained, so prefer deriving.
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts pairs the
same two builds over the structured agentSession.* surface. Because a released build
cannot name a capability string its own source never contains, the old side's advertised
list and registered method names are read from the extracted checkout rather than
hand-written. It covers the three skews that surface can fail on:
- an old client — advertising the baseline's list minus this capability — is told the whole surface does not exist and reaches no host method;
- a new client against the old dispatcher always gets an answer rather than silence,
and
method_not_foundfor every method that release does not register, so the absence is visible during negotiation instead of by calling; - a cursor survives a host restart: the client's fence is refused as stale with the live one attached, and resuming from the held cursor replays only what it missed.
Run it with:
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
The harness covers the terminal stream and the structured agent-session surface. It does not cover the session-tab sync channel, legacy agent-session publications, file or Git RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its own reasoning against the three rules above.
Worked example: agentWait on terminal and worker reads
terminal.show, orchestration.workerShow and orchestration.federationShow carry an
optional agentWait naming a pane parked on a prompt only a human can answer. It is Rule 1 —
a new optional field — but it has a second state that Rule 1 alone does not describe, and
getting that wrong turns a skew into a false "nothing is blocked".
- present object — this pane is waiting, with the evidence that proved it.
- present
null— the host evaluated this pane and nothing proves a wait. - absent — the host never evaluated it: it predates the field, the worker identity was unverifiable, the pane was unreadable, or the agent probe did not answer in time.
A new client against an old host sees the field absent, which is why absence must read as
unknown and never as not waiting. Collapsing absent into null at any hop — including a
convenience ?? null in an RPC handler — makes an old or unreachable peer indistinguishable
from a healthy idle worker, which is the exact failure the field exists to remove.
An old client against a new host ignores the key, as Rule 1 allows. New members added to
RuntimeTerminalWaitBlockedReason are also Rule 1: no consumer switches exhaustively on it,
and both the CLI and worker-start interpolate it as an opaque string.
Worked example: the turn journal item and its transitional downgrade
The structured chat journal records a turn as a first-class item,
{ kind: 'turn', turnId, state, userItemId?, startedAt?, completedAt?, durationMs? }, where it
used to write { kind: 'status', text, turnLifecycle }. Nothing in the codec moves, but it is
Rule 3: a client that predates the item does not know the kind and renders it as a text bubble
with no text. So the item is gated on a client capability, agent-session.turn-item.v1.
The gate lives at the RPC boundary only, in
src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts, composed
around agentSession.history and agentSession.subscribe next to the background-task
projection. A client that does not advertise the capability receives every turn item
rewritten to the legacy status form with the full lifecycle under turnLifecycle; a client that
advertises it, and any in-process caller, receives the canonical body. The journal, the status
feed, and every host-side reader keep the turn item; readAgentJournalTurn in
src/shared/agent-session-turn-record.ts reads either form, so a new client against an old
host that still writes the status row also works.
An old client against a new host sees the status row it always did. A new client against an old host advertises a capability the host ignores and reads the status row through the shared reader. The downgrade is transitional: once no supported release lacks the capability, delete the projection module and the capability check, and leave the reader.
The cross-version suite derives the old client's list by removing this capability from the baseline's own list, per the rule above, so the downgrade stays exercised after a release ships it.
Known debt: JSON-RPC errors drop Node's string code
An error raised on an SSH host crosses the relay as JSON-RPC, and
ssh-channel-multiplexer rebuilds it with the TRANSPORT's numeric code. Node's
string code — 'ENOENT', 'EACCES' — does not survive, so a caller on this side
cannot ask what kind of failure it was.
isENOENT in src/main/ipc/filesystem-path-containment.ts pays for that by also
matching Node's canonical message text, which is what makes remote worktree creation
work. The cost is that a host can make an unrelated failure read as "absent" by
putting that sentence in a message.
The exit is Rule 1: carry the original string code in a new optional field on the
error payload and read that instead. An old host omits it and the message match still
covers them; once hosts that send it are the floor, the message match can be deleted
rather than lived with at its ~10 call sites. Narrowing isENOENT back to .code
without doing this reinstates the bug — the transport has already overwritten it.
Known hazard: clients ignore host-published failure fields on client-placed pages
RuntimeMobileSessionBrowserTab — the browser tab a host publishes on the session-tab sync
channel — permits placement, loadError and certificateFailure together. But for a tab
whose placement.kind is 'client' the engine runs in the client's own app: the failure is
raised by the local guest webview, and the host has no view of it (RuntimeBrowserClientPage,
what the registry actually publishes from, carries neither field). Clients from
this version on therefore refuse host ownership of both records for client-placed pages
(web-session-tabs-sync.ts, the placement?.kind !== 'client' carve-outs) — without that,
each metadata snapshot deletes the locally recorded failure and the page's failure overlay
disappears mid-navigation.
The hazard is forward-facing and Rule 3 shaped. A host that later starts publishing
loadError or certificateFailure for a client-placed page reaches these clients as content
they silently drop, so the host would see no error and no effect. Publishing it has to be
capability-gated, with the carve-out narrowed to clients that did not negotiate the
capability. Note the cross-version harness does not exercise the session-tab sync channel, so
nothing fails if this is forgotten — this note is the only record.
A related carve-out covers title, url, loading, canGoBack and canGoForward
(resolveMirroredBrowserPageContent), and for those the hazard is already live rather than
forward-facing: the host does publish them, from a RuntimeBrowserClientPage it can only learn
about second-hand through the client's own browser.clientHost.pageMetadata calls. Its copy
therefore starts at the registry defaults ('Browser', the create-time url), and while those
publishes are failing it never leaves them.
That copy is not simply behind, though, and a client must not treat it as such. When a lease
reattaches, the host refreshes the page from the client host's own inventory
(runtime-browser-client-page-recovery.ts), which reads the live guest — so it can be strictly
fresher than a local row whose pane is unmounted and whose metadata publisher was disposed with
it. A client that ignores the host url is relying on its own guest to re-answer on remount,
which ClientHostedBrowserPagePane's mount-time syncNavigation is what makes true.
These five are therefore refused only by the client whose guest actually runs the page:
placement.browserHostClientId is compared against this client's own host id
(readBrowserClientHostId). Main stamps that id into the guest-hosting window's
additionalArguments at creation, and the preload reads it back out of its own argv — the answer
has to be there before the first snapshot is interpreted, which is earlier than any IPC handler a
renderer could wait on. Every other viewer — a second desktop, the web client, which installs no
page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking
the host, which is the only reason a mirrored viewer shows anything but its first snapshot
forever. Improving what a second client sees still means fixing the publish, not the carve-out;
the carve-out no longer stands in the way of it.
The two failure fields above are deliberately left on the looser placement?.kind !== 'client'
predicate. It is unobservable today — the host publishes neither field for a client-placed page at
all, so a mirror has nothing to take either way. If the capability-gated publish this section
anticipates ever lands, narrow them the same way rather than by placement kind: a mirror should
take a failure it cannot otherwise see, and only the hosting client should refuse it.