Files
callstack__agent-device/test/wire-compat
Michał Pierzchała 05a1d76f2e test: add daemon RPC wire-surface compatibility gate (#1717)
* test: gate daemon RPC wire compatibility against the last released tag (#1432)

ADR 0006 fixes exactly when DAEMON_RPC_PROTOCOL_VERSION must be bumped, and
nothing checked that it was. The runtime guard (readRemoteDaemonHealth) refuses
a mismatched peer, but only fires when someone remembered the bump — a wire
change that skipped it left both sides advertising protocol 2 while parsing
different payloads, which is the failure ADR 0006 exists to prevent.

Local daemons cannot skew (isReusableDaemonInfo takes over on any package
version mismatch). Cross-machine is skewed by design — proxy, cloud/limrun, a
remote macOS host — and ADR 0006 explicitly rules package version out as the
compatibility gate there, so the one boundary where skew is intended was the
one boundary with no gate.

test/wire-compat/surface.ts declares the wire surface grouped by the ADR bullet
each group serves, quoting it, with an `uncovered` note where a bullet is only
partly digestible (the /health and /rpc literals inside http-server.ts stay
reviewer-owned: a moved route 404s at connect time rather than misparsing).
ledger.json records what each declaration hashes to, at which protocol version.

Two gates, split for the same reason the replay-compat corpus splits:
- unit-core holds the ledger to its source and prints the digest to paste;
- Released-Surface Compatibility reads the ledger at the last RELEASED tag and
  requires the drift since then to carry a bump or a compatibleChanges ack.

From one commit a bumped ledger and an unbumped one are both just an edited
file, so only a released baseline can tell them apart. Acks are keyed by the
digest they cover, so one "added an optional field" cannot launder later
changes. Digests ignore comments and formatting; the manifest's closure is
derived from the AST, so a field typed by an unlisted sibling fails rather than
sitting outside the gate.

CI cost: one added job (checkout + toolchain + two node scripts, ~1 min),
mirroring the existing full-history replay-compat job.

* test: close wire-surface overclaim and make the closure fail closed (#1432)

Addresses both review P1s on #1717.

P1 — the manifest materially overclaimed ADR 0006 coverage. It quoted all four
bullets while digesting only the payload TYPES, so the producer and consumer
seams could break a skewed peer without moving a listed digest. Now listed on
both sides of every boundary: JSON-RPC method sets and the projections that
turn each method's params into a DaemonRequest, createRpcError/sendJson/
writeRpcResponseEnvelope, resolveToken and the auth-hook types, upload
preflight/finalize/308 handlers and the resumable ticket shape, artifact route
and download/inventory framing, REST error mapping, and the client's own
payload builder, lease-method mapping, response parser and error projection.
57 -> 117 declarations.

What stays out is now named rather than implied: createDaemonHttpServer's
dispatch wiring and the /health and /rpc literals inside it. Everything it
dispatches WITH is digested individually, and a moved route 404s at connect
time rather than misparsing — the loud failure, not the silent one.

P1 — imported and re-exported payload shapes escaped the closure.
declarationHomes() scanned only the manifest's own files and the walk
continued silently when a name could not be placed, so a listed type could
gain foo?: ImportedShape from a new module and stay green. Resolution is now
explicit and fails closed: relative imports, workspace specifiers (through the
owning package's own exports map, so a re-pointed export cannot drop a type),
and facade re-export chains. Every referenced name must land on a listed
declaration, a waiver with a written reason, a declared external module, or the
TS/Node global set. Fixed two extractor blind spots the walk exposed: a
declaration's own generic parameters and `as const` were being reported as
references.

Planted-red proofs (wire-mutations.test.ts): 13 cases independently mutate
method naming, response serialization, response parsing, auth projection,
upload ticket shape, 308 framing, artifact framing, REST error mapping, and
progress framing, each asserting the digest moves; 3 probes prove the closure
really reaches across a package boundary, a facade re-export, and a plain
relative import. Mutations apply inside the declaration's own span — a
whole-file replace silently hit a sibling sharing the substring, which is how
the first draft of one case passed vacuously.

The largest waiver pair (InternalRequestOptions, CommandFlags) rests on ADR
0006's own additive rule: they reach the peer inside DaemonRequest's untyped
flags/input bags, and the decision says a new flag needs no bump. Digesting
them would fire the gate on every new CLI flag and train reviewers to
rubber-stamp acks.

* test: list the consumer half of the auxiliary HTTP boundaries (#1432)

Addresses the remaining review P1 on #1717. The manifest claimed both sides of
response/upload/artifact framing while listing nothing from upload-client.ts,
daemon-artifacts.ts, or the health consumer in daemon-client-transport.ts, so
those parsers could narrow without moving a listed digest or protocol 2.

Now listed (117 -> 141 declarations):

- /health consumer: RemoteDaemonHealth, readHealthPayload, readDaemonHttpHealth,
  readRemoteDaemonHealth. This is the sharpest of the three — narrowing the
  reader or the comparison disables the very refusal ADR 0006 exists to
  guarantee, and nothing else in the repo would notice.
- /upload consumer: UploadResponse, UploadPreflightResponse, UploadPreflightResult,
  parseUploadPreflightResult, requestUploadPreflight, uploadDirectArtifact,
  tryDirectUploadWithResume, shouldRetryDirectUpload, finalizeDirectUpload,
  uploadLegacyArtifact, ARTIFACT_HASH_ALGORITHM, isStringRecord, and
  PreparedUploadArtifact — whose sha256/sizeBytes/fileName/artifactType/
  contentType fields ARE the preflight body the daemon parses.
- /artifacts/* consumer: DaemonArtifactEndpoint, buildDaemonArtifactUrl,
  isRemoteDaemon, DownloadRemoteArtifactParams, downloadRemoteArtifact,
  materializeRemoteArtifacts, resolveMaterializedArtifactPath.

Running the closure fail-closed over the new files surfaced three more stops,
each decided rather than skipped: PreparedUploadArtifact listed (it is payload),
UploadProgressSink waived (client-local rendering, never leaves the process),
and src/daemon/types.ts#DaemonArtifact waived as a re-export alias of the listed
kernel type, matching its DaemonRequest/DaemonResponse siblings.

10 more planted-red mutations cover the new seams: health version-read and
mismatch-refusal defeated, RemoteDaemonHealth field dropped, preflight parser
narrowed, preflight/legacy response shapes narrowed, finalize body key renamed,
ticket field renamed, artifact tenant header dropped, artifact URL moved. A
fourth closure probe proves the upload-consumer files are genuinely reached by
the walk rather than merely listed. 22 -> 33 tests.

The README now states the coverage as a producer/consumer table per boundary,
so the claim is checkable at a glance instead of asserted in prose.

* test: list the client half of the resumable 308 contract (#1432)

Addresses the third review P1 on #1717. Listing the daemon's
handleResumableUpload proved it still PRODUCES 308; nothing proved the client
still CONSUMES the released one. src/remote/upload-stream.ts owns that half and
was entirely outside the manifest, so a newer client could stop accepting
`upload-offset`, change how it reads `Range: bytes=0-N`, or emit a different
resumed `Content-Range` without moving one of the 141 listed digests.

Now listed (141 -> 151): UploadStreamResponse, streamFileToHttpRequest,
streamFileToHttpRequestAttempt, buildUploadRequestHeaders, isUploadResumeStatus,
isUploadRedirectStatus, parseUploadResumeOffset, parseNonNegativeIntegerHeader,
firstHeaderValue, MAX_UPLOAD_REDIRECTS.

streamFileToHttpRequestAttempt is listed despite its size, unlike
createDaemonHttpServer which stays in `uncovered`. The distinction is stated at
the declaration: the HTTP server only dispatches to handlers that are each
digested, while the attempt loop IS the resume state machine — it decides
whether a 308 continues the upload and what the next request carries, so its
sequencing alone can break a released daemon while every helper keeps its digest.

6 new planted-red mutations prove the client half moves the ledger: a dropped
`upload-offset` fallback, narrowed Range parsing, a changed resumed
Content-Range, 308 no longer treated as continue, a narrowed UploadStreamResponse,
and dropped header-value coercion. 33 -> 39 tests.

Closure fail-closed surfaced two more stops: UploadStreamProgressOptions waived
(local byte-progress rendering) and URL/URLSearchParams added to the global set.

README now carries a `/upload` resume row in the producer/consumer table, and
names the pattern behind three rounds of review: the coverage sentence kept
getting written ahead of the coverage, so the table and the `uncovered` notes
are the claims to trust — they are checkable against surface.ts, prose is not.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 20:52:29 +02:00
..

Daemon RPC wire ledger

ADR 0006 says exactly when DAEMON_RPC_PROTOCOL_VERSION must be bumped. Until #1432 nothing checked that it was.

The runtime guard already refuses a mismatched peer — readRemoteDaemonHealth compares /health's rpcProtocolVersion before sending any RPC. But it only fires when someone remembered the bump. A wire change that skipped it left both sides advertising protocol 2 while parsing different payloads, which is the failure ADR 0006 was written to prevent.

This directory closes that: surface.ts declares which declarations cross the boundary, ledger.json records what they hashed to and at which protocol version, and two gates hold the ledger honest from opposite sides.

Why this boundary and not the local daemon

A local daemon cannot skew. isReusableDaemonInfo (src/daemon/client/daemon-client-lifecycle.ts) takes over any daemon whose package version differs from the client's, so the two are always the same build.

Cross-machine is the opposite by design. ADR 0006: "a 0.15 client and 0.17 remote daemon can often interoperate" — agent-device proxy, cloud/limrun providers, and a remote macOS host all run skewed on purpose, and package version is explicitly not the compatibility gate there. The one place version skew is designed in was the one place with no gate.

The three gates

Runs Answers
wire-compat.test.ts (unit-core) every PR, offline, shallow-safe does the ledger still match the source it describes, and is the manifest closed?
wire-mutations.test.ts (unit-core) every PR does the gate actually catch each class of wire break?
pnpm check:daemon-wire-compat (Released-Surface Compatibility) own fetch-depth: 0 job did the drift since the last released tag come with a bump or an ack?

The mutation lane exists because the first two answer "the manifest describes today's source", never "the manifest describes the right source". A gate can list 151 declarations, pass every check, and still miss the seam that breaks a skewed peer — which is exactly what review found in the first version of this directory. Each case there mutates one real listed declaration the way a real break would (method renamed, envelope unframed, auth header dropped, upload ticket field renamed, 308 downgraded, artifact route narrowed, health-version check defeated, 308 resume contract broken on the client side) and asserts the digest moves. Two guards keep them honest: the mutation is applied inside the declaration's own span so it cannot silently hit a sibling that shares the substring, and the unmutated digest must equal the ledger's so the case is pinned to the declaration the gate really watches.

The split is forced, not stylistic. From a single commit a bumped ledger and an unbumped one are both just an edited file, so only a baseline read out of git can tell them apart — the same reason the replay-compat corpus splits its provenance verifier out of the unit lane.

Baseline is the released tag, never arbitrary git history. An unreleased shape has no peer in the wild to be incompatible with (AGENTS.md, "Unreleased API surface dies free"), so mid-branch churn is free and only the net change since publication has to be justified.

When the gate fails

It names the declaration and prints the digest to paste. Decide which ADR 0006 case you are in:

Breaking — a peer on the previous protocol would misinterpret the payload rather than fail clearly. Removing wire surface is always this case; an ack cannot cover a removal, because a released peer can still send it.

  1. Bump DAEMON_RPC_PROTOCOL_VERSION (src/daemon/http-health.ts).
  2. Set ledger.json's protocolVersion to match, and paste the new digests.
  3. ADR 0006 also wants a remote-client regression test proving mismatched protocols fail before command RPC.

Additive — ADR 0006's own list: a new optional request field an older daemon can ignore or reject with a normal command error, a new optional response field an older client can ignore.

  1. Paste the new digest into ledger.json.
  2. Add a compatibleChanges entry naming the declaration, carrying that same digest, and saying why the older peer still parses correctly.

An ack is keyed by the digest it covers so it expires: the next change to the same declaration produces a different digest and needs its own rationale. One "added an optional field" ack cannot launder every later change. Stale acks fail the unit lane — drop them; git history is the audit trail, the ledger is the gate.

There is deliberately no regenerate script. A stale entry is fixed by pasting the one digest the failure prints, so every ledger line in a diff is a wire declaration someone decided to change — the same reason fallow-baselines are not bulk-refreshed.

What is covered, and what is not

surface.ts groups declarations by the ADR 0006 bullet each serves, quoting the bullet, so the manifest can be checked against the decision rather than against someone's summary of it. Where a bullet is only partly digestible the group carries an uncovered note saying which part is reviewer-owned and why — a gate that implies coverage it does not provide is worse than one that admits the gap (AGENTS.md, "a registry claim is not a semantic check").

Both sides of every boundary are listed — four boundaries, producer and consumer each:

Boundary Producer Consumer
/rpc method sets, request projections, createRpcError, envelope serializers buildHttpRpcPayload, lease-method mapping, parseDaemonHttpResponseBody, toDaemonHttpRpcError
/health DaemonHealthPayload, buildDaemonHealthPayload RemoteDaemonHealth, readHealthPayload, readRemoteDaemonHealth
/upload route resolver, preflight/finalize/308 handlers, body parsers UploadPreflightResponse, parseUploadPreflightResult, direct/legacy/finalize senders, PreparedUploadArtifact
/upload resume (308) handleResumableUpload emits 308 + offset headers isUploadResumeStatus, parseUploadResumeOffset, buildUploadRequestHeaders, streamFileToHttpRequestAttempt
/artifacts/* route resolver, inventory and download framing buildDaemonArtifactUrl, downloadRemoteArtifact, materialization

A client-only change breaks an older daemon just as surely as the reverse. Two consumers are the sharpest cases: narrowing readHealthPayload or readRemoteDaemonHealth disables the very refusal ADR 0006 was written to guarantee, and narrowing parseUploadResumeOffset or buildUploadRequestHeaders breaks resume against a daemon still emitting the released 308 contract.

That took three review rounds, and the pattern is worth naming: each round the coverage sentence was written ahead of the coverage. Round one digested only payload types; round two added producers but left the auxiliary consumers out; round three still had the daemon producing 308 with nothing proving the client still consumes it. Prefer the table above and the uncovered notes over any prose claim — those are checkable against surface.ts, and a sentence is not.

The one remaining gap: createDaemonHttpServer, the 200-line dispatcher, and the /health and /rpc path literals inside it. Everything it dispatches with is digested individually, so what is uncovered is the wiring — and its failure mode is the loud one, a 404 at connect time before any payload is exchanged. Everything digested here can misparse silently, which is the whole point.

Digests ignore comments and formatting, so reflowing a type or rewriting the prose above a field does not move them; only the declaration's tokens do.

The closure, and why it fails closed

wire-compat.test.ts walks each listed declaration's AST and resolves every type name it references — through relative imports, workspace specifiers (via the owning package's own exports map), and façade re-export chains. Every name must land somewhere someone wrote down:

  • a listed declaration, or
  • a waiver in closure-policy.ts — a repo declaration that is genuinely not payload, with the reason it cannot change what a peer parses, or
  • a declared external module (node:http, undici) with no declaration site here, or
  • the TypeScript/Node global set.

Anything else fails. The earlier version skipped names it could not place, which made the whole claim hollow: a listed type could grow foo?: ImportedShape from an unlisted module and stay green. Three probes in wire-mutations.test.ts prove the walk really reaches across each boundary form — drop a listed declaration from the claimed set and the closure must report it.

Challenge the waivers first when reviewing this directory; they are where coverage is traded away. The largest pair (InternalRequestOptions, CommandFlags) rests on ADR 0006's own additive rule: those reach the peer inside DaemonRequest's untyped flags/input bags, and the decision says a new flag needs no bump. If a flag ever becomes a typed field, list the type instead of widening the waiver.

Adding to the surface

Put the declaration in the group whose ADR 0006 bullet it serves and paste its digest from the failure message. If it introduces a new break class, add a case to wire-mutations.test.ts — a listed declaration with no proof that mutating it fails is a claim, not a gate.