E2 — The adapter reaches E1 (design spec)¶
Date: 2026-08-16 Phase: E2 (current) Status: accepted — implementation follows in the companion plan
Goal¶
/implement CONTRACT in the TypeScript adapter reaches the same refusal as
E1’s check: the adapter starts the engine executable as a subprocess,
sends one versioned JSON request, reads one JSON response, and converts
every transport failure into a named refusal — on POSIX and Windows. This
is the architecture gate: it proves the one-process-per-operation design
from BRIEF.md.
The engine side gains a protocol subcommand that serves the same
accept/refuse verdicts as check over stdin/stdout JSON. The default test
tier stays process-free; process behavior lives in the integration tier,
which gains its first tests here.
Non-goals (explicitly out of E2)¶
Delivery, mutation, receipts, worktrees, packaging, the model, and the
engine outside a source checkout. /implement reports a verdict and
stops; it does not create a candidate (E3), does not run a model (E5), and
does not need to find the engine without SATYRN_ENGINE_REPO (E6).
deliver/attempt subcommands do not exist yet. The guards
(packages/engine/engine.ts) are untouched — they stay TypeScript and out
of the roadmap.
Decisions from brainstorming¶
The engine-side protocol surface¶
A new subcommand, satyrn-engine protocol, reads one JSON object from
stdin (all of stdin, whitespace tolerated, nothing after the object),
writes one JSON object to stdout, and exits. It is a one-shot request
surface, not a server: one process per operation, per BRIEF.md.
Request:
{"version": 1, "operation": "check", "repo": "/abs/path", "contract": "/abs/path/contract.yaml"}
Response on acceptance:
{"version": 1, "ok": true, "code": "OK", "message": ""}
Response on refusal (engine side):
{"version": 1, "ok": false, "code": "CONTRACT_UNREADABLE", "message": "cannot read contract ..."}
The verdict travels in the JSON, on stdout. stderr stays empty on every protocol path — the protocol surface is not a human surface, so it does not print the
satyrn-engine: <CAUSE>: <detail>line; the adapter prints that in the Pi session.The process exit code mirrors the verdict (
0for OK,3–7for refusals), so the adapter can fall back to it when the response is missing or unparseable.uvpropagates the child’s exit code (verified:uv run --project . python -c "import sys; sys.exit(5)"exits 5).The request carries paths, not contract content. The engine owns contract reading, parsing, and validation (data-over-code); the adapter resolves
CONTRACTandrepoto absolute paths againstctx.cwd.The protocol handler calls the same
check()seam E1 exposed, so the order of checks (contract first, then repo) is inherited, not re-derived.
Request validation and a new exit code¶
A malformed request — not valid JSON, a top level that is not a mapping, a
missing or wrong-typed version/operation/repo/contract, an unknown
operation, or an unsupported version — is refused as a new ExitCode
member, INVALID_REQUEST = 7. The response names the problem in
message. Unknown extra request fields are ignored, matching the
contract’s own forward-compatibility rule.
This deliberately changes the E1 pinned table: the test
test_exit_codes_are_distinct_and_stable asserts [0, 2, 3, 4, 5, 6] and
must become [0, 2, 3, 4, 5, 6, 7]. One member is added; nothing is
renumbered; 1 stays reserved for crashes (a crash is not a refusal).
Transport failures are not engine exit codes. They happen around the
process — uv missing, a spawn error, a timeout, a malformed response —
and the adapter owns their names (below). The engine has nothing to say
about a process it never became.
The adapter (packages/engine/orchestrator.ts)¶
package.json already declares "./orchestrator.ts" as an extension;
this phase creates the file that reference points at. The adapter:
Registers a Pi command
/implementtaking one positional argument,CONTRACT. The repo isctx.cwd— the roadmap’s/implement CONTRACTshape, and the tree a later phase would apply a change to.Starts the engine with
uv run --project <repo> satyrn-engine protocol, where<repo>isprocess.env.SATYRN_ENGINE_REPO. The env var is the configuration surface; the spawner is the injected test seam (tests inject a fake spawner or a stub engine checkout; production defaults tonode:child_process).Spawns without a shell, stdio piped: write the request,
end()the stdin stream, read stdout to EOF, await exit.spawn("uv", [...])lets the OS resolve the executable on both POSIX and Windows (uv.exe), and argv path arguments need no shell quoting on either platform.Runs under its own deadline and exception boundary, per the Pi facts in
BRIEF.md: Pi awaits extension handlers sequentially with no host deadline, and an uncaught adapter error escapes the turn. The deadline defaults to 30 seconds, injectable alongside the spawner (acheckis single-digit seconds; 30 s is headroom, not a promise). On deadline, the adapter kills the child and refusesENGINE_TIMEOUT.Prints refusals as
satyrn-engine: <CAUSE>: <detail>— the same line format as the CLI — and reports acceptance assatyrn-engine: OK.
The adapter is thin: it owns transport and the command surface, nothing else. Contract semantics stay in Python.
Refusal vocabulary¶
code |
side |
when |
|---|---|---|
|
adapter |
spawn error, |
|
adapter |
no response before the deadline |
|
adapter |
nonzero exit with no parseable response (incl. exit 1) |
|
adapter |
response is JSON but wrong shape, or version mismatch |
|
adapter |
|
|
engine |
malformed request (the only new engine code) |
|
engine |
pass through verbatim from |
The engine’s refusals pass through unchanged with their E1 names; the adapter never reinterprets them.
Testing¶
Default tier (no process, no network, no subprocess — tripwire
enforced): the pure functions behind the protocol — request parsing,
response rendering, and a handle_protocol(text) -> (response_text, exit_code) that drives check() — unit-tested with sibling
success/refusal pairs. Reading stdin itself is not tested here (it is a
piped byte stream, exercised in the integration tier).
Integration tier (-m integration, marked, not in CI — first tests in
this phase): spawns the real console script and pipes the JSON, covering
accept, every engine refusal, and INVALID_REQUEST, each refusal with a
sibling success. Also proves uv run --project forwards stdin and the
exit code on this machine, one time, recorded.
Node replay harness (pattern of tools/replay_guards.mjs): an
ExtensionAPI double and an injected fake spawner drive
orchestrator.ts’s request building, response parsing, deadline, and all
four transport conversions, against a stub engine. No real Pi, no model.
One manual proof, recorded: a live /implement in a running Pi
against a real contract and a real refusal — the E2 analogue of E1’s
planted-tripwire proof — plus one recorded run on Windows, since the phase
names POSIX and Windows and the integration tier does not run in CI.
Protocol compatibility fixtures¶
Committed JSON request/response pairs live in
tests/fixtures/protocol/ (the “internal Pi-adapter protocol and its
compatibility fixtures” README.md already claims ownership of). They are
the versioned contract between the adapter and the engine, reused by the
Python protocol tests and the Node harness.
Error handling¶
The protocol handler never lets an exception escape as a traceback to the adapter: every input problem is a named
INVALID_REQUESTresponse; an unexpected internal error propagates and the interpreter exits 1, which the adapter reads asENGINE_CRASHED(no response) — a transport failure, never a mislabeled refusal.The adapter wraps the whole exchange in try/catch; an adapter bug surfaces as a named adapter refusal, not an escape from the Pi turn.
Unknown
versionin a response →ENGINE_MALFORMED_RESPONSE; a future engine that still speaks the same request shape is forward-compatible, and a future that changesversionis detected, not guessed at.
Dependencies¶
No new runtime dependency for the engine (pyyaml remains the only one).
The adapter imports Pi’s ExtensionAPI types and Node built-ins only
(node:child_process, node:path). The harvest index warns that any
change to an installable file forces an install-doc re-verification: the
guards’ documented one-file cp install becomes a two-file install
(engine.ts + orchestrator.ts plus the package.json), and the
SATYRN_ENGINE_REPO requirement is new — both must be re-verified in the
docs as part of this phase.
Testing layout¶
tests/
conftest.py # autouse tripwire (kept; now also covers protocol tests)
test_protocol.py # default tier: parse/render/handle unit tests (sibling pairs)
test_integration_protocol.py # -m integration: real subprocess, accept + refusals + INVALID_REQUEST
fixtures/protocol/ # versioned request/response compatibility fixtures
tools/
replay_orchestrator.mjs # Node harness: ExtensionAPI double + fake spawner
packages/engine/
orchestrator.ts # the adapter: /implement command, transport, conversion