E4 — One bounded replacement (design spec)¶
Date: 2026-08-20 Phase: E4 (current) Status: accepted — implementation follows in the companion plan
Correction, 2026-08-21¶
The accepted text below made four boundary mistakes that implementation must not preserve:
A path missing from the TypeScript revision map was described as a local refusal. That lets TypeScript decide writable-path policy. The wire field is instead required and nullable: TypeScript always sends the request with its known SHA-256 or
null; Python first checkswritable_paths, then returnsREVISION_UNAVAILABLEwhen an authorized path has no captured revision.Rejecting only symlink escapes is insufficient. A declared in-repository symlink can alias an undeclared file. Every target path component, including the leaf, must therefore be opened and checked without following symlinks; E4 never mutates through a symlink.
A mutation transport failure can occur after Python published a replacement but before TypeScript received the response. Such a result is indeterminate, not proof that the file is unchanged. The mutation context becomes poisoned after a timeout, crash, malformed response, or other transport failure and accepts no later edit. E5 discards the isolated worktree containing that context.
The existing one-shot exchange did not yet earn its lifecycle claim. It rejected immediately after sending
SIGTERMand had no asynchronous stdin error boundary. The adapter now contains stdin failures and reports timeout only after the direct child closes, escalating toSIGKILLafter a short grace period.
These are corrections to the accepted design, not new phase scope. The normative request, refusal, path, adapter, and test rules below are read with this section taking precedence where they conflict.
Goal¶
Route one exact-text replacement through Pi → TypeScript → Python. The Python core is the only component that decides whether the target is writable, the file revision is current, and the anchor occurs exactly once. A successful request atomically replaces that one occurrence and returns the next SHA-256 revision. A stale revision, undeclared path, missing anchor, or ambiguous anchor is a named refusal and leaves the file unchanged.
This phase proves the mutation boundary that E5 will run inside E3’s detached worktree. It does not yet start a model or create a candidate.
Evidence and scope¶
The pinned design record at local-ai-pi@8588ba4 names E4’s done-when and the
one-shot protocol boundary:
docs/superpowers/research/2026-08-16-two-repo-rewrite-and-python-engine.md, “Python core, TypeScript adapter”, “Start with a one-shot protocol”, and the E4 roadmap row;docs/superpowers/handoff/satyrn-engine/ROADMAP.md, “E4 is the scope-blowup risk”;extensions/implementer/mutation-engine.ts, especially its revision and unique-anchor evidence.
The old TypeScript mutation engine is evidence, not source. It combined file creation, whole-file proposals, multiple edits, size limits, newline policy, symbol preservation, cross-file moves, and no-op handling. E4 re-earns only the four decisions required by its done-when: writable path, current revision, anchor present, and anchor unique.
Non-goals¶
E4 does not add a human-facing CLI command, a model call, delivery, a candidate, a transcript, file creation, whole-file write, multiple replacements, a general edit algebra, fuzzy matching, symbol analysis, cross-file moves, proposal-size policy, validation, a persistent Python process, or a generic mutation framework.
The E3.5 loop breaker remains a separate TypeScript extension. E4 does not add
another tool_call guard: the mutation decision happens inside the replacement
tool before a write.
Contract extension¶
The YAML contract gains one optional field:
field |
shape |
meaning |
|---|---|---|
|
list of non-empty strings |
workspace-relative path patterns accepted for mutation |
Contract.writable_paths is an immutable tuple. Omitting the field produces
an empty tuple, preserving every E1–E3 contract and check fixture. If the field
is present but is not a list of non-empty strings, contract loading refuses it
as CONTRACT_MISSING_FIELD; no new contract exit code is needed.
Python’s fnmatch.fnmatch is the reference matching rule. In particular, *
may cross /. This is a recorded design decision, not a TypeScript behavior to
port. The request path is first normalized to one safe POSIX-style relative
path; patterns are matched only in Python.
Product surfaces¶
Human CLI¶
There is no new human-facing command and no change to check or deliver.
The existing private satyrn-engine protocol command gains the replace
operation.
Pi tool¶
The package adds mutator.ts, loaded beside engine.ts and
orchestrator.ts. It replaces Pi’s built-in edit tool only when
SATYRN_MUTATION_CONTEXT contains a valid explicit context. Ordinary Pi
sessions without that variable keep their built-in tool untouched.
The E4 tool accepts Pi’s familiar shape, restricted to exactly one entry:
{
"path": "src/app.py",
"edits": [
{"oldText": "return 1", "newText": "return 2"}
]
}
The array shape follows the installed Pi edit surface, while minItems: 1
and maxItems: 1 make the E4 operation a single replacement rather than a
premature edit language.
Mutation context¶
E5 will construct the context before starting its Pi child. E4 fixes its versioned shape now so the adapter has no hidden filesystem policy:
{
"version": 1,
"repo": "/absolute/disposable/worktree",
"contract": "/absolute/path/to/contract.yaml",
"revisions": {"src/app.py": "<64 lowercase hex characters>"}
}
The context is data, not ambient engine state. It is parsed once per extension
registration. The adapter never computes a file hash or reads a contract. A
path absent from revisions is refused before a subprocess starts. After a
successful replacement, the returned SHA-256 replaces the stored revision so
a later E5 slice can make sequential calls without a long-running Python
process.
Missing or malformed context means no E4 tool is registered. This keeps
ordinary pi install behavior safe and avoids overriding a useful built-in
with a tool that cannot run.
Protocol¶
The protocol remains version 1 and one-request/one-response. A replacement request is:
{
"version": 1,
"operation": "replace",
"repo": "/absolute/disposable/worktree",
"contract": "/absolute/path/to/contract.yaml",
"path": "src/app.py",
"expected_sha256": "<64 lowercase hex characters or null>",
"old_text": "return 1",
"new_text": "return 2"
}
All fields are required. expected_sha256 is either 64 lowercase hexadecimal
characters or null; omitting it is invalid. old_text must be non-empty;
new_text may be empty. Malformed JSON, wrong field types, an invalid non-null
SHA shape, an unsafe relative path, or an unsupported operation is
INVALID_REQUEST and exit 7. Those are transport/input-shape failures, not
mutation-policy refusals.
A successful response extends the existing base response with one result:
{
"version": 1,
"ok": true,
"code": "OK",
"message": "",
"result": {"path": "src/app.py", "sha256": "<next revision>"}
}
A policy refusal has ok: false, one detailed code, a human-readable message,
and result: null. The detailed code is authoritative; all five policy
refusals share product process exit 9, MUTATION_REFUSED:
response code |
condition |
|---|---|
|
normalized path matches no contract pattern |
|
declared path has no captured revision in the mutation context |
|
current file SHA-256 differs from |
|
|
|
|
An expected local operational failure after request acceptance, such as an
unreadable target or failed atomic replacement, returns MUTATION_FAILED,
result: null, and exit 9. It is not one of the five done-when policy
refusals, but it must not become a traceback or a malformed protocol response.
check responses keep their existing four-field shape. Protocol parsers must
therefore use an operation-discriminated request type and a replacement-only
result rather than adding nullable mutation fields to every response.
Python mutation rules¶
Path boundary¶
The request path must use /, be relative, contain no NUL, empty segment,
. segment, or .. segment, and name a regular file inside the canonical
repository root. Absolute paths are malformed requests. After shape
acceptance, Python checks every filesystem component without following
symlinks; any symlink or containment failure is MUTATION_FAILED. The
declared-path match happens against the normalized relative path before the
filesystem target is opened.
E4 does not create a missing file. A declared but missing/non-regular target is
MUTATION_FAILED.
Revision¶
SHA-256 is computed from the file’s exact bytes. After the path is declared,
a null request revision is REVISION_UNAVAILABLE. A non-null revision must be
64 lowercase hexadecimal characters. The engine reads the file once, computes
the actual revision, and refuses REVISION_STALE before anchor inspection if
it differs. The response carries the hash of the exact bytes written.
Byte hashing avoids newline normalization and preserves a stable revision for
arbitrary UTF-8 text files. E4 replacement strings are UTF-8 JSON strings; a
target that is not valid UTF-8 is MUTATION_FAILED, not silently decoded.
Anchor and write¶
old_text is encoded as UTF-8 and counted as an exact byte sequence in the
revision-checked content. Zero matches is ANCHOR_MISSING; more than one is
ANCHOR_AMBIGUOUS. Exactly one match is replaced literally, so $, backslash,
and other replacement syntax have no special meaning.
The engine writes a same-directory temporary file, copies the target’s mode,
flushes and closes it, then uses os.replace to publish atomically. It removes
its temporary on every catchable failure. The caller’s supplied path is never
opened for a partial in-place write.
TypeScript adapter and failure boundary¶
mutator.ts owns only:
parsing the versioned mutation context;
validating the one-edit Pi argument shape;
looking up the current expected revision in its in-memory map;
building the versioned request;
calling the existing injected
exchangeseam;updating the map on a typed successful result;
rendering every refusal as a Pi tool error.
It does not read files, match writable_paths, count anchors, or compute hashes.
The adapter catches malformed context, malformed engine responses, engine
refusals, timeout/start/crash failures, and unexpected local errors. Its tool
execute never rejects. Refusal content is marked as an error through Pi’s
tool-result shape; a failed call does not advance the revision map.
The existing exchange has an injected Spawner, deadline, response parsing,
and process lifecycle. E4 generalizes its request/response types only as far as
the replacement response requires; it does not create a second subprocess
adapter or persistent transport.
Types and module layout¶
Python uses concrete closed types:
Contractgainswritable_paths: tuple[str, ...];CheckRequestandReplaceRequestare frozen dataclasses in a discriminatedProtocolRequestunion;MutationCodeis aStrEnumfor seven response codes:OK, five policy refusals, and the operational-failure codeMUTATION_FAILED;MutationResultandMutationReceiptare frozen dataclasses;response payloads use
TypedDictshapes at the JSON boundary.
TypeScript uses MutationContext, EditInput, ReplacementRequest,
ReplacementResult, and ReplacementResponse interfaces plus the existing
Spawner extension seam. There is no generic tool framework.
New product files:
file |
responsibility |
|---|---|
|
path/revision/anchor checks and atomic replacement |
|
conditional Pi edit adapter |
Existing protocol.py, contract.py, exits.py, orchestrator.ts, and the
package manifest receive only the changes required to connect that vertical
slice.
Tests¶
Default Python tier, no subprocess or network:
contract field absent, valid patterns, and malformed pattern list;
successful literal replacement and exact next revision;
each of the five named policy refusals with a sibling successful replacement;
unsafe path, internal/escaping symlinks, missing/non-regular/non-UTF-8 target, atomic-write failures, mode preservation, literal
$replacement, and temporary cleanup;protocol parsing/rendering for
checkandreplace, including every request field and response shape;stable exit mappings and 100% statement/branch coverage.
Node behavior tier, no model or network:
valid/malformed context and one-edit argument shape;
exact replacement request generation;
revision map advances only on success and a transport failure poisons it;
each engine/transport refusal becomes an error result and no
executepromise rejects;sessions without context do not override Pi’s edit tool.
Marked integration tier:
the real console protocol changes one fixture through the Python process;
the shipped TypeScript adapter uses the real spawner and Python protocol for the success plus unavailable, stale, undeclared, missing, ambiguous, and symlink siblings;
a temporary
pi installloads all three package extensions without touching user settings.
No integration test calls a model. Real filesystem and subprocess behavior remain outside the hermetic default suite.
Done when¶
E4 is complete when:
one fixture replacement succeeds through the shipped TypeScript adapter and real Python protocol;
unavailable revision, stale revision, undeclared path, missing anchor, and ambiguous anchor each return their exact typed refusal and leave the file unchanged;
no adapter path rejects or lets an exception escape Pi;
old contracts still load and
checkunchanged, while a contract withwritable_pathsis enforced only by Python;the package loads the conditional mutator without changing ordinary Pi sessions;
default, integration, Node, Ruff, Pyrefly, strict Sphinx, coverage, and
git diff --checkgates are green;the exact commands and recomputed evidence are recorded in public docs and
docs/sdd.md.