# E4 — One Bounded Replacement Implementation Plan > **For agentic workers:** implement the tasks below in order. Use > `local-ai-pi@8588ba4` only for the incidents and guarantees cited in the > spec. Do not copy its TypeScript mutation engine. **Goal:** Route one exact, unique file replacement through Pi → TypeScript → Python. Python alone enforces the contract path, prior SHA-256 revision, and anchor cardinality; every refusal is typed and leaves the file unchanged. **Architecture:** A conditional Pi `edit` override translates one edit entry to the existing one-shot JSON transport. `protocol.py` dispatches a typed `ReplaceRequest` to a new `mutation.py` core. The core reads and atomically replaces the file, then returns the next revision. E5 will supply the same versioned mutation context inside E3's worktree. **Tech stack:** Python 3.14 standard library plus the existing PyYAML contract loader; TypeScript with erased Pi types and a JSON-schema literal; the existing Node/Pi installation; pytest and Node's built-in test runner. No new runtime dependency and no model call. **Spec:** `docs/superpowers/specs/2026-08-20-e4-bounded-replacement-design.md` ## Correction, 2026-08-21 The accepted plan is implemented with the design correction recorded at the top of the spec. In particular, `expected_sha256` is a required nullable wire field and Python returns `REVISION_UNAVAILABLE`; every target component is checked without following symlinks; transport failures poison the mutation context because publication may already have happened; and the shared one-shot exchange contains asynchronous stdin failure and waits for child close after bounded TERM/KILL teardown. Tests must prove each corrected rule with a successful sibling. This note supersedes the narrower revision lookup, symlink-escape, and inherited-lifecycle steps below. ## Phase-size decision Keep E4 to one existing-file replacement. If implementation asks for file creation, whole-file writes, two replacement entries, fuzzy anchors, symbol analysis, size policy, validation, delivery, transcript handling, a persistent process, or a generic mutation abstraction, stop and defer it. E5 consumes this seam; it does not justify building its orchestration here. --- ### Task 1: Extend the contract without breaking E1–E3 **Files:** - Modify: `src/satyrn_engine/contract.py` - Modify: `tests/test_contract.py` - Add: `tests/fixtures/contracts/writable.yaml` **Produces:** - immutable `Contract.writable_paths`; - omission-compatible empty tuple; - shape validation for a list of non-empty string patterns; - no new contract exit code and no behavior change for existing fixtures. **Steps:** 1. Add failing tests for one valid pattern list, omission, non-list, empty item, and non-string item. 2. Add `writable_paths: tuple[str, ...] = ()` to the frozen dataclass. 3. Parse only the optional field and retain the existing unknown-field policy. 4. Run the contract, check, and CLI suites under the no-process tripwire. **Evidence:** the E1 valid fixture still equals the same contract plus an empty tuple; the E4 fixture exposes its exact patterns. **Commit:** fold into Task 3; the contract field has no E4 user behavior alone. --- ### Task 2: Implement the Python mutation core **Files:** - Create: `src/satyrn_engine/mutation.py` - Create: `tests/test_mutation.py` **Produces:** - `MutationCode` `StrEnum`; - frozen `MutationResult` and `MutationReceipt`; - safe normalized relative paths and Python `fnmatch` enforcement; - exact-byte SHA-256 revision checks; - unique literal UTF-8 anchor replacement; - same-directory atomic replacement with mode preservation. **Steps:** 1. Write the success test first: replace one unique anchor and assert exact content, mode, result path, and next hash. 2. Add one refusal test and one success sibling for undeclared path, unavailable revision, stale revision, missing anchor, and ambiguous anchor. Assert unchanged bytes where the result is determinate. 3. Add malformed-path tests for absolute, parent, empty/dot segments, and NUL. Add operational refusal tests for internal and escaping symlink components; no target path component may be followed. 4. Add operational failure tests for missing/non-regular/non-UTF-8 targets and same-directory write/publish failures. Mocks are restricted to failures the real filesystem cannot safely force. 5. Add literal replacement tests for `$`, backslash, empty `new_text`, and CRLF-preserving exact bytes. 6. Implement with small functions: normalize, contain, hash, count, atomic replace. Do not add a class hierarchy or mutation registry. **Evidence:** every refused operation leaves both file bytes and mode equal to the pre-call snapshot; success changes one occurrence and returns the hash of the bytes on disk. **Commit:** `feat: implement one bounded replacement` --- ### Task 3: Add the typed replacement protocol **Files:** - Modify: `src/satyrn_engine/protocol.py` - Modify: `src/satyrn_engine/exits.py` - Modify: `tests/test_protocol.py` - Modify: `tests/test_exits.py` - Add: `tests/fixtures/protocol/request-replace-valid.json` - Add: `tests/fixtures/protocol/response-replace-ok.json` **Produces:** - frozen `CheckRequest` and `ReplaceRequest` in a closed union; - operation-specific parsing rather than one shared field tuple; - typed replacement response payloads; - `MUTATION_REFUSED = 9`, with detailed `MutationCode` in JSON; - unchanged check request and response fixtures. **Steps:** 1. Split the existing request dataclass into operation-specific frozen types; keep `parse_request` as the single parser seam. 2. Add exact field/type/SHA/path/anchor validation for `replace`, including a required nullable revision, and prove each malformed sibling returns `INVALID_REQUEST` without touching the file. 3. Dispatch with `match` over the parsed request type. 4. Render `result` only for replacement responses; pin exact success and refusal JSON shapes. 5. Map every `MutationCode` refusal/operational result to process exit 9 and keep exit 1 reserved for unexpected bugs. 6. Re-run every old protocol/check fixture unchanged. **Evidence:** the real Python seam returns exact response code plus process status for all five mutation outcomes, and the E2 check compatibility fixture is byte-identical. **Commit:** fold into Task 2; the protocol and core form one Python behavior. --- ### Task 4: Route Pi's one-edit tool through the engine **Files:** - Create: `packages/engine/mutator.ts` - Modify: `packages/engine/orchestrator.ts` - Modify: `packages/engine/package.json` - Create: `tests/test_mutator.mjs` **Produces:** - versioned `MutationContext` parsing; - one conditional `edit` override with a one-entry JSON schema; - operation-specific request and response types; - revision-map advancement only after a successful engine response; - a total Pi `execute` boundary that always resolves to a tool result. **Steps:** 1. Generalize the existing exchange response parser so check responses stay compatible and replacement responses can carry a typed result. Do not add a second spawner. 2. Add Node tests for valid and invalid contexts, absent-context no-op, one-entry request generation, successful map advancement, unavailable and stale revisions, malformed engine output, start failure, timeout, and an unexpected local error. A transport failure poisons the context and the next call starts no process. Each refusal has a successful sibling. 3. Define the Pi edit JSON schema locally as data so the package gains no runtime schema dependency. Require exactly one `{oldText,newText}` entry. 4. Implement `createMutator(exchange, context)` as the test/extension seam and keep all filesystem and contract policy out of TypeScript. 5. Register the tool only from a valid explicit `SATYRN_MUTATION_CONTEXT`. Preserve the built-in tool otherwise. 6. Add `mutator.ts` to the package manifest and keep E3.5 registration-local state unchanged. **Evidence:** a refused engine response returns a Pi error result and the next call uses the old revision; a success returns the new revision and the next call sends it. **Commit:** `feat: route bounded replacement through Pi` --- ### Task 5: Prove the vertical slice with real processes **Files:** - Modify: `tests/test_integration_protocol.py` - Add: `tests/test_integration_mutator.py` - Add or modify: E4 fixtures under `tests/fixtures/` **Produces:** - real console-protocol mutation evidence; - shipped TypeScript adapter → real spawner → real Python evidence; - exact success plus five named refusal siblings; - temporary package installation with all extensions loaded and no user settings mutation. **Steps:** 1. Add a real protocol success fixture and compare its response shape while computing the dynamic SHA independently. 2. Run the shipped TypeScript adapter against the real Python engine in a temporary repository; prove one replacement changes exact bytes. 3. Repeat with unavailable revision, stale revision, undeclared path, missing anchor, ambiguous anchor, and an internal symlink alias. Snapshot bytes and assert no determinate refusal mutates the target. 4. Install the package into a temporary `PI_CODING_AGENT_DIR`; assert the settings list `engine.ts`, `orchestrator.ts`, and `mutator.ts`, and that a session without context does not replace `edit`. 5. Keep every process-marked test out of default CI and prove the tripwire still catches accidental process use in the default tier. **Evidence:** the one fixture crosses the full TypeScript/Python boundary and returns the new SHA; all five policy-refusal fixtures return the exact detailed code and unchanged file. **Commit:** `test: prove the E4 replacement path` --- ### Task 6: Record the phase and its boundaries **Files:** - Modify: `README.md` - Modify: `ROADMAP.md` - Modify: `docs/architecture.md` - Modify: `docs/contributing.md` - Modify: `docs/glossary.md` - Modify: `docs/index.md` - Modify: `docs/usage.md` - Modify: `docs/sdd.md` **Produces:** - E4 usage and exact request/response examples; - a clear statement that E4 mutates only a disposable workspace supplied by its caller and E5 has not yet wrapped it in delivery; - the five E4 outcomes and revision semantics; - recomputed test, coverage, and fixture evidence; - E4 complete and E5 current without changing phase order. **Steps:** 1. Update public docs only after implementation and data shapes settle. 2. Explain why Python owns policy and TypeScript owns translation. 3. Record success plus every sibling refusal by test name in `docs/sdd.md`. 4. Run the final gates below from the final tree and record only measured counts. 5. Check repository weight and concept budget; add only the term `revision` if the public docs actually need it. **Commit:** `docs: document and verify E4` ## Final verification Run from a clean checkout of the completed phase: ```console uv run pytest uv run pytest -m integration uv run pytest -m "" --cov node --test --experimental-strip-types tests/test_loop_breaker.mjs tests/test_transport.mjs tests/test_mutator.mjs node --experimental-strip-types tools/replay_guards.mjs uv run ruff check . uv run pyrefly check uv run --group docs sphinx-build -W -b html docs docs/_build/html git diff --check ``` Record the actual counts from these commands. A changed file on any refusal, an advanced revision after failure, an exception escaping the Pi tool, a TypeScript path-policy decision, a default-tier subprocess, or an ordinary Pi session whose built-in `edit` was replaced without context blocks phase completion.