# 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`. - `check` is 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) and `attempt` (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 | |-------|---------| | `id` | stable identifier for the contract (names receipts and candidates in later phases) | | `task` | 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 | `OK` | contract accepted | | 2 | `USAGE` | argparse usage error (bad flags, missing CLI args) | | 3 | `CONTRACT_UNREADABLE` | CONTRACT path missing or not a readable file | | 4 | `CONTRACT_INVALID_YAML` | CONTRACT is not valid YAML | | 5 | `CONTRACT_MISSING_FIELD` | required field absent, empty, or wrong type (incl. top level not a mapping) | | 6 | `REPO_UNAVAILABLE` | `--repo` missing or not a directory | 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: : ``` where `` 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: 1. read the contract file → `CONTRACT_UNREADABLE` 2. parse it as YAML → `CONTRACT_INVALID_YAML` 3. validate required fields → `CONTRACT_MISSING_FIELD` 4. lint `--repo` (exists and is a directory) → `REPO_UNAVAILABLE` 5. accept → `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 | |------|----------------| | `src/satyrn_engine/exits.py` | `ExitCode` IntEnum — the single source of truth for exit codes | | `src/satyrn_engine/contract.py` | `Contract` (frozen dataclass), `ContractError` (named refusal), `load_contract(path)` | | `src/satyrn_engine/check.py` | `CheckResult` (code + message + optional contract), `check(repo, contract_path)` | | `src/satyrn_engine/cli.py` | `build_parser()`, `main(argv) -> int` | | `src/satyrn_engine/__init__.py` | `__version__` (unchanged) | `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_contract` raises `ContractError` carrying the `ExitCode` and a one-line `message`. It converts `OSError` on read into `CONTRACT_UNREADABLE` and `yaml.YAMLError` on parse into `CONTRACT_INVALID_YAML`, chaining the original exception. - `check` catches `ContractError` and folds it into a `CheckResult`; it does not let it escape to the caller's stderr as a traceback. - An unexpected exception in `main` is **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: 1. **accepts one valid contract** — `test_valid_contract_accepted` asserts `main([...]) == ExitCode.OK` and empty stderr. 2. **refuses invalid YAML / impossible path / missing field, each with a distinct stable exit code** — one test per refusal, asserting both the exact `ExitCode` and that stderr names the cause; plus `test_refusal_codes_are_distinct_and_stable` pins the numeric values to `0, 2, 3, 4, 5, 6` so renumbering breaks the build. 3. **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. 4. **default tier in single-digit seconds** — no subprocess, no network, ~10 tests; verified with `time uv run pytest`. 5. **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.