2026-07-11
July 11, 2026 · View on GitHub
Outcome
The CLI↔Core socket protocol (Agent API, schemaVersion 1) now has a single
typed home: core/src/protocol/ (envelope + 17 per-command args structs +
SocketCommand name-binding trait + ErrorTag), consumed by both Core's
socket_listener handlers and a new CLI SocketClient
(cli/src/client.rs) over a string-line Transport seam
(cli/src/transport.rs is now JSON-blind). Wire shapes are unchanged;
response bytes are pinned by golden tests recorded against the
pre-migration code.
Motivating friction (2026-07-11 architecture review, candidate 1): the
command surface was hand-restated in three places (clap args, CLI json!
literals, Core serde structs) with silent drift — #[serde(default)]
turns a misnamed field into a dropped argument, not an error — and the
response envelope was hand-parsed in four spots that had already diverged
(session_watch swallowed malformed frames; read_watch_frame errored).
Decisions (grilling 2026-07-11)
- Protocol lives as a module in the core crate, not a separate
galley-protocolcrate. The CLI already depends ongalley-core; a crate adds ceremony with no isolation gain. Revisit only when a third consumer appears. - Full command surface — every dispatch arm's args type comes from
protocol/(or sharedcrate::apitypes:sessions.listparsesSessionFilter). Partial coverage would recreate "two places to look".session.restore/session.shutdown_runnergot their own structs (same shape as archive/stop) becauseSocketCommand::NAMEbinds name↔type 1-to-1. - Envelopes are protocol types too (
SocketRequest,SocketResponse,StreamEnvelope,WatchFrame).StreamEnvelopestays Serialize-only; the consumer side isWatchFrame::parse, keptValue-based so unknown future frame kinds pass through as events (frozen lenient behavior). - Type-level name binding:
call<C: SocketCommand>(args: C)— a call site cannot pair a command name with the wrong args. Results stayValue: the CLI printsresultverbatim; a typed-result round-trip would silently drop additively-added server fields, violating the schemaVersion 1 evolution rule. - Transport seam at the string-line level (
round_trip(String) → String,open_stream(String) → WatchLines). Fakes replay canned lines, including malformed ones — which is exactly what a Value-level seam could not simulate. - Bad-frame policy stays per-caller (ADR-0002 spirit): one parser
(
WatchFrame::parse) with an explicitUnparseable(raw)variant;session watchpasses raw lines through and continues (frozen agent contract), programmatic consumers (session follow, project follow) treat it as an internal error vianext_watch_frame_strict. ErrorTagenum shared by both ends,Other(String)for runtime forward compat. The CLI'sgalley_error_for_tagmatch is exhaustive with no_arm — a new Core tag now fails CLI compilation until mapped, instead of silently landing in the exit-1 bucket. Exit-code collapse for transport tags (schema_mismatch / unknown_command / app_unavailable / idle_timeout → exit 1) is unchanged and remains documented in stability-and-versioning.md §2A.
Wire-compatibility notes
- Response/stream bytes: identical. Pinned by
protocol/envelope.rsgolden tests, recorded against the pre-migrationwire.rsand kept green through the migration. - Request bytes: semantically identical, not byte-identical. The old
json!-built requests serializedValuemaps (alphabetical key order); structs serialize in declaration order. JSON object key order is not part of any contract and Core parses both identically. Field names are pinned byValue-level legacy-equivalence tests inprotocol/commands.rs(one per command). Option fields still serialize as explicitnull(noskip_serializing_if), matching legacy shape. - One degenerate-input behavior improved: a response missing
okused to surface asInternalwith an empty message; it now surfaces asInternalwith the error-envelope default tag path (#[serde(default)] ok: bool) — same exit class, clearer message.
Verification
- Golden snapshots recorded and green BEFORE migration, green after.
- 17 legacy-equivalence tests (args struct ⇔ old
json!literal). - 5 CLI fake-transport tests (success passthrough incl. unknown server
fields, tag→exit-class mapping incl.
Other, malformed response, missing-ok, strict/lenient frame policies). cargo fmt+cargo check+cargo testin both crates: core 187 lib + 118 integration, cli 95 — all green.git diff --checkclean. No GUI surface touched.
Follow-ups
cli/tests/m1_writes.rsstill only covers the "core not running" exit-4 path end-to-end; with theTransportseam, happy-path coverage per command is now cheap to add if wanted.- The 2026-07-11 review's candidate 2 (inject DB/notifier seams into Core's socket write handlers) composes with this: together they make the CLI→Core write path testable end-to-end without a live Tauri app.