E3 — Delivery (design spec)¶
Date: 2026-08-18 Phase: E3 (current) Status: accepted — implementation follows in the companion plan
Correction — 2026-08-19: Implementation review exposed three unsafe assumptions in the accepted text. Direct pipe forwarding was replaced by a temporary-file spool plus synchronous process-group teardown, because an escaped descendant can retain a pipe descriptor and block the engine. A short-lived empty hooks directory was replaced by
core.hooksPath=/dev/nullandcore.fsmonitor=falseon every engine-owned Git call, covering publication-timereference-transactionhooks and external fsmonitor hooks. Finally, the worktree-registration guard now becomes uncertain beforegit worktree add, rather than after confirmed registration, because interruption can occur after Git mutates shared metadata but before Python observes the return. The current text below is normative; these statements record the superseded design and why it changed.
Goal¶
Deliver one user-visible behavior:
satyrn-engine deliver --repo REPO [--timeout SECONDS] CONTRACT -- COMMAND [ARG ...]
The engine runs COMMAND once in a detached Git worktree at the repository’s
exact HEAD. If the command succeeds and changes the tree, the engine creates
a commit and publishes it as a candidate ref. Otherwise it discards the
worktree and publishes no candidate.
This isolates engine-owned Git writes from the caller’s working tree, index,
branch, and HEAD. It is not a security sandbox: COMMAND is trusted and can
write absolute paths, deliberately escape its process group, or mutate shared
Git state. E3 adds lightweight POSIX process-group teardown so an ordinary
timed-out command cannot leave its children running, but it adds no container,
filesystem sandbox, or general process supervisor. E3 proves delivery with a
deterministic executable standing in for a model. E4 adds writable-path and
revision enforcement; E5 adds validation and one real attempt.
Identity and candidate ref¶
E3 uses identities Git already provides instead of introducing a UUID, database, slug, escaping scheme, or allocator:
contract.idis the logical identity of the requested change.The candidate commit SHA is the identity of this exact revision.
The pair
(Git shared-ref namespace, contract.id)is unique. Different spellings or linked worktrees of the same repository share that namespace; atomic ref creation supplies the uniqueness constraint.
This is the same separation used by Gerrit, where a Change-Id survives new
patch-set commits, and by GitHub’s pull-request refs, where the pull-request
number and terminal head role are distinct from the commit they name.
Database terms lead to the same result: this is a repository-scoped composite
key, with Git enforcing the unique constraint.
The candidate ref is:
refs/satyrn/candidates/<contract-id>/head
The terminal head leaves room for later revision or metadata refs without
adding that machinery now. The engine first rejects any contract id containing
/, NUL, or a value that cannot be encoded as UTF-8, because the id must be
exactly one processable Git ref component. It then constructs the full ref and
validates it with git check-ref-format without --normalize.
It never rewrites, case-folds, normalizes, or hashes the id: two visible ids
cannot silently become the same candidate. An invalid id, including
team/foo, produces INVALID_CANDIDATE_ID before a worktree is created.
Publication uses:
git update-ref --no-deref REF COMMIT ""
The empty expected-old value is an object-id compare-and-swap requiring no
direct ref to exist, so exactly one cooperating delivery process wins a race.
Preflight refuses an existing ordinary or symbolic ref as
CANDIDATE_EXISTS; --no-deref ensures publication updates the candidate ref
itself rather than a symbolic target. Git’s object-id CAS does not atomically
distinguish absence from a concurrently introduced dangling symbolic ref, so
external mutation of this ref during delivery remains part of the trusted
shared-Git-state boundary. Adding a separate lock protocol for that adversarial
case is not justified in E3. Updating, listing, and deleting candidates are
also deferred.
Sources: Git ref-name rules, Git ref transactions, GitHub pull-request refs, Gerrit Change-Id, Gerrit change refs, and PostgreSQL unique constraints.
CLI boundary¶
CONTRACT and COMMAND are separated by a literal --. At least one command
token is required. CLI syntax errors, including a missing command, use
argparse’s stderr and exit USAGE = 2; no receipt exists because no operation
was accepted.
The command is invoked as its exact argument vector with shell=False, the
isolated worktree as cwd, and stdin connected to DEVNULL. It inherits the
caller’s environment, including PATH and normal Git configuration, except
that repository-local routing variables and GIT_NAMESPACE are removed so a
Git command discovers the isolated worktree, and GIT_TERMINAL_PROMPT=0
prevents an engine-owned Git command from waiting for interactive credentials.
E3 does not send the contract or task through argv, environment, or stdin.
Command stdout and stderr are
spooled to an engine-owned temporary file and then forwarded to the engine’s
stderr in bounded chunks, so arbitrary command output is not retained in
memory and stdout remains machine-readable.
The default timeout is 30 seconds. On POSIX the engine starts COMMAND in a
new session, making the direct child the leader of a new process group. On
timeout it sends SIGTERM to that group, waits up to five seconds for the
process group to exit, then sends SIGKILL to any group that remains and reaps
the direct child. Because output goes to a temporary file rather than a pipe,
an escaped descendant cannot keep the engine blocked merely by retaining an
output descriptor. A missing process group during either signal is treated as
already exited. Process-group teardown completes before worktree cleanup
begins.
This is bounded cleanup, not adversarial descendant tracking. COMMAND is
expected to run synchronously and not call setsid() or otherwise move a
descendant out of the engine-created process group. Cancellation, retry, and
repair remain outside E3. A timeout value must be finite and greater than zero;
zero, negative, NaN, and infinite values are CLI USAGE = 2 errors before
delivery starts.
Receipt and exit behavior¶
Every accepted operation writes exactly one UTF-8 JSON object followed by a
newline to stdout. An unexpected engine bug remains an uncaught exit 1 and
does not fabricate a receipt.
If the caller closes stdout, delivery cannot transmit that receipt. The engine
suppresses the resulting broken-pipe traceback and returns the reserved
abnormal exit 1; publication may already have completed, so the caller must
inspect the candidate ref before retrying.
The receipt always has the same fields:
field |
type |
populated when |
|---|---|---|
|
integer |
always |
|
string |
always: |
|
string |
always; authoritative specific result |
|
string |
always; human-readable detail |
|
string or null |
contract parsing succeeds |
|
string |
always; absolute normalized input path, without resolving symlinks |
|
string or null |
Git preflight resolves |
|
string or null |
the candidate id validates |
|
string or null |
|
|
array of UTF-8 strings or null |
final diff succeeds; sorted by UTF-8 bytes; |
|
integer or null |
the direct child exits; unavailable and timeout remain null |
|
string or null |
cleanup fails and operator action is required |
A successful receipt is therefore shaped as follows:
{"version":1,"outcome":"candidate-created","code":"OK","message":"candidate created","contract_id":"greeting","repository":"/src/app","base_commit":"<sha>","candidate_ref":"refs/satyrn/candidates/greeting/head","candidate_commit":"<sha>","changed_paths":["greeting.py"],"command_exit":0,"worktree_path":null}
On an early refusal the still-unknown values are null. NO_CHANGES reports
changed_paths: []; a command failure leaves paths null and records its
nonzero command_exit. On publication loss,
candidate_commit and changed_paths describe the unpublished commit and the
code is CANDIDATE_EXISTS. CLEANUP_FAILED replaces any pending result, names
that displaced result in message, and retains the path in worktree_path.
Success exits 0. Existing E1 failures keep codes 3–6, and protocol input
keeps INVALID_REQUEST = 7. All new handled outcomes that create no candidate
exit NO_CANDIDATE = 8; automation reads the receipt code for the precise
reason. This deliberately keeps the shell surface binary instead of assigning
a numeric exit code to every Git or process detail.
receipt |
outcome |
condition |
|---|---|---|
E1’s |
|
inherited preflight refusal |
|
|
unsupported source state |
|
|
candidate identity cannot be created |
|
|
the direct child cannot start |
|
|
command did not yield an acceptable tree |
|
|
command succeeds but the tree is unchanged |
|
|
named engine-owned Git or cleanup operation fails |
|
|
the candidate ref is created atomically |
outcome describes the candidate lifecycle; code describes the specific
cause. The code vocabulary is closed, and both outcome and the numeric exit
status are derived from it so contradictory receipt states are not
representable in the implementation. E3 deliberately does not expose
infrastructure-failure as a fourth
outcome. A generic COMMAND boundary cannot reliably distinguish a model
failure from a broken model server, missing executable, or other setup failure.
Later model and eval phases may classify their own evidence without changing
this delivery receipt.
Workflow¶
The supported E3 evidence target is a normal, non-bare POSIX working
repository with at least one commit. The supplied --repo must name its root
(a symlink to that root is accepted), and its tracked, deleted, and untracked
state must be clean. The source may move concurrently after preflight; the
candidate remains based on the captured commit.
START
|
v
E1 check -> resolve Git root and exact HEAD -> require clean source
| refused / not Git / dirty
+----------------------------------------------------------> REFUSED
v
construct and validate refs/satyrn/candidates/<id>/head
| invalid / already exists
+----------------------------------------------------------> REFUSED
v
add detached temporary worktree at BASE
| Git failure
+--> cleanup if registered (failure => CLEANUP_FAILED) ---> GIT_FAILED
v
run trusted COMMAND once
| unavailable / timeout / nonzero
+--> cleanup (failure => CLEANUP_FAILED) ------------> REFUSED/DISCARDED
v
require detached HEAD == BASE -> git add -A -> compare tree with BASE
| moved HEAD / no changes
+--> cleanup (failure => CLEANUP_FAILED) --------------------> DISCARDED
v
write tree -> commit-tree with parent BASE -> derive changed paths
| Git failure
+--> cleanup (failure => CLEANUP_FAILED) --------------------> GIT_FAILED
|
v
remove worktree
| failure
+--------------------------------------------------> CLEANUP_FAILED
v
update-ref --no-deref REF COMMIT ""
| ref now exists | other Git failure
+--> CANDIDATE_EXISTS +---------------------> GIT_FAILED
v
CANDIDATE_CREATED
The ref transition is intentionally just create-if-absent:
ABSENT -- update-ref(REF, COMMIT, expected=absent) --> CREATED
| |
| another creator wins | other failure
v v
EXISTS ABSENT OR UNKNOWN
An initial lookup gives an early, useful refusal; update-ref is still the
race-safe decision. After an update failure the engine re-reads both symbolic
and ordinary ref state. A now-existing ref is CANDIDATE_EXISTS; any verified
absence or unreadable state is GIT_FAILED. E3 intentionally exposes both of
the latter as one machine result: callers must not infer retry safety from a
diagnostic lookup. The receipt therefore does not add a second ref_state
axis; code and the candidate fields describe its stable result.
Every handled path after worktree registration passes through the same
cleanup operation, git worktree remove --force PATH. Force is needed because
candidate state is dirty or staged. A locked worktree intentionally remains a
visible CLEANUP_FAILED; manual recovery is:
git worktree unlock PATH
git worktree remove --force PATH
git worktree prune
A locked worktree can alternatively be removed by giving --force twice. The
temporary parent is retained until Git confirms the linked worktree is absent.
If Git removes the worktree but deleting the temporary parent fails, the same
CLEANUP_FAILED result reports that parent as the retained cleanup path.
Abrupt termination can still leave an engine-owned worktree; automatic crash
recovery is deferred.
The registered-worktree lifetime is enclosed by try/finally. The cleanup
guard becomes uncertain immediately before the mutating git worktree add
call, because an interruption can arrive after Git registers the worktree but
before the call returns. The command runs only after Git confirms registration.
A failed lookup keeps the guard uncertain, conservatively retaining or removing
that state rather than running the command. It is cleared only after confirmed
absence, so normal cleanup is not repeated. After an unexpected Python exception, the
engine attempts cleanup while the guard is set and then re-raises the original
error as exit 1, without inventing a receipt. If cleanup also fails, stderr
reports the retained path without hiding the original exception. Signals that
prevent finally from running, such as SIGKILL, still require the manual
recovery above.
Process lifetime is a separate cleanup gate. It closes before command creation
and reopens after normal direct-child completion or only when timeout teardown
confirms the process group is gone and the direct child was reaped. If teardown
cannot establish both facts, CLEANUP_FAILED supersedes the pending command
result and retains the worktree; Git cleanup does not race a possibly live
process.
The new commit is created before cleanup and published afterward. A crash or lost race can therefore leave a dangling Git object, but never a partial candidate ref or an engine write in the caller’s tree. A temporary pin ref is not justified in E3.
Git and implementation decisions¶
decision |
reason |
|---|---|
Resolve |
Operate from the root and base every later action on an immutable commit. git-rev-parse |
Check |
Stable, NUL-safe scripting output; no optional caller-index refresh. git-status, git |
Remove repository-local routing variables named by |
Prevent inherited |
Force |
A non-interactive delivery operation must fail rather than wait for credential input. This retains the earlier prototype’s behavior without discarding normal system or global configuration. git |
Preserve normal system, global, and repository config; force |
Standard Git features such as LFS and clean/smudge filters continue to work, while repository hooks, reference-transaction hooks, external fsmonitor hooks, and caller date overrides cannot alter engine-owned commit creation. The trusted command still receives the date variables. Filters remain part of the trusted-repository boundary. git-config, git-commit-tree, githooks |
Use |
Isolate index and tree while sharing Git objects, without trusting |
Spool combined command output to |
Keep receipt stdout separate without writing even transient output files in the caller tree, retaining unbounded output in engine memory, or letting a descendant-held pipe block teardown. The trusted command and the spool can still consume temporary disk within the command deadline; E3 adds no general resource supervisor or byte quota. tempfile, subprocess |
Start |
Bound the attempt and its ordinary descendants without introducing a container or persistent supervisor. This re-earns a behavior retained by the earlier prototype. subprocess, os.killpg |
Stage with |
Include additions, modifications, and deletions and make the parent explicit without editor or signing behavior. Use identity |
Derive paths from |
The final commit is authoritative. Split NUL-delimited bytes, require UTF-8, and sort by those bytes so Git config and locale cannot change the receipt. Non-UTF-8 paths produce |
Preflight, worktree add/removal, registration checks, and ref lookup/publication
run from the canonical source root. add, tree comparison, write-tree,
commit-tree, and diff-tree run from the isolated worktree. This cwd split is
part of the isolation guarantee, not an implementation convenience.
The Python surface stays small: delivery.py owns DeliveryReceipt and the
deliver() lifecycle; cli.py owns parsing and rendering; exits.py adds
NO_CANDIDATE. E3 adds no runtime Python dependency. pytest-cov is a
development-only dependency used to enforce the coverage gate. Git 2.36 or
newer is an explicit external runtime requirement because that release added
the NUL-delimited git worktree list --porcelain -z format used for safe path
parsing (Git 2.36 release notes). There is no injected Git-runner or
test-only failure hook: the production library seam is the test and extension
seam.
The earlier prototype is evidence, not source. Its candidate lifecycle and
process teardown were inspected at the repository’s pinned handoff revision
8588ba4, especially
harness/candidate.py,
harness/processes.py,
the two-repository rewrite,
and the harvest index.
No implementation is copied from it.
Testing and phase work¶
Default-tier tests cover parsing, timeout validation, receipt construction, and result precedence. Candidate-id rejection and Git-specific ref-name rules are integration-only because preflight includes real Git subprocesses. The default tier preserves E1’s process and network tripwire. Marked integration tests use temporary local Git repositories and production CLI seams—never a network or test-only injected backend—to prove:
Success creates one commit with parent
BASE, publishes the expected ref, reports added/modified/deleted paths, removes the worktree, and leaves the caller tree, index, branch, andHEADunchanged.No-op, nonzero, unavailable, timeout, and moved-
HEADcommands emit their exact receipts, publish no ref, and clean up. The timeout fixture starts a same-process-group descendant that would write a delayed sentinel; teardown prevents the write before the worktree is removed.Dirty repositories, invalid ids, and existing ordinary, symbolic, and dangling-symbolic refs refuse without engine-owned mutation.
A locked worktree proves
CLEANUP_FAILED, outcome precedence, retained-path reporting, and explicit test teardown.Two real CLI processes race on the same id; exactly one creates the ref and the other reports
CANDIDATE_EXISTS.An ancestor ref that collides with the candidate namespace produces
GIT_FAILED; the sibling success uses the same id without that collision.UTF-8 paths have canonical byte order; a POSIX non-UTF-8 path produces
GIT_FAILEDand no candidate, paired with a UTF-8 success fixture.A hook sentinel does not fire, while a normal repository filter remains usable with the preserved Git configuration.
An interruption after real worktree registration leaves neither a linked nor prunable registration, while uncertain cleanup retains a visible path.
Attaching the isolated
HEADto a branch at the same base is discarded; detached sibling delivery succeeds.The caller can advance its own
HEADafter preflight without changing the candidate’s captured base or sole parent.Candidate metadata uses the fixed engine identity and exact message and ignores repository commit-signing configuration.
E3 also updates README.md, ROADMAP.md, architecture, usage, glossary,
the documentation index, contributing guidance, and the pytest
integration-marker description, plus the test that pins the complete
ExitCode table. It adds exact committed receipt fixtures for OK,
REPO_DIRTY, COMMAND_FAILED, CANDIDATE_EXISTS, and CLEANUP_FAILED; the
SDD record names commands and evidence for both success and failure. Those
changes belong in the implementation plan and phase commit, not in this
spec-only change.
Planned completion commands:
uv run pytest
uv run pytest -m integration tests/test_integration_delivery.py -v
uv run pytest -m "" --cov
uv run ruff check .
uv run pyrefly check
uv run --group docs sphinx-build -W -b html docs docs/_build/html
Explicitly outside E3¶
Pi, model selection, task-to-command handoff, and contract authoring.
Writable-path and revision enforcement (E4), then validation and a real attempt (E5).
A security sandbox, containment of descendants that deliberately escape the engine-created process group, retry, repair, or cancellation.
Applying, updating, listing, or deleting candidates; UUIDs and patch sets.
Special behavior or evidence for bare or unborn repositories, sparse checkouts, submodule mutation, and Windows. Cross-platform packaging is E6.
A persistent service, JSON-RPC, plugins, or CI execution of integration tests.