ADR 0027 — Transport-neutral contract execution (bring-your-own transport)
- Status: Accepted — upholds ADR 0008 and ADR 0020; keeps the core generic per ADR 0002
- Date: 2026-06-05
Context
A contract drives the four built-in transports (HTTP, MCP, agent tools, CLI). But
some apps have a transport stitchkit does not own and should not own — a desktop
app whose UI webview talks to its own local Bun sidecar over a raw WebSocket, an
IPC channel, a queue worker. Today such a transport is written from scratch: a
hand-rolled method registry (one map entry per method, re-using the app’s Zod
schemas), a client, a server, and bespoke error shapes — the very duplication
defineContract removes for the built-in transports. One consuming project
reported ~1000 lines of this, parallel to its stitchkit contracts, because three
things were missing: a way to execute a contract method off a non-built-in
transport, an idempotency hint to drive replay-on-reconnect, and retained
(“sticky”) events so a late subscriber catches up.
The execution core already exists internally: executeToolMethod slices the flat
args, validates params/input, runs the handler, validates the output, and
returns a typed { ok, data } | { ok: false, code, details, hint } envelope. It
is what mountMcp / mountAgent run — transport-neutral, but not exported.
The tension is ADR 0008 (“never ship a competing WebSocket engine — wrap what you
use”). Shipping a full reliable-RPC-over-raw-WebSocket engine (framing,
handshake, reconnect, a pending-call map, replay policy) would be a competing
engine. ADR 0020 already drew the line for the raw-WebSocket lane: stitchkit ships
the composition primitive, the raw socket itself is the app’s Bun.serve
WebSocket. This ADR draws the same line for RPC.
Decision
Expose the existing execution core and the two metadata/utility pieces a bring-your-own (BYO) transport needs — not a transport engine.
createContractDispatcher(services, { source, … })(stitchkit/tools) — builds a{ dispatch(method, args, context?) }over one or moreimplement()services.dispatchruns a method by its contract key through the sameexecuteToolMethodthe MCP/agent mounts use, returning the identical result envelope (validation, typed errorcode,beforeToolCall/afterToolCallhooks, abeforeHandlescope gate). An unknown method is aNOT_FOUNDresult. The app keeps its own wire (framing, handshake, reconnect) and callsdispatchper inbound frame. Exposure is the transport’s choice (which services it dispatches), not the contract’sexpose(which gates the built-in transports).TransportSourceis an open union —'http' | 'mcp' | 'agent' | 'cli' | (string & {}). The four built-ins keep autocomplete; a BYO transport tags its own calls (source: 'local-ws').sourcestays transport-only with no framework behaviour (ADR 0002).idempotent?: booleanon the contract rides through toMethodDef.idempotent. The core attaches no behaviour — it is a generic, transport-neutral property of an operation (like HTTPPUT/DELETE). A retrying transport reads it: replay an idempotent call after a reconnect (the durability guarantee), reject a non-idempotent one rather than re-send it (a duplicate would be a second side effect). Unset = “unknown” = treat as non-idempotent.createRetainedTopics()(stitchkit, browser-safe) — a transport-agnostic retained-last-value store for “sticky events”: a late subscriber is replayed the last payload per topic (MQTT retained /BehaviorSubject).createSocketIOClientgains aretainoption that uses it, and a BYO lane can use it directly.
Alternatives considered
- Ship a
localWsLanereliable-RPC engine (raw-WS framing + secret handshake- reconnect + replay + a generated client/server). Rejected for now: it is a competing engine (ADR 0008) and a large, durability-sensitive surface to keep stable pre-1.0. The dispatcher removes the bulk of a BYO transport’s duplication (the registry, the error typing, the validation) while the app keeps the ~200 lines of its own wire. If the wire itself proves a recurring need across multiple desktop consumers, that is a future ADR — built on evidence, not on one case.
- Export
executeToolMethodraw. Rejected: it is named for tools and takes a resolvedMethodDef; the dispatcher is the contract-shaped, neutrally-named surface (resolve-by-key, multi-service,NOT_FOUND) a transport actually wants. replay: 'safe' | 'never'on the contract (the consumer’s framing). Rejected in favour ofidempotent: boolean— idempotency is the underlying, transport-neutral property; “replay on reconnect” is one policy a transport derives from it. Naming the property, not the policy, keeps the core generic.
Consequences
- Not a competing engine (upholds ADR 0008 / ADR 0020). stitchkit ships the executor, the hint and the sticky helper; the transport is the app’s.
- One mental model. A BYO transport runs a
defineContractlike every other surface — same validation, same error envelope, same hooks. - Generic core (ADR 0002).
sourceis a free tag;idempotentcarries no built-in behaviour; the dispatcher imposes no domain model. - Cast-free. No new
as;createRetainedTopicsis typed viaPartial<Events>and the socketretainwiring widens the (any-typed) listener by plain assignment, as the existing client already does. - Additive. New exports plus an opened
TransportSourceunion and an optionalidempotentfield — no existing call breaks.