E1 — check: it installs and refuses (design spec)¶
Date: 2026-08-16 Phase: E1 (current) Status: accepted — implementation follows in the companion plan
Goal¶
Deliver satyrn-engine check --repo REPO CONTRACT: parse and validate a
contract, lint the paths it names, and either accept it (exit 0) or refuse
it with a named cause and a stable, documented exit code. On every path —
accepted or refused — the engine makes zero model calls and starts zero
processes.
Non-goals (explicitly out of E1)¶
Pi, the adapter, delivery, mutation, packaging. check does not create a
worktree, does not run a model or any executable, does not touch the working
tree, and does not build a wheel. Those are later phases (E2–E6). A “checkout
install” for E1 means uv sync in a fresh checkout, which produces an
editable install exposing the satyrn-engine console script — not a
published package.
Decisions from brainstorming¶
CLI surface¶
Console script
satyrn-engine→satyrn_engine.cli:main.checkis an argparse subcommand with one required option and one required positional:--repo REPO— the working-tree root; must exist and be a directory.CONTRACT— path to the contract YAML file; must exist and be readable.
Subparsers are used now (one subcommand) rather than flat flags so that
deliver(E3) andattempt(E5) join as sibling subcommands without a breaking CLI change. This is the standard argparse shape, not machinery ahead of its contract: the roadmap already names the future subcommands.
main(argv) returns an int exit code. The console-script wrapper applies
sys.exit(main()). argparse’s own usage errors still raise SystemExit(2);
that is exit code USAGE, not a refusal.
Contract format¶
A contract is a YAML document whose top level is a mapping with exactly two required fields, both non-empty strings:
field |
meaning |
|---|---|
|
stable identifier for the contract (names receipts and candidates in later phases) |
|
the change description |
Unknown extra fields are ignored, not refused: contracts will grow fields
in later phases, and data-over-code means extending is adding a key, not
subclassing. A top level that is not a mapping, a field that is absent, empty,
or not a string is refused as CONTRACT_MISSING_FIELD (the message names the
field and the problem).
Exit codes¶
A single ExitCode IntEnum in satyrn_engine/exits.py is the source of
truth. The numeric values are the stable contract, pinned by a test.
code |
name |
when |
|---|---|---|
0 |
|
contract accepted |
2 |
|
argparse usage error (bad flags, missing CLI args) |
3 |
|
CONTRACT path missing or not a readable file |
4 |
|
CONTRACT is not valid YAML |
5 |
|
required field absent, empty, or wrong type (incl. top level not a mapping) |
6 |
|
|
Exit code 1 is reserved for unexpected internal errors (an uncaught exception, which Python reports with exit 1). It is not a refusal: a refusal is a deliberate, named verdict; a crash is a bug. No refusal code may ever equal 1, 0, or another refusal code.
Each refusal prints one line to stderr:
satyrn-engine: <CAUSE>: <detail>
where <CAUSE> is the ExitCode member name (the “named cause”). Success
prints nothing to stdout or stderr.
Order of checks¶
check runs its steps in this order and returns the first refusal:
read the contract file →
CONTRACT_UNREADABLEparse it as YAML →
CONTRACT_INVALID_YAMLvalidate required fields →
CONTRACT_MISSING_FIELDlint
--repo(exists and is a directory) →REPO_UNAVAILABLEaccept →
OK
The contract is checked before the repo because the contract is the thing being refused or accepted; the repo is the target it will later be applied to. The order is documented here so a later phase that changes it does so deliberately.
Zero model calls and zero processes¶
Enforced mechanically, per binding rule 3, by a global autouse tripwire
in tests/conftest.py that patches the process- and network-entry points of
the standard library to raise AssertionError:
process:
subprocess.Popen,run,call,check_call,check_output,getoutput,getstatusoutput;os.system,os.popen*,os.spawn*,os.exec*,os.fork,os.forkpty,os.posix_spawn*;network (model-call proxy):
socket.socket.
A “model call” at E1 can only happen two ways — spawning a model process
(caught by the subprocess guard) or opening a network connection to a model
server (caught by the socket guard) — because the package has zero runtime
dependencies other than pyyaml and no model client. The tripwire is
proven once: a deliberately planted process-spawning test must fail the
run, and that failure is recorded before the planted test is removed. The
tripwire then stays, constraining every later phase’s test design.
Module layout¶
file |
responsibility |
|---|---|
|
|
|
|
|
|
|
|
|
|
check() is the library seam: it returns a CheckResult carrying the
ExitCode, the human-readable detail, and (on success) the parsed
Contract. The CLI is a thin wrapper that prints the refusal and returns the
code. The seam used by tests is the same seam later phases extend — no second
plugin mechanism.
Error handling¶
load_contractraisesContractErrorcarrying theExitCodeand a one-linemessage. It convertsOSErroron read intoCONTRACT_UNREADABLEandyaml.YAMLErroron parse intoCONTRACT_INVALID_YAML, chaining the original exception.checkcatchesContractErrorand folds it into aCheckResult; it does not let it escape to the caller’s stderr as a traceback.An unexpected exception in
mainis not caught: it propagates and the interpreter exits 1, keeping crashes distinct from refusals.
Dependencies¶
pyyaml>=6.0.3 joins [project.dependencies]. It is the only runtime
dependency. (YAML is not in the Python standard library, and re-implementing
a YAML subset would be a trap the brief warns against.)
Testing¶
Layout:
tests/
conftest.py # autouse tripwire (no process / no network)
test_toolchain.py # existing, kept
test_contract.py # load_contract unit tests (sibling success + refusal)
test_check_cli.py # end-to-end through main(): accept + every refusal
fixtures/contracts/
valid.yaml # id + task
invalid.yaml # malformed YAML
missing-field.yaml # valid YAML, no task
Coverage, mapping each done-when item to a test:
accepts one valid contract —
test_valid_contract_acceptedassertsmain([...]) == ExitCode.OKand empty stderr.refuses invalid YAML / impossible path / missing field, each with a distinct stable exit code — one test per refusal, asserting both the exact
ExitCodeand that stderr names the cause; plustest_refusal_codes_are_distinct_and_stablepins the numeric values to0, 2, 3, 4, 5, 6so renumbering breaks the build.zero model calls and zero processes on any path — the autouse tripwire plus
test_check_paths_make_no_process_or_model_calls, which drives every path (valid + all four refusals) under the tripwire.default tier in single-digit seconds — no subprocess, no network, ~10 tests; verified with
time uv run pytest.planted process-spawning test fails the build — proven first (before product code) and recorded; the planted test is then removed and the tripwire kept.
Non-vacuity (binding rule 4): every refusal test has the valid-contract
acceptance test as its sibling success, and test_contract.py pairs each
load_contract refusal with a success case.