From 6f67250caaf84368d7621987efa84df7455d8fd1 Mon Sep 17 00:00:00 2001 From: Sollan Systems Date: Sat, 25 Jul 2026 22:08:36 -0400 Subject: [PATCH] docs(plan): commit the executed evidence-wiring plan (Slice 3, PR #102) Committed as an executed artifact, not as current truth. Execution proved four of its statements wrong and they are NOT corrected here, so the record shows what was planned against what shipped: the bound order is bundle -> record -> object (the plan says bundle -> object -> record); doctor's missing_event_store tripwire does NOT catch a store deleted after an ordinary run (the external anchor does); several test counts are stale; and four of its proposed doc assertions were non-discriminating. The shipped reference docs and tests are authoritative wherever they disagree. --- .../2026-07-25-slice3-evidence-wiring.md | 1660 +++++++++++++++++ 1 file changed, 1660 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-slice3-evidence-wiring.md diff --git a/docs/superpowers/plans/2026-07-25-slice3-evidence-wiring.md b/docs/superpowers/plans/2026-07-25-slice3-evidence-wiring.md new file mode 100644 index 0000000..e90df37 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-slice3-evidence-wiring.md @@ -0,0 +1,1660 @@ +# Evidence Wiring (Tamper-Evident Provenance, Slice 3) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the evidence a verified dispatch writes *load-bearing*: the artifacts are content-addressed and their digests are bound into the Slice-1 hash chain at append time; `loop doctor` hash-verifies every discovered record and compares its `policy_digest` against the live `TASKS.json` entry; and `Succeeded` gains an opt-in completion mode whose bar is evidence that is hash-verified, chain-bound (wherever a store exists to bind against) and agreeing with the live goalpost — rather than non-empty path strings. Every silence Slice 2 documented as "recorded, not checked" that this slice closes is named as a tier movement in §17; every silence it does **not** close stays pinned under its true name. + +**Architecture:** `loop/emit.py` splits its writer into a **pure builder** (`build_verify_evidence` — computes the bundle text, the record text, the content-addressed object path via `loop.evidence.artifact_object_path`, and the three `{path, sha256}` artifact-hash entries, writing nothing) and the existing writer (`write_verify_evidence`, which now accepts a prebuilt result). `dispatch_once` builds **before** it appends, passes `artifact_hashes=` to `SQLiteEventStore.append` — a field already inside `loop/chain.py`'s twelve-field preimage (`loop/chain.py:16-20`) — and only then writes the files. `loop/runtime.py` grows an event-store-gated walk that re-hashes each bound path and reports `evidence_chain_mismatch` / `missing_bound_evidence`; `loop/contract.py` composes the already-shipped `verify_evidence()` (`loop/evidence.py:142`) over discovered records and adds a `policy_digest_mismatch` comparison against the live task entry; `loop/completion.py` gains a second completion mode that `emit.terminate`, `loop.contract`, `loop.reducer` and `loop.integrations` each enforce at their own layer. + +**Tech Stack:** Python 3.10+ stdlib only (`hashlib`, `json`, `os`, `pathlib`, `sqlite3`). No new runtime dependencies. Tests via pytest under `uv run`. + +**Baseline: `main` @ `7319dd2`** (the Slice-2 feature squash `c1a386b` plus its plan-docs commit `7319dd2`). PR #94's own gate numbers were **1143 passed / 16 skipped** (pyyaml+jsonschema) and **1058 / 101** (pyyaml-only), measured on the branch head, not on a fresh worktree — **Task 0 re-measures and those two measurements, not PR #94's, are what every later acceptance line adds to.** `pyproject.toml:7` still reads `0.10.0`, so the Slice-2 release cut (plan Task 9) has **not** run: this slice's release-cut task is version-flexible and operator-gated (see Task 10). Verified live at planning time: the only concurrent work in flight is dependabot; issues #37/#38/#39 are help-wanted and touch nothing here. + +**Review status:** revised after a three-lens adversarial review (design-lint, kernel-reality, threat-honesty) — 1 BLOCKER, 2 MAJOR and 4 MINOR findings, all adjudicated; the applied changes and the rulings are in **Post-review design changes** below. Every "current text" citation against `reference/repo-os-contract.md` and `loop/` in this plan was re-verified against `origin/main` @ `7319dd2` during that revision; three citations had already drifted and are now recorded as discharged rather than as work. + +## Global Constraints + +- **Zero new runtime dependencies.** `loop/` imports stdlib + intra-package only; `pyproject.toml` `[project.optional-dependencies]` stays exactly `yaml`, `schemas`, `dev`. +- **event@1 and evidence@1 both stay `@1`, and this slice adds NO new event type.** `artifact_hashes` is already a specified event@1 property (`schemas/event.schema.json:21-32`) and already inside the hash preimage (`loop/chain.py:16-20`); binding evidence needs no new `type` enum member, no new payload row in `_PAYLOAD_REQUIRED_FIELDS` (`loop/events.py:21-31`), and no new structural-fallback branch. See binding decision 1 for the rejected alternative and its justification. **evidence@1 gains no new field at all** — the content-addressed object location is *derived* from `sha256` by `artifact_object_path`, not stored. +- **The one schema edit is `schemas/terminal.schema.json`'s `completion_policy.mode`**, whose `const: "all_required"` (`:80`) becomes a two-member `enum`. That is permissive-additive: every shipped record still validates, `required` is untouched, and terminal@1 stays `@1`. +- **Validation-mode parity.** Every new check runs identically in `jsonschema` and `structural-fallback` mode. A finding that fires in one mode and not the other is a bug, not a shortcut (`reference/repo-os-contract.md` commits to fallback parity). +- **Absent-everything doctor byte-stability.** With no `.loop/evidence/`, no runner bundle, and no `.loop/events.db`, `doctor_report` output is byte-identical to today's. This slice adds **no new top-level doctor key and no new key inside the `event_store` block**, so `scripts/test_doctor_eventstore.py:107` (`test_absent_event_store_matches_pre_slice_doctor_shape`) and `scripts/test_doctor_evidence.py:54` (`test_absent_evidence_directory_is_a_byte_stable_no_op`) both pass **unmodified**. +- **Typed fail-loud everywhere.** No new path may skip on error (R007: an errored verifier must FAIL, not skip). An unreadable object, a colliding object, a non-canonicalizable task, and an unverifiable terminal each raise a typed `EmitError`; a store that cannot be read keeps reporting its own `error_code` and never silently drops the binding walk. +- **Honest-recording, never proof.** Chain-binding inherits Slice 1's trust model *exactly*: a worker that can rewrite `.loop/` can rewrite the store too (`reference/repo-os-contract.md:592-595`, the DROP-TRIGGER limitation). Binding makes tampering **detectable against an anchor**, not impossible. The forbidden claim is "tamper-proof evidence"; the shipped claim is **"evidence whose bytes are bound into an anchorable chain"**. Docs and pinned honest-limitation tests must both carry it. +- **FLIP, never delete, a pinned honest limitation.** Slice 2 shipped ten `*_pinned` tests. Four of them describe silences this slice closes. They are **rewritten in place** — renamed to the new honest outcome, with the residual half retained and a docstring naming the release that changed it. Deleting one converts a documented limitation into an undocumented one (Slice-2 whole-branch review, T6 ledger row). +- **Succeeded tightening is opt-in, never retroactive.** Both shipped examples' terminals carry evidence entries that are *bundle paths*, not evidence@1 records (`examples/coverage-repair/terminal_state.json:9`, `examples/flaky-test-triage/.loop/terminal_state.json:9`), and `dispatch_once`'s auto-terminal writes `evidence: ["RUNLOG.md"]` (`loop/runner.py:225`). A blanket tightening would fail G1 on the repo's own dogfood in the same commit that ships it. The bar moves through a declared `completion_policy.mode`; see binding decision 6 and its compat table. +- **`evals/cases/structural.json` is untouched** — no new skill, reference, template, or *schema file* ships. Editing existing schemas is additive. +- **Version bumps live only in the release-cut PR** (Task 10). Tasks 0-9 land as one feature PR with no version change. +- Repo env quirks: no system pytest — run tests via `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider ...`; the Bash deny-list blocks `rm`, bare `cd`, `VAR=` prefixes, `timeout`, `printf`, `source` — use `git -C`, absolute paths, `bash -c`. A Rust `git` wrapper on PATH can serve **stale** `git log` tips; trust `git rev-parse` / `git cat-file`. + +--- + +## Program context (what this plan is Slice 3 of) + +Source: `review/2026-07-24-graph-engineering-report-assessment.md`. The accepted program is **tamper-evident provenance**; the "graph engineering" framing was rejected (assessment §2 blocker 5). + +| Slice | Scope contract | Status | +|---|---|---| +| 1 | Hash-linked chain, `loop migrate`, doctor chain gate + `--expect-chain-head`, honest-limitation tests/docs. | **shipped v0.10.0** (`7002580` / `91bf36a`) | +| 2 | Verifier identity + independence: `verified_by.code_digest`/`policy_digest` on evidence@1, `produced_by.executor == verified_by.by` as a doctor finding, visible/held-out partition in verify bundles. | **merged as PR #94** (`c1a386b`); plan committed `7319dd2`. Release cut **not yet run** — `pyproject.toml` still reads `0.10.0`. | +| **3 (this plan)** | Wire evidence@1 into writer + doctor (the §17 deferral): content-addressed objects via `artifact_object_path`, doctor **hash-verifies** evidence referenced by criteria, doctor compares a record's `policy_digest` against the live TASKS.json entry, evidence artifacts bind into the hash chain, `Succeeded` tightens from non-empty paths to hash-verified evidence. Ships as **v0.12.0**, or **v0.11.0** if the operator has still not cut Slice 2 (Task 10 decides from `pyproject.toml`). | now | +| 4 | CI-attested verdict (in-toto Statement shape, GitHub OIDC keyless). Needs its own ADR. | after Slice 3 | +| 5 | Docs/interop pass (Mermaid in reference/docs only, trace-context-as-data, Cypher recipe). | anytime | + +Rejected permanently (do not resurrect): `graph` CLI verbs + query DSL, FastAPI HTTP API/SDK, Neo4j/Qdrant integrations, unanchored Merkle checkpoints, local DSSE/HMAC signing. + +**Assessment line this slice implements** — P3 (assessment:91): *"The real residue is the already-scheduled §17 wiring: evidence@1 into writer + doctor, tightening `Succeeded` from non-empty-paths to hash-verified evidence."* Assessment §1's accuracy nuance is the defect being closed: *"evidence@1 is NOT wired end-to-end … no production writer passes `artifact_hashes=`; `terminal.evidence` is plain path strings, not hashed records."* Slice 2 made the runner a production writer; this slice makes `artifact_hashes=` non-empty and makes the strings verifiable. + +--- + +## Post-review design changes + +Seven findings, each adjudicated below. The house rule applied to every conflict: **move toward threat-honesty** — when a cheaper option was to leave a limitation unnamed, the limitation is named and pinned instead. + +| # | Lens / severity | Finding | Ruling | +|---|---|---|---| +| 1 | design-lint MAJOR | Task 3's stated test count is wrong. | **Applied** — duplicate of #2; adjudicated there. | +| 2 | kernel-reality MAJOR | Task 3's literal test block defines 8 single tests + 2 `parametrize(_MODES)` tests = **12** collected items, not 11; the Task 9 row and grand total inherit the error. | **Applied.** Task 3 now reads 12 extras / 10 passed 2 skipped fallback; the Task 9 row reads 12. The grand total is recomputed from scratch (it also absorbs the new tests from #6 and #7): **90 collected / 82 fallback passes / 8 jsonschema skips.** Re-counted every other task's block the same way while making the fix; Tasks 1/2/4/6/8 were already exact. | +| 3 | threat-honesty **BLOCKER** | The strict completion mode requires each `Succeeded` evidence entry to hash-verify, but never that the record is **chain-bound** or that its `policy_digest` agrees with the live task. A worker with `.loop/` write access can hand-write a bundle, its digest, and a matching record, and satisfy `all_required_verified_evidence` with **zero dispatch and zero event**. The plan's headline claim did not name that residual anywhere. | **Applied, closing rather than only pinning.** New **decision 14**: at the two layers that hold a workspace (`emit.terminate`, `loop.contract`) the strict mode additionally requires (a) every cited record to be **chain-bound** whenever an event store exists, and (b) the record's `policy_digest` to equal the live goalpost whenever it names a live TASKS.json task. The irreducible residuals — a **store-less** contract (writer-API-only) can never chain-bind, and the reducer/`to_terminal_state` layers are pure and can only check shape — are now **pinned** (Task 7 pins 13-14, Task 5's two new refusal tests) and named in §17's completion-mode subsection (Task 8 Step 4). Rejected: requiring a store unconditionally, which would make the mode unusable for the writer-API path this plan pins as supported; rejected equally: leaving the gap and only writing a sentence, because the gap is the slice's headline capability. | +| 4 | threat-honesty MAJOR | Two Slice-2 review items — M-7 (§17's `unresolvable` row) and ledger row T6 (workspace-root symlink-loop pin unpinned) — appear nowhere in the carry-forward table, breaking the table's own "explicit task or recorded exclusion" discipline. | **Applied, split by verification.** M-7 was re-checked against `origin/main`: `reference/repo-os-contract.md:869` **already** reads *"raised `OSError` (or pathlib's `RuntimeError` on Python ≤3.12 for a symlink loop)"* — fixed in Slice 2's pre-merge wave, so it lands as carry-forward row **10: already resolved, no action**, not as doc work. T6 is genuinely open (`scripts/test_verifier_identity.py:65` pins the argv[0] symlink loop; `loop/verifier.py:64` resolves the workspace root inside the same `try`, so the workspace-root variant is covered behaviorally but unpinned) → carry-forward row **11**, discharged by new **Task 7 Step 2b**. | +| 5 | kernel-reality MINOR | Carry-forward row 7 claims Task 1 "incidentally fixes" M-1 (`_EVIDENCE_SCHEMA_ID` literal fork). `loop/emit.py:32` on `origin/main` already reads `from .evidence import EVIDENCE_SCHEMA_ID`. | **Applied.** Row 7 now records M-1 as **already resolved on `origin/main`**; Task 1 Step 3's rationale keeps only M-2 (verified still open — `origin/main`'s `loop/emit.py` contains no `validate_evidence` call). A PR body claiming "fixed M-1" would have been false. | +| 6 | kernel-reality + threat-honesty MINOR (×2) | Task 8 Step 5 tells the implementer to fix a heading that says "three honest tiers"; `reference/repo-os-contract.md:951` already says **four**. | **Applied.** The heading clause is struck (M-12 recorded discharged in row 7), and Task 8 gains a standing instruction to re-diff every quoted "current" sentence against the live file immediately before editing it — the same discipline Task 0 already applies to test counts. | +| 7 | kernel-reality MINOR | Task 4 Step 4's Implement snippet omits, in code, the "only for records with no structural issues" gate its own prose requires; `verify_evidence()` re-runs `validate_evidence` internally, so an unconditional call double-reports a malformed record. | **Applied.** The snippet now shows the captured `record_issues` and the `if not record_issues:` guard literally. | + +--- + +## Carry-forwards from Slice 2 (each is an explicit task or a recorded exclusion) + +Sources: PR #94's "Deferred (issues to be filed post-merge)" list, and the whole-branch review's sections (b) Findings and (c) Ledger triage. + +| # | Carry-forward | Where it lands | +|---|---|---| +| 1 | **FLIP, never delete**, `test_rewriting_the_evidence_record_to_independent_identities_leaves_doctor_clean_pinned` (`scripts/test_adversarial_verifier_identity.py:81`) and `test_deleting_both_bundle_and_record_leaves_doctor_clean_pinned` (`:67`) when chain-binding lands. | Task 7 Step 1 — both rewritten in place, both halves kept | +| 2 | **`missing_verify_bundle` counterpart** — a committed iteration with no bundle becomes detectable (review I-2, second silence; ledger T4 W1). | Task 3 — `missing_bound_evidence`; naming ruling in binding decision 4 | +| 3 | **Reverse record-without-bundle check** — a record whose `uri` does not resolve (review I-2, first silence). | Task 4 — composed `verify_evidence()` surfaces `missing_evidence_path` verbatim | +| 4 | **`scripts/metrics.py` reads `verifier.source`** so injected-callable bundles stop counting as gate evidence — the §17 sentence added in the Slice-2 fix wave (`reference/repo-os-contract.md:926-929`) promises exactly this. | Task 6 | +| 5 | **`policy_digest` live comparison** closes the recorded-not-checked tier for goalposts. | Task 4 | +| 6 | Review M-4: `_RUNNER_BUNDLE_RE` uses `\d+`, admitting Unicode digits (`loop/contract.py:637`). | **Already fixed** in the merged branch — the live regex is `verify-iter([0-9]+)\.json`. Recorded as discharged; the wider `re.fullmatch` convention sweep stays a separate follow-up. | +| 7 | Review M-1 `_EVIDENCE_SCHEMA_ID` literal fork; M-2 writer does not self-validate; M-3 bare `KeyError` on malformed `code_identity`; M-12 §17 heading "three honest tiers" over four bullets. | **M-1 and M-12 are already resolved on `origin/main`** — re-verified during the post-review pass: `loop/emit.py:32` reads `from .evidence import EVIDENCE_SCHEMA_ID` (used at `:462`), and `reference/repo-os-contract.md:951` already reads "in four honest tiers". Both were fixed in Slice 2's pre-merge wave, after the review transcript this table cites; **no action, and neither may be claimed as fixed in this slice's PR body.** M-2 is verified still open (no `validate_evidence` call anywhere in `origin/main`'s `loop/emit.py`) and Task 1 Step 3 discharges it *incidentally*, because the builder validates its own record before returning. M-3 stays **excluded** and open: a foreign mapping should still fail loudly, and `KeyError` is loud. | +| 8 | Review M-5 non-UTF-8 record → uncaught `UnicodeDecodeError`; M-6 unimplemented tasks@1 fallback type-check; M-9 unstripped CLI identities; M-10 `_USAGE` echo parity; M-11 `test_conformance.py` import hygiene; M-13 README null-digest nuance. | **Excluded** — recorded here so the exclusion is deliberate. None is a false claim; each is a separate small PR. | +| 9 | Review M-8: "an evidence record MUST NOT be named `verify-*.json`" is unenforced. | **Excluded, with a reason:** the enforcement point is `metrics.py`, and Task 6 already changes that file's bundle-loading contract. Stacking a second behavioral change on the same function in the same slice makes the FCR delta unattributable. | +| 10 | Review M-7: §17's `unresolvable` row says "resolving argv[0] raised `OSError`" while a symlink loop raises pathlib's `RuntimeError` on Python ≤3.12. | **Already resolved** — `reference/repo-os-contract.md:869` on `origin/main` reads *"resolving argv[0] raised `OSError` (or pathlib's `RuntimeError` on Python ≤3.12 for a symlink loop), permission-denied parent, or name too long"*, matching the code comment at `loop/verifier.py:76-79`. Recorded as discharged; **no Task-8 edit.** (This row exists because the review's Minor list omitted it from the original table — the omission, not the finding, was the defect.) | +| 11 | Review ledger T6: the **workspace-root** symlink-loop pin is unpinned (the argv[0] variant is pinned at `scripts/test_verifier_identity.py:65`). | **Task 7 Step 2b** — one test added to `scripts/test_verifier_identity.py`. `loop/verifier.py:64` resolves `Path(workspace).resolve()` **inside** the same `try` whose `except (OSError, RuntimeError)` returns `unresolvable`, so the behavior is already correct on both 3.10-3.12 and 3.13; what is missing is the pin that keeps it correct. Cheap, in-charter for a slice whose product is an honest boundary, and it removes the last untriaged review item. | + +--- + +## Design decisions (binding) + +1. **Chain-binding uses the existing `artifact_hashes` envelope field on `iteration_appended`, NOT a new event type.** Slice 2's decision 15 and `reference/repo-os-contract.md:972-973` both assert that binding "requires a new event type"; that was an assumption, and the live tree refutes it. `artifact_hashes` is a specified event@1 property (`schemas/event.schema.json:21-32`), is validated structurally today (`loop/events.py:210-219`), and is field #11 of the twelve-field canonical preimage (`loop/chain.py:16-20`) — so a digest placed there is *already* covered by `event_hash` and by `--expect-chain-head`. A new type would additionally require: a `type` enum edit, a `_PAYLOAD_REQUIRED_FIELDS` row, a structural-fallback branch, a reducer projection branch, a terminal-immutability rule (`loop/reducer.py:96-100` rejects any post-terminal event but `terminal_superseded`), and a **second append per dispatch** — breaking the load-bearing invariant commented at `loop/runner.py:208` ("each `dispatch_once` invocation appends at most one event") and introducing a new half-committed failure mode. **Ruling: reuse the field.** `loop/events.py` and `schemas/event.schema.json` are untouched by this slice; `loop/reducer.py` changes only for decision 6's structural half. +2. **The builder is pure and runs before the append; the writer runs after.** `build_verify_evidence()` performs zero I/O beyond reading `.loop/state.json` for the contract precondition, and returns a frozen `BuiltEvidence` carrying the exact bytes that will be written plus their digests. `dispatch_once` orders: identity → verifier → `iteration_id` → **build** → **append (with `artifact_hashes`)** → **write**. Consequences: (a) a build failure (non-canonicalizable task, bad `code_identity`) now aborts **before** anything is committed, which is strictly better than Slice 2's committed-then-failed path; (b) the pre-commit crash pin `test_crash_injection_before_iteration_event_commit_leaves_no_partial_dispatch` (`scripts/test_runner_dispatch.py`) still sees a byte-identical tree, because the builder writes nothing; (c) the post-commit crash window is unchanged in *existence* but no longer silent — the committed event names artifacts that are missing, which is exactly `missing_bound_evidence`. +3. **Three artifacts are bound, and the object is derived, never declared.** `artifact_hashes` carries, in this order: the bundle (`.loop/artifacts/verify-iter.json` → bundle sha256), the content-addressed object (`artifact_object_path(workspace, bundle_sha)` → the same sha256), and the record (`.loop/evidence/evidence-iter.json` → record sha256). The object's location is a pure function of the digest (`loop/evidence.py:217-219`), so **evidence@1 needs no `object_uri` field** and a third-party reader can find the object from `record["sha256"]` alone. The object is the recovery source when the friendly-named bundle is swapped: the swap fails doctor *and* the original bytes are still on disk. +4. **Issue codes reuse `verify_evidence()`'s vocabulary verbatim; only four codes are new.** `reference/repo-os-contract.md:1109` sets the doctrine — doctor composes a verb and surfaces "the identical issue code the `status`/`replay` verbs already use". Doctor therefore reports `hash_mismatch`, `missing_evidence_path`, `workspace_escape`, `not_a_file` and `invalid_uri` straight out of `verify_evidence` (`loop/evidence.py:155-213`). The `missing_verify_bundle` counterpart the Slice-2 review asked for **is** `missing_evidence_path` — recorded here so the carry-forward is visibly discharged rather than silently renamed, and given its own "do not confuse with `missing_evidence_record`" row in §22. The four genuinely new codes are `evidence_chain_mismatch`, `missing_bound_evidence`, `policy_digest_mismatch`, `unverified_evidence_terminal`. +5. **`policy_digest` is compared only against the LATEST record per `task_id`, and only when that task still exists in TASKS.json.** A run legitimately produces several records for one task (attempt 1 red, attempt 2 green); the older ones describe goalposts that were current when they were written, and comparing them would make every honest re-verification a permanent doctor failure. The latest record (highest `iteration_id` parsed from the record filename, ties broken by filename sort) is the one currently backing the task's claim, so it is the one that must agree with the live entry. A record naming a `task_id` absent from TASKS.json is **not** compared (a renamed or removed task is a replan, not a forgery) — pinned as an honest limitation. The remedy for a legitimately-moved goalpost is therefore constructive and does not require deleting evidence: re-verify under the new goalpost and the new record becomes the latest. `code_digest` is **not** compared against a live re-hash — the verifier file legitimately changes between runs and a comparison without a declared baseline would fire on every honest edit. That non-movement is named in §17. +6. **`Succeeded`'s evidence bar moves through a declared completion mode, enforced at four layers at the strength each layer can honestly reach.** `loop/completion.py` gains `all_required_verified_evidence` alongside `all_required`; `criteria_satisfy_completion` treats both identically (the criteria half is unchanged) and a new pure predicate `policy_requires_verified_evidence(policy)` names the difference. Enforcement: + - `emit.terminate` (write time, has the workspace) — every `evidence[]` entry must resolve to a readable evidence@1 record that `validate_evidence` accepts and `verify_evidence` hash-verifies; otherwise `EmitError`. **Extended by decision 14** (post-review): this layer also requires chain-boundness when a store exists, and goalpost agreement when the record names a live task. Read decision 14 before implementing this bullet — decision 6's bar alone is satisfiable by a fabricated record. + - `loop.contract` (read time, has the workspace) — the same check **including decision 14's two additions**, reported as `unverified_evidence_terminal`. "The same check" is literal: one shared predicate, two callers (Task 5 Step 4). + - `loop.reducer` (pure fold, no I/O) — the **structural half only**: under the strict mode every evidence entry must be a workspace-relative `.loop/evidence/*.json` path. This is exactly §16's "two-layer enforcement, deliberately split" (`reference/repo-os-contract.md:629-642`): the reducer cannot touch a filesystem and must not pretend to. + - `loop.integrations.to_terminal_state` (pure projection, no workspace) — the same structural half, returning `FailedUnverifiable` rather than raising, matching its existing fail-closed style (`loop/integrations.py:170-173`). + + Compat: `normalize_completion_policy(None)` still returns `{"mode": "all_required"}` (`loop/completion.py:30-31`), so every record written before this slice — including both dogfood examples, which carry no `completion_policy` at all — is untouched. +7. **`dispatch_once`'s auto-terminal adopts the strict mode only when it can honestly satisfy it.** When every task in TASKS.json has at least one evidence record from this run, the terminal is written with `completion_policy: {"mode": "all_required_verified_evidence"}` and `evidence` listing those records; otherwise it keeps `{"mode": "all_required"}` and `evidence: ["RUNLOG.md"]`, and its `reason` says which tasks had no record. Choosing the weaker policy silently would be a self-serving downgrade; saying so in the record is the honest form. Both branches are pinned. +8. **Doctor's binding walk lives in `loop/runtime.py`, the evidence walk stays in `loop/contract.py`.** `missing_bound_evidence` and `evidence_chain_mismatch` are event-store consistency findings and belong beside `chain_columns_missing` inside `event_consistency_issues` (`loop/runtime.py:282-332`), which is already gated on the store existing — so absent-store byte-stability is preserved for free. `hash_mismatch` / `missing_evidence_path` / `policy_digest_mismatch` need no store and stay in `_validate_evidence_records` (`loop/contract.py:684-711`), preserving Slice-2 decision 8. Neither adds a reported key. +9. **The binding walk pays for one extra read-only fold, deliberately.** `event_consistency_issues` already calls `status_report` and `replay_report`, each of which does its own `_events()` read. The walk needs the raw events (it reads envelope-level `artifact_hashes`, which no report returns) and adding a field to either report's dict would leak into the `loop status` / `loop replay` CLI JSON. A third `_read_store` pass through the existing read-only, sidecar-free helper is the low-blast-radius choice; the cost is recorded rather than hidden. +10. **A legacy event with empty `artifact_hashes` is never a finding.** Every event committed before this slice carries `artifact_hashes: []`. The binding walk is driven by what an event *declares*, so an old dispatch is silent and a new one is checked. The consequence — a pre-Slice-3 iteration whose evidence was deleted is still undetectable — is a named residual, not an oversight, and it cannot be repaired: the append-only triggers forbid UPDATE (`loop/events.py:50-57`), so no backfill is possible. Same reasoning as `loop migrate`'s refusal to backfill hashes (`reference/repo-os-contract.md:1150-1159`). +11. **Object writes are create-once and collision-loud.** The object is written with `_atomic_create_text` (`loop/emit.py:97-115`, the same hard-link create-once primitive the immutable terminal uses). A `FileExistsError` is resolved by re-reading the existing object: identical bytes → idempotent success (a re-run of the same dispatch); different bytes → `EmitError` naming the digest, because a sha256 collision or a corrupted object store must never be silently accepted. +12. **`loop simulate` stays decision-level.** `_empty_prediction` gains no field, per Slice-2 decision 16. A dispatch always writes the bundle, record and object, so a boolean predicting `True` every time carries no information. Restated in §17 so a reader does not infer that simulate enumerates the object store. +13. **`metrics.py` excludes only an explicit `injected_callable` source.** A bundle with no `verifier` block (every bundle written before Slice 2, including both shipped examples' four) has an unknown source and keeps counting — grandfathering by absence, not by guess. Only `verifier.source == "injected_callable"` is excluded, and the excluded names are reported under `provenance` so the exclusion is visible rather than a silent FCR shift. +14. **The strict mode's bar is hash-verified *and chain-bound and goalpost-agreeing*, at every layer that can honestly reach it.** *(Added post-review: BLOCKER, threat-honesty.)* Decision 6's original bar — `validate_evidence` + `verify_evidence` per entry — proves only that a record is *internally self-consistent*: the file it points at exists and hashes to the digest the record declares. Both halves of that pair are worker-writable, and `verification_policy_digest` is a public pure function over public TASKS.json data, so a worker with `.loop/` write access could hand-write a bundle, its digest, a matching record and a correct-looking `policy_digest`, then call `terminate(..., completion_policy=VERIFIED_EVIDENCE_MODE)` with **no dispatch, no verifier execution and no event ever appended** — and both `emit` and `doctor` would accept it. Shipping the slice's headline capability with that unnamed would be exactly the false-completion class this repo exists to close. The bar is therefore extended at the two workspace-bearing layers: + - **`emit.terminate` and `loop.contract`** additionally require, per cited entry, that (a) the record's workspace-relative path appears in the set of artifacts some event bound into the chain, **and its bound digest equals the record file's current sha256** — evaluated only when `.loop/events.db` exists; and (b) when `produced_by.task_id` names a task that is still in TASKS.json, the record's `verified_by.policy_digest` equals `verification_policy_digest` of that live entry. Failure is `EmitError` at write time and `unverified_evidence_terminal` at read time, with a message naming which of the three sub-checks failed (unverified / unbound / stale goalpost). + - **The `policy_digest` half here is per-cited-record, not latest-per-task** — deliberately stricter than decision 5's doctor-wide rule, and the difference is named in §17. Decision 5's rule protects an *incidental* older record from a permanent failure; a terminal that *cites* a record is asserting that this record backs `Succeeded` right now, so a stale goalpost in a cited record is a false completion, not an artifact of history. + - **`loop.reducer` and `loop.integrations.to_terminal_state` are unchanged** — still the structural `.loop/evidence/*.json` half. They are pure and hold no workspace; making them *appear* to enforce binding would be the same pretending decision 6 already refuses. + - **Two residuals, both pinned and both documented.** (i) A contract with **no event store** — the writer-API path this plan pins as supported — cannot chain-bind anything, so there the strict mode degrades to hash-verification + goalpost agreement, and a hand-written record satisfies it. That degradation is *silent by construction* but not undocumented: doctor's existing `missing_event_store` tripwire (`loop/runtime.py:293`) is what catches deleting a store out from under a real run, and §17 says plainly that the strict mode's strength is store-dependent. (ii) Chain-binding still proves *bytes*, never *provenance*: a worker who can rewrite `.loop/` can rewrite the store, so without `--expect-chain-head` a full rewrite of artifacts **and** chain satisfies the strict mode. Pins 13 and 14 in Task 7 hold both, each with a positive control. + - **Rejected:** requiring a store unconditionally (breaks the pinned writer-API path and would fail `test_a_record_written_outside_a_dispatch_is_never_chain_bound_pinned`'s own contract); and leaving the gap closed only by prose (a sentence in §17 with no test is exactly the "recorded, not checked" tier this slice exists to drain). + +--- + +## File structure + +| File | Change | Responsibility | +|---|---|---| +| `loop/emit.py` | modify (~406-484) | `BuiltEvidence` + `build_verify_evidence()`; content-addressed object write; `write_verify_evidence(..., built=)`; strict-mode evidence bar in `terminate`. | +| `loop/runner.py` | modify (~245-276, ~219-233) | Build before append; pass `artifact_hashes=`; auto-terminal policy selection. | +| `loop/runtime.py` | modify (append + ~311) | `bound_artifact_digests()` (shared lookup, decision 14); `_bound_evidence_issues()`; composed into `event_consistency_issues`. | +| `loop/contract.py` | modify (~684-711, + new helpers, + `validate_contract` call site ~814) | Hash-verify discovered records; `policy_digest_mismatch`; `unverified_evidence_terminal`. | +| `loop/completion.py` | modify | `all_required_verified_evidence`; `policy_requires_verified_evidence()`. | +| `loop/reducer.py` | modify (~32-50) | Structural half of the strict evidence bar. | +| `loop/integrations.py` | modify (~170-173) | Structural half, fail-closed projection. | +| `schemas/terminal.schema.json` | modify (`:78-82`) | `completion_policy.mode` const → two-member enum. | +| `scripts/metrics.py` | modify (~230-252, ~458-460) | Read `verifier.source`; partition gate-evidence bundles; report exclusions. | +| `scripts/test_verify_evidence_objects.py` | **create** | Builder purity, object store, artifact-hash set, atomicity. | +| `scripts/test_evidence_binding_runner.py` | **create** | Runner build→append→write ordering and the bound digests. | +| `scripts/test_doctor_evidence_binding.py` | **create** | `evidence_chain_mismatch`, `missing_bound_evidence`, legacy silence, zero-write. | +| `scripts/test_doctor_evidence_verification.py` | **create** | Hash verification and `policy_digest_mismatch` (both modes). | +| `scripts/test_completion_verified_evidence.py` | **create** | The new mode across all four enforcement layers. | +| `scripts/test_metrics_verifier_source.py` | **create** | Gate-evidence partition and the published example metrics. | +| `scripts/test_adversarial_evidence_binding.py` | **create** | 14 pins: what binding does and does not catch (13-14 are decision 14's residuals). | +| `scripts/test_adversarial_verifier_identity.py` | modify (4 tests rewritten in place) | The mandated flips. | +| `scripts/test_verifier_identity.py` | modify (+1 test) | Carry-forward 11 — the workspace-root symlink-loop pin (Task 7 Step 2b). | +| `scripts/test_doctor_evidence.py` | modify (helpers only) | Fixtures must produce hash-consistent pairs; test bodies unchanged. | +| `scripts/test_conformance.py` | extend | Artifact-binding vector + three doc pins. | +| `reference/repo-os-contract.md` | modify §16, §17, §22 | Binding, tier movements, four new codes, the stale event-type list. | +| `reference/safety-and-approvals.md` | modify §5 | One sentence: the goalpost check is now live. | +| `loop/evidence.py`, `schemas/evidence.schema.json` | modify (docstring / description only) | Retire the "does not yet hash-verify" claims. | +| `README.md` | modify | One capability sentence + one honest clause (Task 8); version surfaces (Task 10). | +| `CHANGELOG.md`, `pyproject.toml`, `.claude-plugin/plugin.json`, `scripts/test_docs_version.py` | modify (Task 10) | Release cut. | + +Interfaces produced (exact signatures): + +```python +# loop/emit.py +@dataclass(frozen=True) +class BuiltEvidence: + bundle_path: Path + record_path: Path + object_path: Path + bundle_text: str + record_text: str + sha256: str # of bundle_text + record_sha256: str # of record_text + artifact_hashes: tuple[dict[str, str], ...] # ({"path","sha256"}) bundle, object, record + +def build_verify_evidence(target: str | Path, *, run_id: str, iteration_id: int, + task: Mapping[str, Any], passed: bool, + code_identity: Mapping[str, Any], summary: str = "", + executor: str | None = None, verifier_identity: str | None = None, + attempt: int | None = None) -> BuiltEvidence: ... + +def write_verify_evidence(target, *, run_id, iteration_id, task, passed, code_identity, + summary="", executor=None, verifier_identity=None, + attempt=None, built: BuiltEvidence | None = None) -> dict[str, Any]: ... +# {"bundle": Path, "evidence": Path, "object": Path, "sha256": str} ("object" is new) + +# loop/completion.py +VERIFIED_EVIDENCE_MODE: Final[str] = "all_required_verified_evidence" +def policy_requires_verified_evidence(policy: object | None = None) -> bool: ... +def evidence_entry_is_record_shaped(entry: object) -> bool: ... # pure, no I/O + +# loop/runtime.py +def _bound_evidence_issues(target: str | Path, mode: str | None) -> list[dict[str, Any]]: ... +def bound_artifact_digests(target: str | Path, mode: str | None = None) -> dict[str, str] | None: ... +# {workspace-relative POSIX path: sha256} for every artifact any event bound. +# None means "no event store" (caller degrades and says so, decision 14); +# {} means "a store exists and bound nothing"; RuntimeStoreError on an unreadable store. + +# loop/contract.py +def _verify_discovered_record(record, paths, record_path, issues) -> None: ... +def _policy_digest_issues(paths, records: list[tuple[int, Path, dict]], issues) -> None: ... +def _check_verified_evidence_terminal(terminal, paths, issues) -> None: ... +def _strict_evidence_failure(entry: str, paths, bound: dict[str, str] | None) -> str | None: ... +# Shared decision-14 predicate: None when the entry passes all three sub-checks, +# else a human-readable detail naming which one failed. emit.terminate raises on it; +# loop.contract reports it as unverified_evidence_terminal. One definition, two callers. +# new issue codes: evidence_chain_mismatch, missing_bound_evidence, +# policy_digest_mismatch, unverified_evidence_terminal +``` + +--- + +### Task 0: Branch setup and baseline measurement + +**Files:** none. + +- [ ] **Step 1:** Confirm the branch point with `git -C /mnt/c/Dev/projects/loop-engineer rev-parse origin/main` and `git -C ... cat-file -p origin/main` — **not** `git log`, whose tip a Rust wrapper on PATH can serve stale. +- [ ] **Step 2:** `git -C /mnt/c/Dev/projects/loop-engineer checkout -b feat/evidence-wiring main` (main protection blocks direct pushes; all work lands via PR). +- [ ] **Step 3:** Create the baseline worktree **detached** (`git worktree add ` refuses a branch already checked out): `git -C /mnt/c/Dev/projects/loop-engineer worktree add --detach /mnt/c/Dev/projects/loop-engineer/.tmp/s3-base feat/evidence-wiring`. +- [ ] **Step 4:** Extras baseline: `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider /mnt/c/Dev/projects/loop-engineer/.tmp/s3-base/scripts`. +- [ ] **Step 5:** Fallback baseline in the same worktree: `uv run --with pyyaml --with pytest python3 -B -m pytest -q -p no:cacheprovider /mnt/c/Dev/projects/loop-engineer/.tmp/s3-base/scripts`. +- [ ] **Step 6:** Record both `(passed, skipped)` pairs at the top of the PR draft as `BASE_EXTRAS` and `BASE_FALLBACK`, each with its dependency set named. Do **not** reuse PR #94's 1143/16 and 1058/101 — those were branch-head numbers and the live checkout reads ±2 against a fresh worktree. `--with hypothesis` (CI installs it) swings ~+10/−1; never compare across dependency sets. +- [ ] **Step 7:** Record the pre-change dogfood metrics so Task 6 has a before: `uv run --with pyyaml python3 -B scripts/metrics.py examples/flaky-test-triage` and `... examples/coverage-repair`. Note FCR, RP and `evidence_backed` verbatim. + +**Acceptance:** two recorded `(passed, skipped)` pairs, two recorded metric scorecards, detached worktree left in place for Task 9. + +--- + +### Task 1: `loop/emit.py` — pure builder, content-addressed object, bound digests + +**Files:** Modify `loop/emit.py`; Create `scripts/test_verify_evidence_objects.py`. + +- [ ] **Step 1: Write the failing tests** — `scripts/test_verify_evidence_objects.py`: + +```python +"""build_verify_evidence purity, the content-addressed object, and the bound digest set.""" +from __future__ import annotations + +import hashlib +import json + +import pytest + +from loop import emit +from loop.evidence import artifact_object_path +from loop.verifier import executed_verifier_identity + + +def _task(**overrides): + base = {"id": "T-1", "title": "t", "status": "pending", "criterion_ref": "C-1", + "verify": "./scripts/verify-fast.sh", "depends_on": [], "attempts": 0, "evidence": None} + base.update(overrides) + return base + + +def _ws(tmp_path): + workspace = tmp_path / "workspace" + emit.open_contract(workspace) + script = workspace / "scripts" / "verify-fast.sh" + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + return workspace + + +def _tree(workspace): + return {str(p.relative_to(workspace)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in workspace.rglob("*") if p.is_file()} + + +def _build(workspace, *, iteration_id=1, task=None, passed=True, **kwargs): + task = task or _task() + kwargs.setdefault("code_identity", executed_verifier_identity(task["verify"], workspace)) + return emit.build_verify_evidence(workspace, run_id="run-1", iteration_id=iteration_id, + task=task, passed=passed, **kwargs) + + +def test_build_writes_nothing(tmp_path): + workspace = _ws(tmp_path) + before = _tree(workspace) + _build(workspace) + assert _tree(workspace) == before + + +def test_build_and_write_agree_on_every_byte(tmp_path): + workspace = _ws(tmp_path) + built = _build(workspace) + written = emit.write_verify_evidence(workspace, run_id="run-1", iteration_id=1, + task=_task(), passed=True, + code_identity=executed_verifier_identity( + _task()["verify"], workspace), + built=built) + assert written["bundle"].read_text(encoding="utf-8") == built.bundle_text + assert written["evidence"].read_text(encoding="utf-8") == built.record_text + + +def test_write_places_the_content_addressed_object(tmp_path): + workspace = _ws(tmp_path) + built = _build(workspace) + written = emit.write_verify_evidence(workspace, run_id="run-1", iteration_id=1, + task=_task(), passed=True, + code_identity=executed_verifier_identity( + _task()["verify"], workspace), built=built) + assert written["object"].is_file() + assert hashlib.sha256(written["object"].read_bytes()).hexdigest() == built.sha256 + + +def test_object_path_matches_artifact_object_path(tmp_path): + workspace = _ws(tmp_path) + built = _build(workspace) + assert built.object_path == artifact_object_path(workspace, built.sha256) + + +def test_artifact_hashes_cover_bundle_object_and_record(tmp_path): + workspace = _ws(tmp_path) + built = _build(workspace) + by_path = {entry["path"]: entry["sha256"] for entry in built.artifact_hashes} + assert len(built.artifact_hashes) == 3 + assert by_path[".loop/artifacts/verify-iter1.json"] == built.sha256 + assert by_path[".loop/evidence/evidence-iter1.json"] == built.record_sha256 + assert by_path[built.object_path.relative_to(workspace).as_posix()] == built.sha256 + + +def test_artifact_hashes_entries_are_workspace_relative_posix(tmp_path): + built = _build(_ws(tmp_path)) + for entry in built.artifact_hashes: + assert not entry["path"].startswith("/") and "\\" not in entry["path"] + assert len(entry["sha256"]) == 64 + + +def test_record_sha256_still_commits_to_the_bundle_bytes(tmp_path): + built = _build(_ws(tmp_path)) + record = json.loads(built.record_text) + assert record["sha256"] == hashlib.sha256(built.bundle_text.encode("utf-8")).hexdigest() + + +def test_rewriting_the_bundle_leaves_the_object_intact(tmp_path): + workspace = _ws(tmp_path) + built = _build(workspace) + written = emit.write_verify_evidence(workspace, run_id="run-1", iteration_id=1, + task=_task(), passed=True, + code_identity=executed_verifier_identity( + _task()["verify"], workspace), built=built) + written["bundle"].write_text('{"outcome": "PASS", "passed": true}', encoding="utf-8") + assert hashlib.sha256(written["object"].read_bytes()).hexdigest() == built.sha256 + + +def test_a_second_identical_build_reuses_the_object_without_error(tmp_path): + workspace = _ws(tmp_path) + identity = executed_verifier_identity(_task()["verify"], workspace) + first = emit.write_verify_evidence(workspace, run_id="run-1", iteration_id=1, + task=_task(), passed=True, code_identity=identity) + second = emit.write_verify_evidence(workspace, run_id="run-1", iteration_id=1, + task=_task(), passed=True, code_identity=identity) + assert first["object"] == second["object"] and first["sha256"] == second["sha256"] + + +def test_an_object_collision_with_different_bytes_raises_emit_error(tmp_path): + workspace = _ws(tmp_path) + built = _build(workspace) + built.object_path.parent.mkdir(parents=True, exist_ok=True) + built.object_path.write_text("not the bundle", encoding="utf-8") + with pytest.raises(emit.EmitError, match="object store"): + emit.write_verify_evidence(workspace, run_id="run-1", iteration_id=1, task=_task(), + passed=True, + code_identity=executed_verifier_identity( + _task()["verify"], workspace), built=built) + + +def test_a_non_canonicalizable_task_raises_a_typed_emit_error_at_build_time(tmp_path): + workspace = _ws(tmp_path) + with pytest.raises(emit.EmitError, match="canonical"): + _build(workspace, task=_task(criterion_ref=float("nan"))) + + +def test_a_failed_record_write_leaves_no_metrics_visible_bundle_and_no_partial_object(tmp_path): + workspace = _ws(tmp_path) + built = _build(workspace) + (workspace / ".loop" / "evidence").mkdir(parents=True, exist_ok=True) + (workspace / ".loop" / "evidence" / "evidence-iter1.json").mkdir() # write must fail + with pytest.raises((OSError, emit.EmitError)): + emit.write_verify_evidence(workspace, run_id="run-1", iteration_id=1, task=_task(), + passed=True, + code_identity=executed_verifier_identity( + _task()["verify"], workspace), built=built) + assert not list((workspace / ".loop" / "artifacts").glob("verify-*.json")) +``` + +- [ ] **Step 2: Run it — expect collection to pass and 12 failures** (`AttributeError: module 'loop.emit' has no attribute 'build_verify_evidence'`): `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider scripts/test_verify_evidence_objects.py`. +- [ ] **Step 3: Implement.** In `loop/emit.py`: extend the existing `from .evidence import EVIDENCE_SCHEMA_ID` at `:32` with `artifact_object_path` (M-1's literal fork was **already fixed before PR #94 merged** — that import is live on `origin/main` and is used at `:462`; do not claim it as this slice's work), add `from dataclasses import dataclass`, and factor the body of today's `write_verify_evidence` (`:410-484`) into `build_verify_evidence`, which: + 1. calls `_require_contract(target)`, computes `policy_digest` with the existing `ChainHashError → EmitError` conversion (`:430-433`); + 2. builds the identical `identity`/`bundle`/`record` dicts (`:435-475`) — **no field changes**, so a Slice-2 record and a Slice-3 record for the same input are byte-identical; + 3. serializes both with the existing `json.dumps(..., indent=2, sort_keys=True) + "\n"`; + 4. validates its own record with `validate_evidence(record, mode="basic")` and raises `EmitError` when it is not `ok` (discharges review M-2 — every other schema'd writer validates at write time); + 5. returns `BuiltEvidence` with `object_path=artifact_object_path(paths.workspace, sha256)` and the three-entry `artifact_hashes` tuple, each `path` computed as `p.relative_to(paths.workspace).as_posix()`. + + `write_verify_evidence` keeps its signature, adds `built: BuiltEvidence | None = None`, builds when `built is None`, then writes in this order: **object → staged bundle → record → `os.replace(staged, bundle)`**. The object write is `_atomic_create_text`, wrapped: + +```python + try: + built.object_path.parent.mkdir(parents=True, exist_ok=True) + _atomic_create_text(built.object_path, built.bundle_text) + except FileExistsError: + existing = hashlib.sha256(built.object_path.read_bytes()).hexdigest() + if existing != built.sha256: + raise EmitError( + f"object store holds different bytes for {built.sha256}: refusing to overwrite " + f"a content-addressed object (found {existing})") + except OSError as exc: + raise EmitError(f"object store write failed at {built.object_path}: {exc}") from exc +``` + + The `except BaseException` cleanup around the record write (`:480-482`) also unlinks nothing from the object store: the object is immutable and correct by construction, and removing it would destroy the recovery copy. Return dict gains `"object": built.object_path`. +- [ ] **Step 4: Guard the return-shape change.** `grep -rn "write_verify_evidence" scripts/ loop/` and confirm no caller compares the returned dict by equality (Slice-2 callers index `["bundle"]`/`["evidence"]`/`["sha256"]`). Any equality comparison found is a stop-and-explain. +- [ ] **Step 5: Run the gate.** `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider scripts/test_verify_evidence_objects.py scripts/test_verify_evidence_writer.py scripts/test_emit.py scripts/test_evidence.py` — the new file **12 passed**, the three Slice-2 files unchanged and green. +- [ ] **Step 6:** Commit: `feat(emit): pure evidence builder, content-addressed object store, bound digest set`. + +**Acceptance:** +12 passed in both dependency sets; `scripts/test_verify_evidence_writer.py`, `scripts/test_emit.py`, `scripts/test_evidence.py` byte-unmodified and green; `git diff --stat` shows `loop/emit.py` as the only kernel file touched. + +--- + +### Task 2: `loop/runner.py` — build before append, bind at append + +**Files:** Modify `loop/runner.py`; Create `scripts/test_evidence_binding_runner.py`. + +- [ ] **Step 1: Write the failing tests** — `scripts/test_evidence_binding_runner.py`. Read `scripts/test_runner_dispatch.py:21-39` first and reuse its existing `_ws`/`_task`/`_pass` helpers by import if they are module-level, else copy them verbatim into the new file (there is no `conftest.py` anywhere in the tree; per-file helpers are the repo convention). + +```python +"""dispatch_once binds its evidence digests into the iteration event before writing them.""" +from __future__ import annotations + +import hashlib +import json + +import pytest + +from loop import emit +from loop.chain import compute_event_hash +from loop.events import SQLiteEventStore +from loop.runner import RunnerError, VerifyOutcome, dispatch_once + + +def _events(workspace, run_id="run-1"): + return SQLiteEventStore(workspace / ".loop" / "events.db").read(run_id) + + +def _iteration_events(workspace): + return [e for e in _events(workspace) if e["type"] == "iteration_appended"] + + +def test_dispatch_binds_the_evidence_digests_into_the_iteration_event(ready_workspace): + dispatch_once(ready_workspace) + hashes = _iteration_events(ready_workspace)[-1]["artifact_hashes"] + paths = {entry["path"] for entry in hashes} + assert len(hashes) == 3 + assert ".loop/artifacts/verify-iter1.json" in paths + assert ".loop/evidence/evidence-iter1.json" in paths + + +def test_bound_digests_match_the_files_on_disk(ready_workspace): + dispatch_once(ready_workspace) + for entry in _iteration_events(ready_workspace)[-1]["artifact_hashes"]: + blob = (ready_workspace / entry["path"]).read_bytes() + assert hashlib.sha256(blob).hexdigest() == entry["sha256"] + + +def test_the_bound_event_hash_covers_the_artifact_hashes(ready_workspace): + dispatch_once(ready_workspace) + event = _iteration_events(ready_workspace)[-1] + assert compute_event_hash(event) == event["event_hash"] + tampered = {**event, "artifact_hashes": []} + assert compute_event_hash(tampered) != event["event_hash"] + + +def test_a_build_failure_commits_no_event(ready_workspace): + tasks_path = ready_workspace / "TASKS.json" + data = json.loads(tasks_path.read_text(encoding="utf-8")) + data["tasks"][0]["criterion_ref"] = float("nan") + tasks_path.write_text(json.dumps(data), encoding="utf-8") + before = len(_events(ready_workspace)) + with pytest.raises(RunnerError): + dispatch_once(ready_workspace) + assert len(_events(ready_workspace)) == before + + +def test_an_evidence_write_failure_after_the_commit_is_still_loud(ready_workspace, monkeypatch): + def boom(*args, **kwargs): + raise OSError("disk full") + monkeypatch.setattr(emit, "write_verify_evidence", boom) + with pytest.raises(RunnerError, match="committed to the event log"): + dispatch_once(ready_workspace) + assert len(_iteration_events(ready_workspace)) == 1 + + +def test_dispatch_result_names_the_object_and_the_record(ready_workspace): + result = dispatch_once(ready_workspace) + assert result["evidence"].endswith("evidence-iter1.json") + assert "objects" in result["object"] + + +def test_injected_verifier_dispatch_binds_its_own_bundle(ready_workspace): + dispatch_once(ready_workspace, verifier=lambda task, ws: VerifyOutcome(True, "injected")) + bundle = json.loads((ready_workspace / ".loop" / "artifacts" / "verify-iter1.json") + .read_text(encoding="utf-8")) + entry = next(e for e in _iteration_events(ready_workspace)[-1]["artifact_hashes"] + if e["path"].endswith("verify-iter1.json")) + assert bundle["verifier"]["source"] == "injected_callable" + assert entry["sha256"] == hashlib.sha256( + (ready_workspace / entry["path"]).read_bytes()).hexdigest() + + +def test_two_dispatches_bind_distinct_artifact_sets(two_task_workspace): + dispatch_once(two_task_workspace) + dispatch_once(two_task_workspace) + first, second = _iteration_events(two_task_workspace)[:2] + assert {e["path"] for e in first["artifact_hashes"]} != {e["path"] for e in second["artifact_hashes"]} + + +def test_terminal_written_carries_no_artifact_hashes(ready_workspace): + dispatch_once(ready_workspace) + dispatch_once(ready_workspace) + terminal = [e for e in _events(ready_workspace) if e["type"] == "terminal_written"] + assert terminal and terminal[-1]["artifact_hashes"] == [] +``` + + Define `ready_workspace` (a scaffolded contract with an events.db projected to `execute-task`, one passing task whose `verify` is a real workspace script) and `two_task_workspace` as module-level `@pytest.fixture`s built from `scripts/test_runner_dispatch.py`'s existing setup helper — read that file and reuse, do not reinvent. + +- [ ] **Step 2: Run it — expect 9 failures** (`artifact_hashes == []`, `KeyError: 'object'`). +- [ ] **Step 3: Implement** in `dispatch_once` (`loop/runner.py:245-276`). Move `iteration_id = projection["iteration_id"] + 1` above the build, then: + +```python + built = emit.build_verify_evidence( + target, run_id=run_id, iteration_id=iteration_id, task=task, + passed=outcome.passed, summary=outcome.summary, code_identity=code_identity, + executor=executor, verifier_identity=verifier_identity, attempt=attempt) + store = SQLiteEventStore(paths.loop_dir / "events.db") + _store_append(store, run_id, "iteration_appended", payload, actor="loop.run", + artifact_hashes=list(built.artifact_hashes), + expected_sequence=projection["last_sequence"] + 1) + try: + written = emit.write_verify_evidence( + target, run_id=run_id, iteration_id=iteration_id, task=task, + passed=outcome.passed, summary=outcome.summary, code_identity=code_identity, + executor=executor, verifier_identity=verifier_identity, attempt=attempt, + built=built) + except (OSError, emit.EmitError) as exc: + raise RunnerError( + f"iteration {iteration_id} is committed to the event log but its verify " + f"bundle could not be written: {exc}" + ) from exc +``` + + Wrap the `build_verify_evidence` call in `except emit.EmitError as exc: raise RunnerError(f"cannot build the verify evidence for iteration {iteration_id}: {exc}") from exc` — the message must **not** say "committed", because nothing is. Extend the result dict with `"object": str(written["object"])`. +- [ ] **Step 4: Guard the event-shape change.** `grep -rn "artifact_hashes" scripts/` and confirm no existing test asserts `artifact_hashes == []` on a *dispatched* iteration; `grep -rn "dispatch_once(" scripts/ | head -40` and confirm no whole-dict equality on the `dispatched` action (`scripts/test_loop_simulate_cli.py` compares whole-dict only on `blocked`, per the Slice-2 audit — re-verify rather than trusting this line). +- [ ] **Step 5: Run the gate.** `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider scripts/test_evidence_binding_runner.py scripts/test_runner_dispatch.py scripts/test_adversarial_process.py scripts/test_loop_simulate_zero_writes.py` — the new file **9 passed**; the three existing files green and byte-unmodified. The crash-injection pin is the one that matters: the builder writes nothing, so a SIGKILL at the pre-commit `COMMIT` still leaves a byte-identical tree. +- [ ] **Step 6:** Commit: `feat(runner): bind evidence digests into the iteration event before writing them`. + +**Acceptance:** +9 passed in both sets; `scripts/test_runner_dispatch.py`, `scripts/test_adversarial_process.py`, `scripts/test_loop_simulate_zero_writes.py` byte-unmodified and green. + +--- + +### Task 3: `loop/runtime.py` — the bound-evidence walk + +**Files:** Modify `loop/runtime.py`; Create `scripts/test_doctor_evidence_binding.py`. + +- [ ] **Step 1: Write the failing tests** — `scripts/test_doctor_evidence_binding.py`: + +```python +"""Doctor's chain-binding walk: bound artifacts must still be the bytes that were bound.""" +from __future__ import annotations + +import json + +import pytest + +from loop.contract import doctor_report +from loop.runner import dispatch_once + +try: # the repo's fallback-mode convention + import jsonschema # noqa: F401 + _HAS_JSONSCHEMA = True +except ImportError: # pragma: no cover - exercised in the fallback leg + _HAS_JSONSCHEMA = False + +_MODES = [pytest.param("basic"), + pytest.param("strict", marks=pytest.mark.skipif(not _HAS_JSONSCHEMA, + reason="jsonschema not installed"))] + + +def _codes(report): + return {issue["code"] for issue in report["issues"]} + + +def _tree(workspace): + return sorted(str(p.relative_to(workspace)) for p in workspace.rglob("*") if p.is_file()) + + +def test_absent_store_adds_no_binding_issue(scaffolded_workspace): + assert doctor_report(scaffolded_workspace)["event_store"] == {"present": False} + + +@pytest.mark.parametrize("mode", _MODES) +def test_a_clean_runner_dispatch_is_doctor_clean(ready_workspace, mode): + dispatch_once(ready_workspace) + report = doctor_report(ready_workspace, mode=mode) + assert report["ok"] is True and report["issues"] == [] + + +def test_rewriting_a_bound_bundle_is_reported(ready_workspace): + dispatch_once(ready_workspace) + (ready_workspace / ".loop" / "artifacts" / "verify-iter1.json").write_text( + json.dumps({"outcome": "PASS", "passed": True}), encoding="utf-8") + assert "evidence_chain_mismatch" in _codes(doctor_report(ready_workspace)) + + +def test_rewriting_a_bound_record_is_reported(ready_workspace): + dispatch_once(ready_workspace) + path = ready_workspace / ".loop" / "evidence" / "evidence-iter1.json" + record = json.loads(path.read_text(encoding="utf-8")) + record["verified_by"]["by"] = "somebody-else" + path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + assert "evidence_chain_mismatch" in _codes(doctor_report(ready_workspace)) + + +def test_deleting_a_bound_pair_is_reported(ready_workspace): + dispatch_once(ready_workspace) + (ready_workspace / ".loop" / "artifacts" / "verify-iter1.json").unlink() + (ready_workspace / ".loop" / "evidence" / "evidence-iter1.json").unlink() + assert "missing_bound_evidence" in _codes(doctor_report(ready_workspace)) + + +def test_the_object_survives_and_is_named_as_the_recovery_source(ready_workspace): + dispatch_once(ready_workspace) + (ready_workspace / ".loop" / "artifacts" / "verify-iter1.json").write_text("{}", encoding="utf-8") + messages = " ".join(issue["message"] for issue in doctor_report(ready_workspace)["issues"]) + assert "objects/" in messages + assert any(p.startswith(".loop/artifacts/objects/") + for p in _tree(ready_workspace)) + + +@pytest.mark.parametrize("mode", _MODES) +def test_a_legacy_event_without_artifact_hashes_is_not_a_finding(legacy_workspace, mode): + report = doctor_report(legacy_workspace, mode=mode) + assert "missing_bound_evidence" not in _codes(report) + assert "evidence_chain_mismatch" not in _codes(report) + + +def test_binding_check_writes_nothing(ready_workspace): + dispatch_once(ready_workspace) + before = _tree(ready_workspace) + doctor_report(ready_workspace) + assert _tree(ready_workspace) == before + + +def test_binding_issues_never_add_a_doctor_key(ready_workspace): + dispatch_once(ready_workspace) + (ready_workspace / ".loop" / "artifacts" / "verify-iter1.json").unlink() + report = doctor_report(ready_workspace) + assert set(report["event_store"]) == { + "present", "readable", "run_id", "event_count", "state_json_agrees", + "deterministic", "legal_sequence", "chain"} + + +def test_a_store_that_cannot_be_read_still_reports_its_own_error_code(corrupt_store_workspace): + report = doctor_report(corrupt_store_workspace) + assert report["event_store"]["readable"] is False + assert "missing_bound_evidence" not in _codes(report) +``` + + `legacy_workspace` is a dispatched contract whose `artifact_hashes` were emptied by rebuilding the store from an unbound append (append the same payload with `artifact_hashes=None`); `corrupt_store_workspace` reuses the fixture shape already used by `scripts/test_doctor_eventstore.py` — read it and copy. + +- [ ] **Step 2: Run it — expect exactly 4 failing items** out of the **12** collected: the two rewrite cases, the delete-pair case, and the object-recovery-message case. The other eight (absent-store, clean-dispatch ×2, legacy ×2, zero-write, key-shape, unreadable-store) pass **vacuously** today — with no detector wired, doctor reports nothing, and "reports nothing" is what seven of them assert. That is the signal to watch: a vacuous pass here is only meaningful because Step 4's mutation probe (Task 7 Step 4) later proves the four detection items die when the detector is stubbed. *(Counts recomputed during the post-review pass; the earlier "6 failures" over "11 collected" was arithmetic drift.)* +- [ ] **Step 3: Implement** in `loop/runtime.py`. Append: + +```python +def _bound_evidence_issues(target: str | Path, mode: str | None) -> list[dict[str, Any]]: + """Re-hash every artifact an event bound into the chain. + + Driven by what each event DECLARES: a legacy event carries an empty + artifact_hashes list and is silent by construction, because the append-only + triggers make a retroactive binding impossible (repo-os-contract.md #22). + """ + _, _run_id, events, _validation = _events(target, mode) + workspace = resolve_loop_paths(target).workspace + issues: list[dict[str, Any]] = [] + for event in events: + for entry in event.get("artifact_hashes") or []: + path = workspace / entry["path"] + try: + blob = path.read_bytes() + except OSError: + issues.append(ContractIssue( + "missing_bound_evidence", + f"event {event['event_id']} (sequence {event['sequence']}) bound " + f"{entry['path']} into the chain but it is absent or unreadable")) + continue + actual = hashlib.sha256(blob).hexdigest() + if actual != entry["sha256"]: + issues.append(ContractIssue( + "evidence_chain_mismatch", + f"{entry['path']} does not match the digest bound at sequence " + f"{event['sequence']}: expected {entry['sha256']}, found {actual} — " + f"the original bytes may remain at " + f".loop/artifacts/objects/{entry['sha256'][:2]}/{entry['sha256']}")) + return issues +``` + + Add `import hashlib` at the top. Add the shared lookup decision 14 needs beside it — one traversal, two consumers, so the binding walk and the strict completion mode can never disagree about what "bound" means: + +```python +def bound_artifact_digests(target: str | Path, mode: str | None = None) -> dict[str, str] | None: + """{workspace-relative POSIX path: sha256} for every artifact any event bound. + + None means there is no event store, and the caller MUST degrade explicitly and say + so (decision 14) rather than treat absence as satisfaction. An empty dict means a + store exists and bound nothing. An unreadable store raises RuntimeStoreError — an + errored check fails, it never skips (R007). + """ + if not (resolve_loop_paths(target).loop_dir / "events.db").is_file(): + return None + _, _run_id, events, _validation = _events(target, mode) + return {entry["path"]: entry["sha256"] + for event in events for entry in event.get("artifact_hashes") or []} +``` + + Compose the walk inside `event_consistency_issues` at the readable branch (`loop/runtime.py:311`), immediately after `issues = list(status["divergence"]) + list(replay["findings"])`: + +```python + issues.extend(_bound_evidence_issues(target, mode)) +``` + + It runs only inside the readable branch, so an absent or unreadable store is untouched and the returned `event_store` dict is unchanged (decision 8). The extra read-only fold is decision 9's recorded cost. +- [ ] **Step 4: Run the gate.** `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider scripts/test_doctor_evidence_binding.py scripts/test_doctor_eventstore.py scripts/test_loop_cli_status_replay.py` — the new file **12 passed** (extras) / **10 passed 2 skipped** (fallback); the two existing files byte-unmodified and green. + + The count is 12, not 10: the block above defines ten `def test_`s, two of which carry `@pytest.mark.parametrize("mode", _MODES)` and contribute **two** collected items each (`basic` + `strict`), so 8×1 + 2×2 = **12**. The `strict` halves are the two that skip without jsonschema. Re-derive this yourself before accepting the number — a stated count that disagrees with `pytest --collect-only -q` is a stop-and-explain under Task 9 Step 8. +- [ ] **Step 5:** Commit: `feat(doctor): verify the evidence digests bound into the event chain`. + +**Acceptance:** +12 passed extras / +10 passed +2 skipped fallback; `scripts/test_doctor_eventstore.py` byte-unmodified, including its absent-store byte-stability pin at `:107`. + +--- + +### Task 4: `loop/contract.py` — hash-verify records, compare the policy digest + +**Files:** Modify `loop/contract.py`, `scripts/test_doctor_evidence.py` (helpers only); Create `scripts/test_doctor_evidence_verification.py`. + +- [ ] **Step 1: Repair the Slice-2 fixtures FIRST — this is a predictable, planned break.** `scripts/test_doctor_evidence.py:17-46` builds records whose `sha256` is the literal `"c" * 64` while `_ws` writes bundles containing `{"outcome": "PASS", "passed": true}`. Once doctor hash-verifies, every one of that file's record-bearing tests gains a `hash_mismatch` and roughly eight assertions break. Replace the two helpers so the pair is consistent, changing **no test body and adding no test**: + +```python +_BUNDLE_TEXT = json.dumps({"outcome": "PASS", "passed": True}) +_BUNDLE_SHA = hashlib.sha256(_BUNDLE_TEXT.encode("utf-8")).hexdigest() + + +def _record(executor="worker-a", by="ci", **overrides): + record = { + "schema": "loop-engineer/evidence@1", "id": "e1", "kind": "verify-bundle", + "uri": ".loop/artifacts/verify-iter5.json", "sha256": _BUNDLE_SHA, + "media_type": "application/json", "created_at": "2026-07-25T00:00:00+00:00", + "produced_by": {"run_id": "run-1", "task_id": "T-1", "attempt": 1, "executor": executor}, + "verified_by": {"by": by, "at": "2026-07-25T00:00:00+00:00", + "command": "./scripts/verify-fast.sh", + "code_digest": "a" * 64, "code_digest_basis": "workspace_file", + "policy_digest": "b" * 64}, + } + record.update(overrides) + return record + + +def _ws(tmp_path, records=(), bundles=(), name="workspace"): + target = tmp_path / name + scaffold(target) + artifacts = target / ".loop" / "artifacts" + if records: + directory = target / ".loop" / "evidence" + directory.mkdir(parents=True, exist_ok=True) + for index, record in enumerate(records): + text = record if isinstance(record, str) else json.dumps(record) + (directory / f"evidence-iter{index}.json").write_text(text, encoding="utf-8") + # Every _record() names this uri; doctor now hash-verifies it, so it must exist + # with exactly the bytes the record commits to. + artifacts.mkdir(parents=True, exist_ok=True) + (artifacts / "verify-iter5.json").write_text(_BUNDLE_TEXT, encoding="utf-8") + for bundle_name in bundles: + artifacts.mkdir(parents=True, exist_ok=True) + (artifacts / bundle_name).write_text(_BUNDLE_TEXT, encoding="utf-8") + return target +``` + + `verify-iter5.json` now always exists when records do, so `test_a_bundle_whose_record_was_deleted_is_reported` (`:115`) must keep passing a bundle name that has no record — re-run that file and reconcile before moving on. The scaffolded TASKS.json contains no task `T-1`, so decision 5's live-entry rule keeps every one of those tests out of the `policy_digest_mismatch` path. + +- [ ] **Step 2: Write the failing tests** — `scripts/test_doctor_evidence_verification.py` (same `_MODES` preamble as Task 3): + +```python +"""Doctor hash-verifies discovered records and compares their policy digest to TASKS.json.""" +from __future__ import annotations + +import hashlib +import json + +import pytest + +from loop.contract import doctor_report +from loop.scaffold import scaffold +from loop.verifier import verification_policy_digest + +# ... _MODES / _codes preamble as in Task 3 ... + +_TASK = {"id": "T-1", "title": "t", "status": "pending", "criterion_ref": "C-1", + "verify": "./scripts/verify-fast.sh", "depends_on": [], "attempts": 0, "evidence": None} + + +def _ws(tmp_path, *, records, bundle_text='{"outcome": "PASS", "passed": true}', tasks=(_TASK,)): + target = tmp_path / "workspace" + scaffold(target) + (target / "TASKS.json").write_text(json.dumps( + {"schema": "loop-engineer/tasks@1", "project": "p", "tasks": list(tasks)}), encoding="utf-8") + (target / ".loop" / "artifacts").mkdir(parents=True, exist_ok=True) + (target / ".loop" / "artifacts" / "verify-iter1.json").write_text(bundle_text, encoding="utf-8") + directory = target / ".loop" / "evidence" + directory.mkdir(parents=True, exist_ok=True) + for iteration_id, record in records: + (directory / f"evidence-iter{iteration_id}.json").write_text( + json.dumps(record), encoding="utf-8") + return target + + +def _record(*, iteration_id=1, policy_digest=None, uri=".loop/artifacts/verify-iter1.json", + sha256=None, task_id="T-1"): + text = '{"outcome": "PASS", "passed": true}' + return { + "schema": "loop-engineer/evidence@1", "id": f"run-1:{iteration_id}:verify", + "kind": "verify-bundle", "uri": uri, + "sha256": sha256 or hashlib.sha256(text.encode("utf-8")).hexdigest(), + "media_type": "application/json", "created_at": "2026-07-25T00:00:00+00:00", + "produced_by": {"run_id": "run-1", "task_id": task_id, "attempt": 1, + "executor": "worker-a"}, + "verified_by": {"by": "ci", "at": "2026-07-25T00:00:00+00:00", + "command": "./scripts/verify-fast.sh", "code_digest": None, + "code_digest_basis": "path_lookup", + "policy_digest": policy_digest or verification_policy_digest(_TASK)}, + } + + +@pytest.mark.parametrize("mode", _MODES) +def test_a_hash_verified_record_is_clean(tmp_path, mode): + target = _ws(tmp_path, records=[(1, _record())]) + assert doctor_report(target, mode=mode)["ok"] is True + + +@pytest.mark.parametrize("mode", _MODES) +def test_a_swapped_bundle_fails_doctor_with_hash_mismatch(tmp_path, mode): + target = _ws(tmp_path, records=[(1, _record())]) + (target / ".loop" / "artifacts" / "verify-iter1.json").write_text("{}", encoding="utf-8") + assert "hash_mismatch" in _codes(doctor_report(target, mode=mode)) + + +def test_a_record_whose_uri_is_absent_reports_missing_evidence_path(tmp_path): + target = _ws(tmp_path, records=[(1, _record(uri=".loop/artifacts/gone.json"))]) + assert "missing_evidence_path" in _codes(doctor_report(target)) + + +def test_a_record_whose_uri_escapes_the_workspace_reports_workspace_escape(tmp_path): + target = _ws(tmp_path, records=[(1, _record(uri="../escape.json"))]) + assert _codes(doctor_report(target)) & {"workspace_escape", "missing_evidence_path"} + + +@pytest.mark.parametrize("mode", _MODES) +def test_policy_digest_mismatch_against_the_live_task(tmp_path, mode): + moved = dict(_TASK, verify="true") + target = _ws(tmp_path, records=[(1, _record())], tasks=(moved,)) + assert "policy_digest_mismatch" in _codes(doctor_report(target, mode=mode)) + + +def test_policy_digest_agreement_is_silent(tmp_path): + target = _ws(tmp_path, records=[(1, _record())]) + assert "policy_digest_mismatch" not in _codes(doctor_report(target)) + + +def test_only_the_latest_record_per_task_is_compared(tmp_path): + stale = _record(iteration_id=1, policy_digest="d" * 64) + current = _record(iteration_id=2) + target = _ws(tmp_path, records=[(1, stale), (2, current)]) + assert "policy_digest_mismatch" not in _codes(doctor_report(target)) + + +def test_a_record_naming_an_unknown_task_is_not_compared(tmp_path): + target = _ws(tmp_path, records=[(1, _record(task_id="T-404", policy_digest="d" * 64))]) + assert "policy_digest_mismatch" not in _codes(doctor_report(target)) + + +def test_a_record_without_a_policy_digest_is_not_compared(tmp_path): + record = _record() + record["verified_by"]["policy_digest"] = None + target = _ws(tmp_path, records=[(1, record)]) + assert "policy_digest_mismatch" not in _codes(doctor_report(target)) + + +@pytest.mark.parametrize("mode", _MODES) +def test_evidence_schema_id_still_joins_schemas_checked(tmp_path, mode): + target = _ws(tmp_path, records=[(1, _record())]) + assert "loop-engineer/evidence@1" in doctor_report(target, mode=mode)["schemas_checked"] +``` + +- [ ] **Step 3: Run it — expect 5 failures** (`hash_mismatch`, `missing_evidence_path`, `workspace_escape`, and the two `policy_digest_mismatch` items). +- [ ] **Step 4: Implement** in `loop/contract.py`, inside `_validate_evidence_records` (`:684-711`). The hash-verification call is **guarded** — it runs only for records that produced **no** structural issues. This is not optional tidiness: `verify_evidence()` internally re-runs `validate_evidence(record, mode="basic")` and emits its own `invalid_evidence`/`invalid_uri` issues, so calling it unconditionally would report a malformed record twice and break `scripts/test_doctor_evidence.py`'s existing single-finding assertions. Capture the per-record result of the existing loop instead of discarding it: + +```python + from .evidence import verify_evidence # local: loop.evidence imports this module + + record_issues = evidence_issues(data, resolved_mode=mode) # was an unbound loop + for issue in record_issues: + issues.append(ContractIssue(issue["code"], + f"{record_path.name}: {issue['message']}", record_path)) + if not record_issues: # <- the guard + result = verify_evidence(data, workspace_root=paths.workspace) + if not result["ok"]: + for issue in result["issues"]: + issues.append(ContractIssue(issue["code"], + f"{record_path.name}: {issue['message']}", + record_path)) +``` + + Read `:684-711` before editing: the existing loop already appends its issues, so the change is to **bind** its result to `record_issues` rather than to add a second traversal. If the live shape differs from the sketch, keep the *semantics* — structural issues reported exactly once, hash verification attempted only on a structurally-valid record — and adapt the code (AC prose is normative; the sketch is illustrative). + + Collect `(iteration_id, record_path, data)` for every structurally-valid record, where `iteration_id` is the integer parsed from `evidence-iter([0-9]+)\.json` (`None` for any other filename — a foreign record is verified but never policy-compared), and pass the list to: + +```python +def _policy_digest_issues(paths: LoopPaths, records, issues: list[dict]) -> None: + """Compare the LATEST record per task against the live TASKS.json goalpost. + + Only the latest record backs the task's current claim; older records describe + goalposts that were current when they were written, and comparing them would + make every honest re-verification a permanent failure (plan decision 5). + """ + from .verifier import verification_policy_digest + + tasks = _read_json(paths.tasks, []) + entries = {t["id"]: t for t in (tasks or {}).get("tasks", []) + if isinstance(t, dict) and isinstance(t.get("id"), str)} + latest: dict[str, tuple[int, Path, dict]] = {} + for iteration_id, record_path, data in records: + task_id = data.get("produced_by", {}).get("task_id") + if not isinstance(task_id, str) or task_id not in entries: + continue + key = (iteration_id if isinstance(iteration_id, int) else -1, record_path.name) + if task_id not in latest or key > (latest[task_id][0], latest[task_id][1].name): + latest[task_id] = (key[0], record_path, data) + for task_id, (_iteration_id, record_path, data) in sorted(latest.items()): + recorded = (data.get("verified_by") or {}).get("policy_digest") + if not isinstance(recorded, str): + continue + try: + live = verification_policy_digest(entries[task_id]) + except ChainHashError: + continue # a non-canonicalizable task entry is TASKS.json's own defect + if live != recorded: + issues.append(ContractIssue( + "policy_digest_mismatch", + f"{record_path.name}: the goalpost recorded for task {task_id!r} " + f"({recorded}) is not the live TASKS.json goalpost ({live}) — " + f"the declared verify/criterion_ref/depends_on/id changed after this " + f"verification; re-verify to record the current goalpost", + record_path)) +``` + + Import `ChainHashError` from `.chain` at the top of `contract.py`. Call `_policy_digest_issues` at the end of `_validate_evidence_records`, before its `return checked`. +- [ ] **Step 5: Dogfood check.** `uv run --with pyyaml python3 -B -m loop doctor examples/coverage-repair` and `... examples/flaky-test-triage` — both still `ok: true`. Neither ships a `.loop/evidence/` directory, so both are untouched by construction; verify rather than assume. +- [ ] **Step 6: Run the gate.** `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider scripts/test_doctor_evidence_verification.py scripts/test_doctor_evidence.py scripts/test_contract_records.py scripts/test_loop_contract_core.py` — the new file **14 passed** extras / **10 passed 4 skipped** fallback; `test_doctor_evidence.py` green at its **unchanged test count** (helpers only). +- [ ] **Step 7:** Commit: `feat(doctor): hash-verify discovered evidence and compare the recorded goalpost`. + +**Acceptance:** +14 passed extras / +10 passed +4 skipped fallback; `scripts/test_doctor_evidence.py`'s diff touches `_record`/`_ws`/imports only (verify with `git diff -U0 scripts/test_doctor_evidence.py`); both dogfood examples doctor-clean. + +--- + +### Task 5: `Succeeded` tightens — the verified-evidence completion mode + +**Files:** Modify `loop/completion.py`, `loop/emit.py`, `loop/contract.py`, `loop/reducer.py`, `loop/integrations.py`, `loop/runner.py`, `schemas/terminal.schema.json`; Create `scripts/test_completion_verified_evidence.py`. + +- [ ] **Step 1: Write the failing tests** — `scripts/test_completion_verified_evidence.py` (same `_MODES`/`_codes` preamble): + +```python +"""all_required_verified_evidence: the same bar at four layers, each as strong as it can be.""" +from __future__ import annotations + +import hashlib +import json + +import pytest + +from loop import emit, integrations +from loop.completion import (VERIFIED_EVIDENCE_MODE, normalize_completion_policy, + policy_requires_verified_evidence) +from loop.contract import doctor_report +from loop.reducer import EventReplayError, reduce_events +from loop.runner import dispatch_once +from loop.verifier import verification_policy_digest + +# ... _MODES / _codes preamble as in Task 3; _dispatched_workspace fixtures from Task 2 ... + + +def test_the_new_mode_is_supported_and_normalizes(): + assert normalize_completion_policy(VERIFIED_EVIDENCE_MODE) == {"mode": VERIFIED_EVIDENCE_MODE} + assert policy_requires_verified_evidence({"mode": VERIFIED_EVIDENCE_MODE}) is True + + +def test_legacy_null_policy_still_normalizes_to_all_required(): + assert normalize_completion_policy(None) == {"mode": "all_required"} + assert policy_requires_verified_evidence(None) is False + + +def test_terminate_refuses_unverified_evidence_under_the_new_mode(dispatched_workspace): + with pytest.raises(emit.EmitError, match="verified evidence"): + emit.terminate(dispatched_workspace, state="Succeeded", criteria_met={"C-1": True}, + evidence=["RUNLOG.md"], completion_policy=VERIFIED_EVIDENCE_MODE) + + +def test_terminate_accepts_hash_verified_evidence_under_the_new_mode(dispatched_workspace): + path = emit.terminate(dispatched_workspace, state="Succeeded", criteria_met={"C-1": True}, + evidence=[".loop/evidence/evidence-iter1.json"], + completion_policy=VERIFIED_EVIDENCE_MODE) + assert json.loads(path.read_text(encoding="utf-8"))["completion_policy"] == { + "mode": VERIFIED_EVIDENCE_MODE} + + +def test_terminate_under_the_default_mode_is_unchanged(dispatched_workspace): + path = emit.terminate(dispatched_workspace, state="Succeeded", criteria_met={"C-1": True}, + evidence=["RUNLOG.md"]) + assert json.loads(path.read_text(encoding="utf-8"))["completion_policy"] == { + "mode": "all_required"} + + +@pytest.mark.parametrize("mode", _MODES) +def test_doctor_reports_unverified_evidence_terminal(dispatched_workspace, mode): + emit.terminate(dispatched_workspace, state="Succeeded", criteria_met={"C-1": True}, + evidence=[".loop/evidence/evidence-iter1.json"], + completion_policy=VERIFIED_EVIDENCE_MODE) + (dispatched_workspace / ".loop" / "artifacts" / "verify-iter1.json").write_text( + "{}", encoding="utf-8") + assert "unverified_evidence_terminal" in _codes(doctor_report(dispatched_workspace, mode=mode)) + + +# --- decision 14: hash-verified is NOT enough when the layer can reach further --- + +def _handwritten_record(workspace, *, name="evidence-iter9.json", task_id="T-1"): + """A self-consistent, never-dispatched record: real bundle, real digest, real policy.""" + bundle = workspace / ".loop" / "artifacts" / "verify-iter9.json" + bundle.parent.mkdir(parents=True, exist_ok=True) + text = '{"outcome": "PASS", "passed": true}' + bundle.write_text(text, encoding="utf-8") + tasks = json.loads((workspace / "TASKS.json").read_text(encoding="utf-8")) + entry = next(t for t in tasks["tasks"] if t["id"] == task_id) + record = { + "schema": "loop-engineer/evidence@1", "id": "hand:9:verify", "kind": "verify-bundle", + "uri": ".loop/artifacts/verify-iter9.json", + "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), + "media_type": "application/json", "created_at": "2026-07-25T00:00:00+00:00", + "produced_by": {"run_id": "run-1", "task_id": task_id, "attempt": 1, + "executor": "worker-a"}, + "verified_by": {"by": "ci", "at": "2026-07-25T00:00:00+00:00", + "command": entry["verify"], "code_digest": None, + "code_digest_basis": "path_lookup", + "policy_digest": verification_policy_digest(entry)}, + } + path = workspace / ".loop" / "evidence" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return f".loop/evidence/{name}" + + +def test_terminate_refuses_a_hash_verified_but_unbound_record_when_a_store_exists( + dispatched_workspace): + """The BLOCKER case: self-consistency is not dispatch. No event bound this record.""" + entry = _handwritten_record(dispatched_workspace) + with pytest.raises(emit.EmitError, match="not bound"): + emit.terminate(dispatched_workspace, state="Succeeded", criteria_met={"C-1": True}, + evidence=[entry], completion_policy=VERIFIED_EVIDENCE_MODE) + + +def test_terminate_refuses_a_cited_record_whose_goalpost_moved(dispatched_workspace): + """Per-cited-record, deliberately stricter than doctor's latest-per-task rule.""" + tasks_path = dispatched_workspace / "TASKS.json" + data = json.loads(tasks_path.read_text(encoding="utf-8")) + data["tasks"][0]["verify"] = "true" + tasks_path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(emit.EmitError, match="goalpost"): + emit.terminate(dispatched_workspace, state="Succeeded", criteria_met={"C-1": True}, + evidence=[".loop/evidence/evidence-iter1.json"], + completion_policy=VERIFIED_EVIDENCE_MODE) + + +def test_doctor_reports_an_unbound_record_cited_by_a_strict_terminal(dispatched_workspace): + """Read-time twin of the write-time refusal — same predicate, one definition. + + The terminal file is hand-written, not emitted: emit.terminate REFUSES this exact + record (the test above), so the only way a strict terminal citing an unbound record + reaches disk is a worker writing it directly — which is the adversary being modelled. + """ + entry = _handwritten_record(dispatched_workspace) + (dispatched_workspace / ".loop" / "terminal_state.json").write_text(json.dumps({ + "schema": "loop-engineer/terminal@1", "project": dispatched_workspace.name, + "state": "Succeeded", "criteria_met": {"C-1": True}, + "completion_policy": {"mode": VERIFIED_EVIDENCE_MODE}, "evidence": [entry], + "false_completion": False, "terminated_at": "2026-07-25T00:00:00+00:00", + "reason": "hand-written"}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + assert "unverified_evidence_terminal" in _codes(doctor_report(dispatched_workspace)) + + +def _terminal_event(evidence, mode): + return [{"schema": "loop-engineer/event@1", "run_id": "r", "sequence": 0, "event_id": "e0", + "type": "contract_opened", "actor": "op", "ts": "2026-07-25T00:00:00+00:00", + "causation_id": None, "correlation_id": None, "payload": {"workspace": "ws"}, + "artifact_hashes": []}, + {"schema": "loop-engineer/event@1", "run_id": "r", "sequence": 1, "event_id": "e1", + "type": "terminal_written", "actor": "op", "ts": "2026-07-25T00:00:01+00:00", + "causation_id": None, "correlation_id": None, "artifact_hashes": [], + "payload": {"state": "Succeeded", "criteria_met": {"C-1": True}, + "evidence": evidence, "false_completion": False, + "completion_policy": {"mode": mode}}}] + + +def test_the_reducer_refuses_a_non_record_evidence_entry_under_the_new_mode(): + with pytest.raises(EventReplayError, match="verified evidence"): + reduce_events(_terminal_event(["RUNLOG.md"], VERIFIED_EVIDENCE_MODE)) + + +def test_the_reducer_accepts_record_shaped_evidence_under_the_new_mode(): + projection = reduce_events( + _terminal_event([".loop/evidence/evidence-iter1.json"], VERIFIED_EVIDENCE_MODE)) + assert projection["terminal"]["state"] == "Succeeded" + + +def test_integrations_projection_refuses_non_record_evidence_under_the_new_mode(): + outcome = integrations.EngineOutcome(reached_end=True, artifacts=("RUNLOG.md",)) + body = integrations.to_terminal_state( + outcome, {"verdict": "Succeeded", "passed_visible": True, "passed_holdout": True, + "false_completion": False, + "visible": [{"id": "C-1", "passed": True}], + "holdout": [{"id": "H-1", "passed": True}]}, + {"severity": "none", "findings": []}, {"C-1": True}, + completion_policy=VERIFIED_EVIDENCE_MODE) + assert body["state"] == "FailedUnverifiable" and "verified evidence" in body["reason"] + + +def test_runner_auto_terminal_adopts_the_verified_mode_when_every_task_has_a_record(ready_workspace): + dispatch_once(ready_workspace) + dispatch_once(ready_workspace) + terminal = json.loads((ready_workspace / ".loop" / "terminal_state.json") + .read_text(encoding="utf-8")) + assert terminal["completion_policy"] == {"mode": VERIFIED_EVIDENCE_MODE} + assert terminal["evidence"] == [".loop/evidence/evidence-iter1.json"] + + +def test_runner_auto_terminal_keeps_all_required_when_a_task_has_no_record(predone_workspace): + dispatch_once(predone_workspace) + terminal = json.loads((predone_workspace / ".loop" / "terminal_state.json") + .read_text(encoding="utf-8")) + assert terminal["completion_policy"] == {"mode": "all_required"} + assert terminal["evidence"] == ["RUNLOG.md"] + assert "no evidence record" in terminal["reason"] + + +@pytest.mark.parametrize("mode", _MODES) +def test_terminal_schema_accepts_both_modes(dispatched_workspace, mode): + emit.terminate(dispatched_workspace, state="Succeeded", criteria_met={"C-1": True}, + evidence=[".loop/evidence/evidence-iter1.json"], + completion_policy=VERIFIED_EVIDENCE_MODE) + assert doctor_report(dispatched_workspace, mode=mode)["ok"] is True +``` + + `predone_workspace` is a contract whose only task is already `status: "done"` with a declared `evidence` string, so the auto-terminal fires with zero dispatched iterations. When hand-writing `terminal_state.json`, mirror the exact key set `emit.terminate` produces (`loop/emit.py:311-320`) — read it rather than copying the sketch above, or the record fails terminal@1 validation for the wrong reason and the test proves nothing. + +- [ ] **Step 2: Run it — expect all 17 collected items to fail at import** (`ImportError: cannot import name 'VERIFIED_EVIDENCE_MODE'`); a collection-time failure is the honest starting point for a module that does not exist yet. +- [ ] **Step 3: Implement `loop/completion.py`.** `CompletionMode` becomes `Literal["all_required", "all_required_verified_evidence"]`; `SUPPORTED_COMPLETION_MODES` gains the new member; `criteria_satisfy_completion`'s branch becomes `if normalized["mode"] in SUPPORTED_COMPLETION_MODES:` with the identical all-required body (the criteria half is deliberately shared, and the `raise AssertionError` exhaustiveness guard stays). Add: + +```python +VERIFIED_EVIDENCE_MODE: Final[CompletionMode] = "all_required_verified_evidence" +_RECORD_PREFIX = ".loop/evidence/" + + +def policy_requires_verified_evidence(policy: object | None = None) -> bool: + """True when this policy's evidence bar is hash-verified records, not path strings.""" + return normalize_completion_policy(policy)["mode"] == VERIFIED_EVIDENCE_MODE + + +def evidence_entry_is_record_shaped(entry: object) -> bool: + """The structural half of the bar — the most a pure, I/O-free layer can honestly check.""" + return (isinstance(entry, str) and entry.startswith(_RECORD_PREFIX) + and entry.endswith(".json") and ".." not in entry) +``` + +- [ ] **Step 4: Implement the four enforcement layers.** The two workspace-bearing layers share **one** predicate — write it once as `loop/contract.py::_strict_evidence_failure(entry, paths, bound)` returning `None` or a detail string, and have `emit.terminate` import it function-locally. Two hand-written copies of a three-part security check will drift, and a drift here is a silent false completion. + + The three sub-checks, in order (decision 14): + 1. **Self-consistency** — the entry resolves under `paths.workspace`, `json.loads`, `validate_evidence(..., mode="basic")` is `ok`, and `verify_evidence(..., workspace_root=paths.workspace)` is `ok`. Detail: `"is not verified evidence: {…}"`. + 2. **Chain-boundness** — `bound = runtime.bound_artifact_digests(paths.workspace)` (function-local import; `loop/runtime.py` imports `loop/contract.py` at module level, so the reverse edge must stay local — the same rule the doctor-events wiring already follows). When `bound is not None`, the entry's path must be a key of `bound` **and** `bound[entry]` must equal the sha256 of the record file's current bytes. Detail: `"is not bound into the event chain"` / `"is bound at a different digest"`. When `bound is None` there is no store and this sub-check is **skipped** — the pinned store-less residual. + 3. **Goalpost agreement** — when `produced_by.task_id` names a task still present in TASKS.json, `verified_by.policy_digest` must equal `verification_policy_digest` of that live entry. Detail: `"records a goalpost that is not the live TASKS.json goalpost"`. Unlike doctor's latest-per-task rule (decision 5), this is per-cited-record: a terminal citing a record is asserting it backs `Succeeded` *now*. + + - `loop/emit.py::terminate`, inside the `state == "Succeeded"` block (`:294-304`), after the existing evidence check: when `policy_requires_verified_evidence(normalized_policy)`, call the predicate per entry and raise `EmitError(f"refusing Succeeded under {VERIFIED_EVIDENCE_MODE}: {entry} {detail}")` on the first failure. Note the ordering constraint: `paths` is resolved at `:306`, *after* the G1 block — move `paths = _require_contract(target)` above the `if state == "Succeeded":` block, which is safe because `_require_contract` only reads. A `RuntimeStoreError` from `bound_artifact_digests` becomes an `EmitError` — an unreadable store is a refusal, never a skip (R007). + - `loop/contract.py`: new `_check_verified_evidence_terminal(terminal, paths, issues)` calling the same predicate and appending `ContractIssue("unverified_evidence_terminal", …)` with the returned detail; call it from `validate_contract` immediately after `records_checked = _validate_optional_records(...)` (`:814`), guarded by `if terminal is not None`. Here a `RuntimeStoreError` is **not** raised out of doctor: the unreadable-store path already reports its own `readable: False` + error code (`loop/runtime.py`), so catch it and append `unverified_evidence_terminal` with "the event store could not be read" as the detail — still a finding, never a silent pass. + - `loop/reducer.py::_validate_terminal_payload_semantics` (`:47-49`): after the existing non-empty check, `if policy_requires_verified_evidence(payload.get("completion_policy")) and not all(evidence_entry_is_record_shaped(e) for e in evidence): raise EventReplayError("Succeeded terminal declares verified evidence but an entry is not a .loop/evidence record (G1)")`. + - `loop/integrations.py::to_terminal_state` (`:170-173`): before the `if not artifacts:` branch, `if policy_requires_verified_evidence(normalized_policy) and not all(evidence_entry_is_record_shaped(a) for a in artifacts): return body("FailedUnverifiable", "green gate but an evidence artifact is not verified evidence — cannot certify")`. +- [ ] **Step 5: Implement the runner's auto-terminal** (`loop/runner.py:219-233`). Before building the payload, collect `records = [f".loop/evidence/evidence-iter{e['iteration_id']}.json" for e in projection["runlog_entries"] if e.get("outcome") == "task_passed"]` filtered to those that exist on disk, and map each to its task. When every task id in `tasks` has at least one such record, write `completion_policy: {"mode": VERIFIED_EVIDENCE_MODE}` with `evidence` = the sorted record list; otherwise keep `{"mode": "all_required"}`, `evidence: ["RUNLOG.md"]`, and set `payload["reason"] = "tasks with no evidence record: " + ", ".join(sorted(missing))`. Never silently downgrade without the reason (decision 7). +- [ ] **Step 6: `schemas/terminal.schema.json`** `:78-82`: `"mode": {"enum": ["all_required", "all_required_verified_evidence"]}`. `default` and `required` untouched. +- [ ] **Step 7: Compat sweep.** Run `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider scripts/test_completion_policy.py scripts/test_integrations.py scripts/test_reducer.py scripts/test_emit.py scripts/test_loop_contract_core.py` — all green and byte-unmodified. Then re-run the dogfood: both examples' terminals carry no `completion_policy`, normalize to `all_required`, and stay clean. +- [ ] **Step 8: Run the gate.** `uv run ... scripts/test_completion_verified_evidence.py` — **17 passed** extras / **15 passed 2 skipped** fallback. (17 = 15 single tests + one `parametrize(_MODES)` test contributing 2; the three decision-14 tests are single-mode because the predicate is mode-independent — it reads records and the store, not a schema.) +- [ ] **Step 9:** Commit: `feat(completion): all_required_verified_evidence — Succeeded on bound, hash-verified records`. + +**Acceptance:** +17 passed extras / +15 passed +2 skipped fallback; the five compat suites byte-unmodified and green; both dogfood examples still `ok: true` with `completion_policy` absent from their records; `_strict_evidence_failure` has exactly **one** definition (`grep -rn "is not bound into the event chain" loop/` returns one file). + +--- + +### Task 6: `scripts/metrics.py` — `verifier.source` decides gate evidence + +**Files:** Modify `scripts/metrics.py`; Create `scripts/test_metrics_verifier_source.py`. + +- [ ] **Step 1: Write the failing tests** — `scripts/test_metrics_verifier_source.py`: + +```python +"""An injected-callable verdict is a caller's self-report, not gate evidence.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from metrics import compute_metrics # noqa: E402 + +_ROOT = Path(__file__).resolve().parent.parent + + +def _bundle(source=None, *, green=True, task="T1", iteration_id=1): + body = {"iteration_id": iteration_id, "task": task, + "outcome": "PASS" if green else "FAIL", "passed": green, "score": 1.0 if green else 0.0} + if source is not None: + body["verifier"] = {"by": "loop.run", "source": source} + return body + + +def _ws(tmp_path, bundles): + target = tmp_path / "workspace" + (target / ".loop" / "artifacts").mkdir(parents=True, exist_ok=True) + (target / "RUNLOG.md").write_text( + "# RUNLOG\n\n## Iteration 1 — 2026-07-25\n\n### Outcome\n\n`task_passed`\n", + encoding="utf-8") + (target / ".loop" / "state.json").write_text(json.dumps( + {"schema": "loop-engineer/state@1", "state": "execute-task", "iteration_id": 1, + "terminal_state": None}), encoding="utf-8") + for name, body in bundles: + (target / ".loop" / "artifacts" / name).write_text(json.dumps(body), encoding="utf-8") + return target + + +def test_a_declared_command_bundle_is_gate_evidence(tmp_path): + target = _ws(tmp_path, [("verify-iter1.json", _bundle("declared_command"))]) + assert compute_metrics(target)["false_completion_rate"] == 0.0 + + +def test_an_injected_callable_bundle_is_not_gate_evidence(tmp_path): + target = _ws(tmp_path, [("verify-iter1.json", _bundle("injected_callable"))]) + assert compute_metrics(target)["provenance"]["injected_verifier_bundles"] == ["verify-iter1.json"] + + +def test_an_injected_only_claim_is_a_false_completion(tmp_path): + target = _ws(tmp_path, [("verify-iter1.json", _bundle("injected_callable"))]) + assert compute_metrics(target)["false_completion_rate"] == 1.0 + + +def test_a_legacy_bundle_without_a_verifier_block_still_counts(tmp_path): + target = _ws(tmp_path, [("verify-iter1.json", _bundle(None))]) + metrics = compute_metrics(target) + assert metrics["false_completion_rate"] == 0.0 + assert metrics["provenance"]["injected_verifier_bundles"] == [] + + +def test_excluded_bundles_are_named_under_provenance(tmp_path): + target = _ws(tmp_path, [("verify-iter1.json", _bundle("injected_callable")), + ("verify-iter2.json", _bundle("declared_command", iteration_id=1))]) + assert compute_metrics(target)["provenance"]["injected_verifier_bundles"] == ["verify-iter1.json"] + + +def test_injected_bundles_do_not_anchor_a_repair(tmp_path): + target = _ws(tmp_path, [("verify-iter1.json", _bundle("injected_callable", green=False)), + ("verify-iter2.json", _bundle("injected_callable"))]) + assert compute_metrics(target)["repair_productivity"] is None + + +def test_the_shipped_examples_keep_their_published_metrics(): + metrics = compute_metrics(_ROOT / "examples" / "flaky-test-triage") + assert metrics["false_completion_rate"] == 0.0 + assert metrics["repair_productivity"] == 1.0 +``` + +- [ ] **Step 2: Run it — expect 5 failures** (`KeyError: 'injected_verifier_bundles'` and the FCR/RP deltas). +- [ ] **Step 3: Implement.** In `_load_verify_bundles` (`scripts/metrics.py:230-252`), read the source alongside the green marker and carry it: + +```python + verifier = data.get("verifier") + source = verifier.get("source") if isinstance(verifier, dict) else None +``` + + and add `"source": source` to the appended dict. In `compute_metrics` (`:458`), partition immediately after loading: + +```python + all_bundles = _load_verify_bundles(loop_dir_path) + injected = sorted(b["name"] for b in all_bundles if b["source"] == "injected_callable") + bundles = [b for b in all_bundles if b["source"] != "injected_callable"] +``` + + Everything downstream (`_assign_bundles_to_iters`, `_claim_is_clean`, `_anchor_repair`) keeps consuming `bundles` unchanged, so an injected-only claim has no attached bundle and `_claim_is_clean` returns `False` at `:507-508` — a false completion, which is the honest reading. Add `injected` to the `provenance` block as `"injected_verifier_bundles"`. +- [ ] **Step 4: Verify the dogfood numbers did not move.** Re-run Task 0 Step 7's two commands and diff against the recorded scorecards. Both examples' bundles carry no `verifier` block, so `source is None` and nothing is excluded — decision 13's grandfathering. A movement in either number is a stop-and-explain. +- [ ] **Step 5: Run the gate.** `uv run ... scripts/test_metrics_verifier_source.py scripts/test_metrics.py scripts/test_metrics_cli.py` — the new file **7 passed**; the two existing files byte-unmodified and green. +- [ ] **Step 6:** Commit: `feat(metrics): an injected-callable verdict is not gate evidence`. + +**Acceptance:** +7 passed in both sets; `scripts/test_metrics.py` and `scripts/test_metrics_cli.py` byte-unmodified; both examples' FCR/RP/`evidence_backed` identical to the Task 0 recording. + +--- + +### Task 7: Adversarial pins — the mandated flips and what binding still misses + +**Files:** Modify `scripts/test_adversarial_verifier_identity.py` (4 tests rewritten in place, none deleted); Create `scripts/test_adversarial_evidence_binding.py`. + +- [ ] **Step 1: Flip the four Slice-2 pins in place.** Each keeps its residual half and gains the detection half; each docstring names the release in which the behavior changed. Net new tests: **0**. + +```python +def test_rewriting_the_evidence_record_is_caught_only_when_the_chain_binds_it(tmp_path, bound_workspace): + """Was `..._leaves_doctor_clean_pinned` in v0.11.0. A record written by the writer API + with no event store behind it is STILL a plain file and a rewrite is STILL invisible. + A record a dispatch bound into the chain is not: its bytes are covered by event_hash. + """ + workspace = _ws(tmp_path) # writer API, no event store + written = _write(workspace, executor="solo", verifier_identity="solo") + record = json.loads(written["evidence"].read_text(encoding="utf-8")) + record["verified_by"]["by"] = "ci" + written["evidence"].write_text(json.dumps(record), encoding="utf-8") + assert "evidence_chain_mismatch" not in _codes(doctor_report(workspace)) # residual + bound = bound_workspace["record"] # dispatched, chain-bound + data = json.loads(bound.read_text(encoding="utf-8")) + data["verified_by"]["by"] = "ci" + bound.write_text(json.dumps(data), encoding="utf-8") + assert "evidence_chain_mismatch" in _codes(doctor_report(bound_workspace["workspace"])) + + +def test_deleting_both_bundle_and_record_is_caught_only_when_the_chain_binds_them(tmp_path, bound_workspace): + """Was `..._leaves_doctor_clean_pinned` in v0.11.0. Without an event log nothing + remembers the dispatch happened, so the pair-deletion is still invisible; with one, + the committed event names artifacts that are gone (missing_bound_evidence). + """ + workspace = _ws(tmp_path) + written = _write(workspace, executor="w", verifier_identity="w") + written["evidence"].unlink() + assert "missing_evidence_record" in _codes(doctor_report(workspace)) + written["bundle"].unlink() + assert doctor_report(workspace)["ok"] is True # residual + bound_workspace["record"].unlink() + bound_workspace["bundle"].unlink() + assert "missing_bound_evidence" in _codes(doctor_report(bound_workspace["workspace"])) + + +def test_policy_digest_comparison_fires_only_against_a_live_task_entry(tmp_path): + """Was `test_no_automated_digest_comparison_exists_pinned` in v0.11.0. The comparison + now exists, and its two silences are the honest limitation: a record naming a task + TASKS.json no longer declares, and any record that is not the latest for its task. + """ + workspace = _ws(tmp_path) + _write(workspace, iteration_id=1, task=_task()) # task T-1 is not in TASKS.json + _write(workspace, iteration_id=2, task=_task(verify="true")) + assert "policy_digest_mismatch" not in _codes(doctor_report(workspace)) # residual + tasks = workspace / "TASKS.json" + tasks.write_text(json.dumps({"schema": "loop-engineer/tasks@1", "project": "p", + "tasks": [_task(status="pending")]}), encoding="utf-8") + assert "policy_digest_mismatch" in _codes(doctor_report(workspace)) + + +def test_doctor_hash_verifies_the_referenced_bundle(tmp_path): + """Was `test_doctor_does_not_hash_verify_the_referenced_bundle_pinned` in v0.11.0. + doctor now composes verify_evidence and surfaces its hash_mismatch verbatim. + """ + workspace = _ws(tmp_path) + written = _write(workspace, passed=False) + written["bundle"].write_text(json.dumps({"outcome": "PASS", "passed": True}), encoding="utf-8") + assert "hash_mismatch" in _codes(doctor_report(workspace)) +``` + + `test_hand_written_record_with_a_fabricated_code_digest_is_doctor_clean_pinned` (`:95`) keeps its name and body — a hand-written record whose `uri` resolves and whose digests are shaped correctly is still clean — but its docstring gains one sentence naming what *is* now checked (the bundle's bytes and, for a live task, the goalpost). The remaining five pins are unaffected; re-run them and confirm. + +- [ ] **Step 2: Write the new pins** — `scripts/test_adversarial_evidence_binding.py`, 14 tests. Each `*_pinned` name states the honest limitation in its docstring; the non-pinned ones prove a real detection. + +```python +"""What chain-bound evidence catches, and what it provably does not. + +The claim this file defends is "evidence whose bytes are bound into an ANCHORABLE +chain", never "tamper-proof evidence". Binding inherits Slice 1's trust model +exactly: a worker who can rewrite .loop/ can rewrite the store (repo-os-contract +#16, Integrity boundary), so without an external anchor a full rewrite verifies. +""" +``` + + 1. `test_a_full_rewrite_of_artifacts_and_store_is_not_caught_without_an_anchor_pinned` — rewrite the bundle, recompute the record, `DROP TRIGGER`, rewrite the row's `artifact_hashes` and re-chain the tail; assert `doctor_report(ws)["ok"] is True`. + 2. `test_an_anchored_head_catches_the_full_rewrite` — the positive control: the same rewrite with `doctor_report(ws, expect_chain_head=)` reports `chain_anchor_mismatch`. + 3. `test_a_record_written_outside_a_dispatch_is_never_chain_bound_pinned` — `emit.write_verify_evidence` called directly leaves no event; rewriting it is invisible. + 4. `test_a_legacy_iteration_event_can_never_be_bound_retroactively_pinned` — an event committed with empty `artifact_hashes` stays silent; the append-only triggers forbid the UPDATE that would fix it. + 5. `test_doctor_does_not_re_hash_the_verifier_file_pinned` — editing `scripts/verify-fast.sh` after a dispatch leaves doctor clean; `code_digest` stays in the recorded-not-compared tier. + 6. `test_an_older_record_for_a_moved_goalpost_is_not_compared_pinned` — the latest-per-task rule's cost, with the positive control that the latest record *is* compared. + 7. `test_deleting_the_object_alone_is_reported` — the object is a bound artifact; deleting it fires `missing_bound_evidence`. + 8. `test_deleting_every_artifact_and_the_store_leaves_a_clean_doctor_pinned` — with sidecars removed too, a fully-deleted run is a valid never-ran contract (`reference/repo-os-contract.md:1200-1202`). + 9. `test_the_verified_evidence_mode_is_opt_in_and_not_retroactive_pinned` — a Succeeded terminal written under `all_required` with `evidence: ["RUNLOG.md"]` stays clean. + 10. `test_a_hand_written_record_pointing_at_a_real_file_still_passes_pinned` — hash verification proves the pointer, never the provenance. + 11. `test_criterion_text_is_still_unbound_pinned` — editing SPEC.md's acceptance wording changes no digest. + 12. `test_metrics_still_counts_a_hand_written_declared_command_bundle_pinned` — `verifier.source` is a self-declared string; writing `declared_command` by hand restores gate-evidence status. + 13. `test_the_strict_mode_accepts_a_hand_written_record_in_a_store_less_contract_pinned` — **decision 14's named residual.** Scaffold a contract with no `.loop/events.db`, write a self-consistent record through the writer API, and terminate under `all_required_verified_evidence`: it is **accepted**, because nothing can chain-bind where nothing records. Positive control in the same test: the identical hand-written record in a *store-bearing* contract is refused with "not bound". Without the control this pin would document a hole instead of bounding one. + 14. `test_a_full_rewrite_satisfies_the_strict_mode_without_an_anchor_pinned` — the headline residual restated at the completion layer: rewrite bundle, record and the store's `artifact_hashes` row, re-chain the tail, and `terminate(..., VERIFIED_EVIDENCE_MODE)` succeeds. Positive control: the same tree with `doctor_report(ws, expect_chain_head=)` reports `chain_anchor_mismatch`. This is the pin that keeps the shipped claim *"bound into an anchorable chain"* and forbids *"tamper-proof"*. + +- [ ] **Step 2b: Discharge carry-forward 11** — add **one** test to `scripts/test_verifier_identity.py`, beside the existing argv[0] symlink-loop pin at `:65`: build a workspace whose **root** is a symlink loop, call `verifier_code_digest("./scripts/verify-fast.sh", workspace)`, and assert `(None, "unresolvable")`. `loop/verifier.py:64` resolves the root inside the same `try` as argv[0], so this passes on both 3.10-3.12 (pathlib `RuntimeError`) and 3.13 (`OSError` from `stat`) — the pin exists to keep that `try` boundary from being narrowed later, which is precisely how Slice 2's Task-1 Critical was born. Net new: **+1** collected item in an existing file. + +- [ ] **Step 3: Run the gate.** `uv run ... scripts/test_adversarial_evidence_binding.py scripts/test_adversarial_verifier_identity.py scripts/test_verifier_identity.py` — **14 passed**; **19 passed** (the identity file's count is unchanged from Slice 2 — only four bodies moved); and `test_verifier_identity.py` at `BASE + 1`. +- [ ] **Step 4: Prove the pins are non-vacuous — deterministic mutation probes with stated kill counts.** Stub each detector, re-run, revert, and record the counts in the PR body: + +```bash +bash -c "cd /mnt/c/Dev/projects/loop-engineer && \ + python3 - <<'PY' +import pathlib +p = pathlib.Path('loop/runtime.py'); s = p.read_text(encoding='utf-8') +p.write_text(s.replace(' issues.extend(_bound_evidence_issues(target, mode))', + ' pass # mutation probe', 1), encoding='utf-8') +PY + uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider \ + scripts/test_doctor_evidence_binding.py scripts/test_adversarial_evidence_binding.py \ + scripts/test_adversarial_verifier_identity.py; \ + git checkout -- loop/runtime.py" +``` + + **Five probes, with predicted kill counts.** Every number below is a *prediction*, not an observation — run each probe, record the observed count next to the prediction in the PR body, and treat a shortfall as a lost control and an excess as an over-broad detector. **Neither is a number to quietly overwrite** (Task 9 Step 8's rule applies here too; the Slice-2 review's standing lesson is that negative-control claims must be confirmed empirically, not derived on paper). + + | # | Stub | Predicted kills | + |---|---|---| + | 1 | `issues.extend(_bound_evidence_issues(target, mode))` → `pass` | **7** — 4 in `test_doctor_evidence_binding.py` (rewrite-bundle, rewrite-record, delete-pair, object-recovery-message), 1 in `test_adversarial_evidence_binding.py` (#7 object deletion), 2 in `test_adversarial_verifier_identity.py` (both flipped pins' detection halves) | + | 2 | `_policy_digest_issues` body → `return` | **3** — 2 in `test_doctor_evidence_verification.py`, 1 flipped pin | + | 3 | the `verify_evidence` composition in `_validate_evidence_records` removed | **5** | + | 4 | `_strict_evidence_failure`'s **chain-boundness** sub-check → always pass | **3** — the two decision-14 refusal tests in `test_completion_verified_evidence.py`, plus pin 13's positive control | + | 5 | `_strict_evidence_failure`'s **goalpost** sub-check → always pass | **1** — `test_terminate_refuses_a_cited_record_whose_goalpost_moved` | + + Probes 4 and 5 are the ones that matter most: they are the only evidence that decision 14 is enforced rather than described. A probe that kills zero means that file is decorative. Confirm `git status` is clean after each. +- [ ] **Step 5:** Commit: `test(adversarial): flip the four closed pins and pin what binding still misses`. + +**Acceptance:** +14 passed in `scripts/test_adversarial_evidence_binding.py` in both sets and +1 in `scripts/test_verifier_identity.py`; `scripts/test_adversarial_verifier_identity.py`'s collected count is unchanged from `BASE`; no `*_pinned` test was deleted (`git diff` shows four renames with bodies, not removals); all five mutation probes run, with predicted **and observed** kill counts recorded side by side in the PR body. + +--- + +### Task 8: Docs — the tier movements, the four codes, the retired claims + +**Files:** Modify `reference/repo-os-contract.md` (§16, §17, §22), `reference/safety-and-approvals.md`, `loop/evidence.py` (docstring), `schemas/evidence.schema.json` (description), `README.md`; extend `scripts/test_conformance.py`. + +> **Standing instruction for every step in this task:** before editing any sentence this plan quotes as "current", re-read that line in the live file and confirm the quote still matches. Task 0 already applies this discipline to test counts; it applies here for the same reason. Three citations in the pre-review draft of this plan had already drifted — two of them (M-1's literal fork, M-12's "three honest tiers" heading) were fixed in Slice 2's own pre-merge wave *after* the review transcript this plan cites, and a fourth (M-7's `unresolvable` row) likewise. A quote that no longer matches is a **stop-and-record**, not a stop-and-edit: append it to the carry-forward table as already-resolved so the PR body cannot claim it. + +- [ ] **Step 1: §16 — bind the artifacts, and fix the stale event-type list.** `reference/repo-os-contract.md:624-627` claims *"Event types: `contract_opened | iteration_appended | receipt_appended | terminal_written` — one-to-one with `loop.emit`'s four writer operations"*. The live enum has **nine** members (`loop/events.py:18-19`). Replace with the nine, noting that the first four are one-to-one with `loop.emit`'s writer operations and the rest are run-control and administrative corrections. Then add one paragraph under `### Hash chain (v0.10.0+)`: *"`artifact_hashes` is field eleven of the preimage, so any digest an appender places there is covered by `event_hash` and by `--expect-chain-head`. Since the evidence-wiring release a verified dispatch binds three entries — the verify bundle, its content-addressed object, and the evidence@1 record — computed **before** the append and written **after** it. An event that binds nothing (every event written before that release, and every append by a foreign writer) is silent by construction: the append-only triggers forbid the UPDATE a retroactive binding would need, exactly as `loop migrate` refuses to backfill hashes."* +- [ ] **Step 2: §17 — replace the scope boundary** (`:837-841`), which now states two false things. New text: *"`loop doctor` reads evidence@1 records from the declared location `.loop/evidence/*.json`, validates them, **hash-verifies the artifact each one references**, and compares the `policy_digest` of the latest record per task against the live `TASKS.json` entry. `Succeeded` still requires non-empty evidence *paths* under the default `all_required` policy; under the opt-in `all_required_verified_evidence` policy it requires every entry to be a hash-verified evidence@1 record that — where an event store exists — some event bound into the hash chain, and whose recorded goalpost matches the live task."* +- [ ] **Step 3: §17 — the content-addressed object and the bound set.** New subsection `### Bound evidence (v0.12.0+)` documenting: the three-entry `artifact_hashes` set and its order; that the object location is `artifact_object_path(workspace, record["sha256"])` — *derived, never stored, so evidence@1 gains no field* — and that the object is the recovery source when the friendly-named bundle is swapped; that objects are created once and a differing-bytes collision is a typed refusal, never an overwrite; and the ordering rule *"build (no I/O) → append (binds) → write (object, staged bundle, record, replace)"* with its two consequences: a build failure commits nothing, and a crash after the commit leaves a detectable `missing_bound_evidence` rather than a silent gap. +- [ ] **Step 4: §17 — the completion-mode subsection.** Document `all_required_verified_evidence`: the four enforcement layers and the honest reason each is as strong as it is (emit and doctor reach a workspace *and* the event store; the reducer and `to_terminal_state` check only the `.loop/evidence/*.json` shape because they are pure and must not pretend to touch a filesystem); that legacy and default records normalize to `all_required` and are untouched; and that `loop run`'s auto-terminal adopts the strict mode only when every task has an evidence record, and otherwise says in `reason` which tasks did not. + + **This subsection must carry decision 14's exact bar and its exact limits — one paragraph, no hedging.** The strict mode proves three things about every cited entry: the record is structurally valid, the artifact it names hashes to the digest it declares, and — *when an event store exists* — that record's bytes are the bytes some event bound into the hash chain; plus, when the record names a live task, that its recorded goalpost is the live one. It proves **none** of: that a human or an independent runner produced it (a hand-written record whose pointer resolves and whose digests are self-consistent still satisfies the mode in a **store-less** contract — say this plainly, and name `missing_event_store` as the tripwire that catches deleting a store out from under a real run); that the chain itself was not rewritten (a worker who can rewrite `.loop/` can rewrite the store, so **without `--expect-chain-head` a full rewrite satisfies the strict mode** — this is the same anchor dependency Slice 1 shipped, not a new one); or that the two pure layers enforce any of it (they check shape only). State that the mode's strength is therefore **store-dependent and anchor-dependent**, and that the pins holding each limit are `test_the_strict_mode_accepts_a_hand_written_record_in_a_store_less_contract_pinned` and `test_a_full_rewrite_satisfies_the_strict_mode_without_an_anchor_pinned`. Also name the deliberate asymmetry with decision 5: the terminal check is **per-cited-record**, doctor's general goalpost check is **latest-per-task**, and the reason is that citing a record is a present-tense claim. +- [ ] **Step 5: §17 — rewrite the integrity boundary, naming every tier movement.** Keep the four-tier shape. **The heading needs no fix** — `:951` already reads *"The integrity boundary, in four honest tiers"*; review finding M-12 was discharged before PR #94 merged (carry-forward row 7). Confirm that in the live file, then edit the four bullets' *content* only: + - **Fails `loop doctor`** — now additionally: a discovered record whose referenced artifact does not match its `sha256` (`hash_mismatch`) or does not resolve at all (`missing_evidence_path`); a record whose recorded goalpost disagrees with the live task (`policy_digest_mismatch`); a chain-bound artifact whose bytes changed (`evidence_chain_mismatch`) or vanished (`missing_bound_evidence`); a Succeeded terminal under the strict policy whose evidence does not verify (`unverified_evidence_terminal`). + - **Recorded for later comparison, not checked by any shipped surface** — now **only** `code_digest`, and the reason it stays: the verifier file legitimately changes between runs, so comparing it without a declared baseline would fire on every honest edit. `policy_digest` has left this tier. + - **Detectable only by explicitly calling `verify_evidence()`** — this tier is now **empty**; doctor composes the call. Say so, and say which release moved it. + - **Not surfaced at all** — retain: a false `verified_by.by`; an omitted executor; a hand-written record whose pointer resolves (verification proves the pointer, never the provenance); criterion *text*. Add the new residuals: a record written through the writer API with no event behind it is never bound; a legacy event can never be bound retroactively; an older record for a task whose goalpost moved is not compared; **a hand-written record satisfies `all_required_verified_evidence` in a contract that has no event store, because there is nothing to bind against** (decision 14's named degradation — `missing_event_store` is what catches removing a store from a run that had one); and — the headline — **a worker who can rewrite `.loop/` can rewrite the store, so without `--expect-chain-head` a full rewrite of artifacts *and* chain verifies clean, including under the strict completion mode.** Binding makes tampering detectable against an anchor, not impossible. + - Close with: *"This does not make evidence tamper-proof. It binds evidence bytes into a chain someone outside the worker's trust domain can anchor, and it fails closed when what is on disk is not what was verified."* +- [ ] **Step 6: §17 — retire the FCR sentence.** `:926-929` currently says *"`scripts/metrics.py` does not read `verifier.source` today … enforcing that distinction is Slice 3 scope."* Replace: *"`scripts/metrics.py` reads `verifier.source`: a bundle whose source is `injected_callable` is excluded from the FCR input set and named under `provenance.injected_verifier_bundles`. A bundle with no `verifier` block at all has an unknown source and still counts — grandfathering by absence, not by guess. `verifier.source` is a self-declared string, so a hand-written `declared_command` restores gate-evidence status; that is pinned as a limitation."* +- [ ] **Step 7: §22 — four new rows**, in the existing table style at `:1134-1141`: + +``` +| `evidence_chain_mismatch` | An artifact whose digest an event bound into the hash chain no longer matches its bytes on disk. The original bytes may remain in the content-addressed object store. | +| `missing_bound_evidence` | An event bound an artifact path into the chain and that path is now absent or unreadable — a deleted pair, or the sanctioned crash window between the durable append and the evidence write. Only fires for events that bound something; a legacy event binds nothing and is silent. | +| `policy_digest_mismatch` | The latest evidence record for a task records a `policy_digest` that is not the live `TASKS.json` goalpost — the declared `verify`/`criterion_ref`/`depends_on`/`id` changed after the verification. Re-verify to record the current goalpost. | +| `unverified_evidence_terminal` | A `Succeeded` terminal declaring `completion_policy.mode: all_required_verified_evidence` has an evidence entry that fails one of the mode's three checks: it is not a hash-verified evidence@1 record; or (when an event store exists) no event bound those bytes into the chain; or the record's `policy_digest` is not the live `TASKS.json` goalpost for the task it names. The message names which. In a contract with no event store the middle check is skipped — the mode's strength is store-dependent by construction. | +``` + + Plus a "do not confuse" note: `missing_evidence_record` walks bundles looking for their record; `missing_evidence_path` comes out of `verify_evidence` and means a *record's* `uri` does not resolve — the two are opposite directions of the same pair. +- [ ] **Step 8: Retire the false claims on the shipped surfaces.** `loop/evidence.py:3-8` and `schemas/evidence.schema.json:5` both still say doctor *"does not yet hash-verify the artifacts those records reference"*. Rewrite both to state what is now true and what still is not (no `code_digest` re-hash; no proof of provenance). `reference/safety-and-approvals.md` §5: append one sentence noting the goalpost half of the invariant now has a machine check too (`policy_digest_mismatch`), and that the protected-file and canary rules remain load-bearing because a rename still evades. +- [ ] **Step 9: README** — extend the event-sourced-runtime bullet with one sentence: *"a verified dispatch binds its evidence digests into the hash chain, and `loop doctor` re-hashes them"*, plus one honest clause: *"binding makes tampering detectable against an anchor, not impossible — without `--expect-chain-head` a worker who can rewrite `.loop/` can rewrite the chain too."* Do not touch the "how it compares" heading or the FCR/RP markers (`structural.json` `readme_differentiation` pins them). +- [ ] **Step 10: Pin the docs** — append 4 tests to `scripts/test_conformance.py`: + +```python +def test_documented_artifact_binding_vector_matches_the_writer(tmp_path): + """The three bound paths and their order are normative, not incidental.""" + workspace = _binding_fixture(tmp_path) + built = emit.build_verify_evidence(workspace, run_id="run-1", iteration_id=1, + task=_VECTOR_TASK, passed=True, + code_identity=injected_verifier_identity()) + assert [entry["path"] for entry in built.artifact_hashes] == [ + ".loop/artifacts/verify-iter1.json", + f".loop/artifacts/objects/{built.sha256[:2]}/{built.sha256}", + ".loop/evidence/evidence-iter1.json"] + doc = (ROOT / "reference" / "repo-os-contract.md").read_text(encoding="utf-8") + assert ".loop/artifacts/objects/" in doc + + +def test_every_new_doctor_code_is_documented_in_section_22(): + doc = (ROOT / "reference" / "repo-os-contract.md").read_text(encoding="utf-8") + for code in ("evidence_chain_mismatch", "missing_bound_evidence", + "policy_digest_mismatch", "unverified_evidence_terminal"): + assert f"`{code}`" in doc + + +def test_no_shipped_surface_still_claims_evidence_is_unverified(): + for path in (ROOT / "loop" / "evidence.py", ROOT / "schemas" / "evidence.schema.json", + ROOT / "reference" / "repo-os-contract.md"): + text = path.read_text(encoding="utf-8") + assert "does not yet hash-verify" not in text + assert "not checked by any shipped surface:** `policy_digest`" not in text + # decision 14: the strict mode's two residuals must be named where the mode is + # documented, not only in this plan. A capability paragraph without its limits is + # the overclaim this slice exists to prevent. + contract = (ROOT / "reference" / "repo-os-contract.md").read_text(encoding="utf-8") + assert "no event store" in contract and "--expect-chain-head" in contract + assert "detectable against an anchor" in contract + # Deliberately NOT asserting the absence of "tamper-proof": §17 names it as the + # forbidden claim, so a substring ban would fire on the honesty sentence itself. + + +def test_section_16_event_type_list_matches_the_code(): + from loop.events import EVENT_TYPES + doc = (ROOT / "reference" / "repo-os-contract.md").read_text(encoding="utf-8") + assert all(f"`{name}`" in doc or name in doc for name in EVENT_TYPES) + assert "one-to-one with `loop.emit`'s four writer operations" not in doc +``` + +- [ ] **Step 11: Run the gates.** `uv run --with pyyaml python3 -B scripts/self_eval.py` (**13/13** — proves no structural pin was disturbed), `uv run --with pyyaml python3 -B scripts/validate_frontmatter.py` (**9/9**), and `uv run ... scripts/test_conformance.py scripts/test_docs_adoption.py scripts/test_docs_claims.py scripts/test_docs_baseline.py`. +- [ ] **Step 12:** Commit: `docs(contract): bound evidence, the verified-evidence policy, and the moved tiers`. + +**Acceptance:** +4 passed in both sets; self_eval 13/13; frontmatter 9/9; §17's boundary heading matches its bullet count; the "Detectable only by explicitly calling `verify_evidence()`" tier is documented as empty with the release that emptied it named. + +--- + +### Task 9: Full-suite gate + feature PR + +**Files:** none new. + +- [ ] **Step 1: Extras suite.** `uv run --with pyyaml --with jsonschema --with pytest python3 -B -m pytest -q -p no:cacheprovider scripts` — expect `BASE_EXTRAS.passed + 90` passed, `BASE_EXTRAS.skipped` skipped. +- [ ] **Step 2: Fallback suite.** `uv run --with pyyaml --with pytest python3 -B -m pytest -q -p no:cacheprovider scripts` — expect `BASE_FALLBACK.passed + 82` passed, `BASE_FALLBACK.skipped + 8` skipped. + +| Task | new collected tests | jsonschema halves (skip in fallback) | how it decomposes | +|---|---|---|---| +| 1 | 12 | 0 | 12 single | +| 2 | 9 | 0 | 9 single | +| 3 | 12 | 2 | 8 single + 2 `parametrize(_MODES)` × 2 | +| 4 | 14 | 4 | 6 single + 4 `parametrize(_MODES)` × 2 = 6 + 8 | +| 5 | 17 | 2 | 15 single (incl. 3 decision-14 tests) + 1 `parametrize(_MODES)` × 2 | +| 6 | 7 | 0 | 7 single | +| 7 | 15 | 0 | 14 new pins + 1 in `test_verifier_identity.py` (Step 2b) | +| 8 | 4 | 0 | 4 single | +| **total** | **90** | **8** | | + + Arithmetic the implementer must reproduce: 12+9+12+14+17+7+15+4 = **90**; 90 − 8 = **82** fallback passes. Every row counts *collected items* — a `parametrize("mode", _MODES)` test contributes **2**, and its `strict` half is what skips without jsonschema. Task 7's four flips and Task 4's fixture repair contribute **0**: they rewrite existing items. + + **Derive each row yourself with `pytest --collect-only -q ` before trusting it.** The pre-review draft of this table stated 11 for Task 3 against a block that collects 12, and the error propagated into the grand total — exactly the wasted reconciliation cycle Step 8 exists to catch. The decomposition column exists so that a mis-add is visible in the row rather than inherited by the total. + +- [ ] **Step 3: Fresh-worktree verification.** `git -C /mnt/c/Dev/projects/loop-engineer/.tmp/s3-base checkout --detach feat/evidence-wiring`, re-run Steps 1-2 against `/mnt/c/Dev/projects/loop-engineer/.tmp/s3-base/scripts`, record both numbers with their dependency sets, then `git -C /mnt/c/Dev/projects/loop-engineer worktree remove /mnt/c/Dev/projects/loop-engineer/.tmp/s3-base`. +- [ ] **Step 4: Zero-regression spot checks.** `scripts/test_doctor_eventstore.py`, `scripts/test_event_chain.py`, `scripts/test_eventstore.py`, `scripts/test_reducer.py`, `scripts/test_adversarial_chain.py`, `scripts/test_adversarial_process.py`, `scripts/test_loop_simulate_zero_writes.py`, `scripts/test_metrics.py`, `scripts/test_verify_evidence_writer.py` green **and** byte-unmodified in `git diff --stat`. +- [ ] **Step 5: Dogfood.** `loop doctor` clean on `examples/coverage-repair` and `examples/flaky-test-triage`; `loop inspect examples/coverage-repair` still scores **90**; `scripts/metrics.py` on both examples identical to Task 0 Step 7. +- [ ] **Step 6: Interpreter matrix.** Run the kernel-touching test files under 3.10 and 3.13 (`uv run --python 3.10 ...`, `--python 3.13`) — Slice 2's Task-1 Critical was a version-dependent pathlib exception class, and this slice adds new `OSError` paths in the binding walk and the object store. +- [ ] **Step 7:** Push and open the PR **non-draft** (single `opened` event — the CI-wedge lesson): `feat(kernel): bound, hash-verified evidence and the verified-evidence completion policy (slice 3/5)`. The body carries the four-tier integrity boundary verbatim, the **five** mutation probes with predicted *and* observed kill counts, both fresh-worktree baselines with dependency sets named, the four flipped pins listed by old and new name, the behavior matrix below, and — required — one paragraph stating the strict completion mode's exact bar and its two residuals (store-less contracts, and no-anchor full rewrites). It must **not** claim M-1, M-7 or M-12 as fixed by this slice: all three were already resolved on `origin/main` (carry-forward rows 7 and 10). +- [ ] **Step 8:** Any count mismatch in either direction at Steps 1-2 is a **stop-and-explain**, not a number to adjust: re-derive the offending file's collected count and reconcile against the table before touching an acceptance line. + +**Behavior matrix — each row names the test that proves it:** + +| Situation | Surface | Expected | Test | +|---|---|---|---| +| no evidence, no bundle, no store | doctor | byte-identical to today | `test_absent_event_store_matches_pre_slice_doctor_shape` (unmodified), `test_absent_evidence_directory_is_a_byte_stable_no_op` (unmodified) | +| clean runner dispatch | doctor (both modes) | `ok` | `test_a_clean_runner_dispatch_is_doctor_clean` | +| bound bundle rewritten | doctor | `evidence_chain_mismatch` | `test_rewriting_a_bound_bundle_is_reported` | +| bound record rewritten | doctor | `evidence_chain_mismatch` (was clean in v0.11.0) | `test_rewriting_the_evidence_record_is_caught_only_when_the_chain_binds_it` | +| bound pair deleted | doctor | `missing_bound_evidence` (was clean in v0.11.0) | `test_deleting_both_bundle_and_record_is_caught_only_when_the_chain_binds_them` | +| record written via the writer API, then rewritten | doctor | **no finding** (pinned residual) | `test_a_record_written_outside_a_dispatch_is_never_chain_bound_pinned` | +| legacy event, empty `artifact_hashes` | doctor | **no finding** (pinned, unrepairable) | `test_a_legacy_iteration_event_can_never_be_bound_retroactively_pinned` | +| swapped bundle | doctor | `hash_mismatch` (was `verify_evidence`-only) | `test_doctor_hash_verifies_the_referenced_bundle` | +| record `uri` absent | doctor | `missing_evidence_path` | `test_a_record_whose_uri_is_absent_reports_missing_evidence_path` | +| goalpost moved, latest record | doctor (both modes) | `policy_digest_mismatch` | `test_policy_digest_mismatch_against_the_live_task` | +| goalpost moved, older record | doctor | **no finding** (pinned) | `test_only_the_latest_record_per_task_is_compared`, `test_an_older_record_for_a_moved_goalpost_is_not_compared_pinned` | +| record names an unknown task | doctor | **no finding** (pinned) | `test_a_record_naming_an_unknown_task_is_not_compared` | +| verifier file edited after the run | doctor | **no finding** — `code_digest` stays uncompared (pinned) | `test_doctor_does_not_re_hash_the_verifier_file_pinned` | +| full rewrite of artifacts + store, no anchor | doctor | `ok` (pinned headline residual) | `test_a_full_rewrite_of_artifacts_and_store_is_not_caught_without_an_anchor_pinned` | +| same rewrite, `--expect-chain-head` supplied | doctor | `chain_anchor_mismatch` | `test_an_anchored_head_catches_the_full_rewrite` | +| bundle swapped | object store | original bytes survive | `test_rewriting_the_bundle_leaves_the_object_intact` | +| object collision, different bytes | writer | typed `EmitError`, never an overwrite | `test_an_object_collision_with_different_bytes_raises_emit_error` | +| non-canonicalizable task | dispatch | build fails, **nothing committed** | `test_a_build_failure_commits_no_event` | +| evidence write fails post-commit | dispatch | typed `RunnerError` naming the committed iteration | `test_an_evidence_write_failure_after_the_commit_is_still_loud` | +| crash before the iteration commit | dispatch | byte-identical tree (unchanged pin) | `test_crash_injection_before_iteration_event_commit_leaves_no_partial_dispatch` | +| Succeeded, default policy, path evidence | emit / doctor | accepted (unchanged) | `test_terminate_under_the_default_mode_is_unchanged` | +| Succeeded, strict policy, path evidence | emit | `EmitError` | `test_terminate_refuses_unverified_evidence_under_the_new_mode` | +| Succeeded, strict policy, swapped artifact | doctor (both modes) | `unverified_evidence_terminal` | `test_doctor_reports_unverified_evidence_terminal` | +| Succeeded, strict policy, non-record entry | reducer / integrations | `EventReplayError` / `FailedUnverifiable` | `test_the_reducer_refuses_a_non_record_evidence_entry_under_the_new_mode`, `test_integrations_projection_refuses_non_record_evidence_under_the_new_mode` | +| Succeeded, strict policy, self-consistent but **unbound** record, store present | emit | `EmitError` naming "not bound" | `test_terminate_refuses_a_hash_verified_but_unbound_record_when_a_store_exists` | +| same, terminal hand-written past emit | doctor | `unverified_evidence_terminal` | `test_doctor_reports_an_unbound_record_cited_by_a_strict_terminal` | +| Succeeded, strict policy, cited record's goalpost moved | emit | `EmitError` naming "goalpost" | `test_terminate_refuses_a_cited_record_whose_goalpost_moved` | +| same, **no event store** (writer-API contract) | emit | accepted — the mode degrades to hash-verify + goalpost (pinned residual) | `test_the_strict_mode_accepts_a_hand_written_record_in_a_store_less_contract_pinned` | +| full rewrite of artifacts + store, strict policy, no anchor | emit / doctor | accepted (pinned headline residual) | `test_a_full_rewrite_satisfies_the_strict_mode_without_an_anchor_pinned` | +| workspace root is a symlink loop | `verifier_code_digest` | `(None, "unresolvable")` on 3.10-3.13 | Task 7 Step 2b's new pin in `scripts/test_verifier_identity.py` | +| every task has a record | auto-terminal | strict mode + record evidence | `test_runner_auto_terminal_adopts_the_verified_mode_when_every_task_has_a_record` | +| a task has no record | auto-terminal | `all_required` + a reason naming it | `test_runner_auto_terminal_keeps_all_required_when_a_task_has_no_record` | +| injected-callable bundle | metrics | excluded from FCR, named in provenance | `test_an_injected_callable_bundle_is_not_gate_evidence`, `test_an_injected_only_claim_is_a_false_completion` | +| bundle with no `verifier` block | metrics | still counts (grandfathered) | `test_a_legacy_bundle_without_a_verifier_block_still_counts` | +| hand-written `declared_command` | metrics | counts (pinned limitation) | `test_metrics_still_counts_a_hand_written_declared_command_bundle_pinned` | +| shipped examples | metrics / doctor / inspect | unchanged | `test_the_shipped_examples_keep_their_published_metrics`, Task 9 Step 5 | + +--- + +### Task 10: Release cut (separate PR, after Task 9 merges) — OPERATOR-GATED + +**Files:** Modify `pyproject.toml`, `.claude-plugin/plugin.json`, `README.md`, `scripts/test_docs_version.py`, `CHANGELOG.md`. + +- [ ] **Step 1: Determine the version from the tree, not from this plan.** Read `pyproject.toml` `version`. If it reads `0.10.0`, the Slice-2 cut never ran and **this cut is `0.11.0`**, and its CHANGELOG entry must cover PR #94 *and* this slice (the Slice-2 plan's Task-9 CHANGELOG bullets are the input for the first half). If it reads `0.11.0`, Slice 2 was cut and **this is `0.12.0`**. Record the determination and the reason in the PR body; every later step uses ``. +- [ ] **Step 2:** Branch `release/v` off updated main; bump `version` in `pyproject.toml` and `.claude-plugin/plugin.json`. +- [ ] **Step 3: README version surfaces** — the release badge, the documented action pin (`SollanSystems/loop-engineer@v0.10.0` → `@v`), and the release/tag table rows. +- [ ] **Step 4:** `scripts/test_docs_version.py`: retarget the pin at `:20` to `` and add `"## "` to the CHANGELOG-headings assertion at `:42`. +- [ ] **Step 5: CHANGELOG `## `** — the bound `artifact_hashes` set and the content-addressed object store; doctor's hash verification and the composed `verify_evidence` codes; `policy_digest_mismatch` and its latest-record-per-task rule; `evidence_chain_mismatch` / `missing_bound_evidence`; the `all_required_verified_evidence` completion mode, its four enforcement layers, its three-part bar (hash-verified + chain-bound-when-a-store-exists + goalpost-agreeing), and the terminal@1 schema widening; `metrics.py`'s `verifier.source` exclusion and the FCR-input change it implies for any loop that dispatched with an injected verifier; and these explicit honesty notes: (a) binding makes tampering **detectable against an anchor**, not impossible — without `--expect-chain-head` a worker who can rewrite `.loop/` can rewrite the chain; (b) a legacy event can never be bound retroactively, so evidence deleted from a pre-release run stays undetectable; (c) `code_digest` is still never compared against a live re-hash; (d) the Succeeded tightening is **opt-in and not retroactive** — every existing terminal record normalizes to `all_required` and is unaffected; (e) doctor still proves the *pointer*, never the provenance: a hand-written record whose artifact exists and hashes correctly passes; and (f) the strict completion mode's strength is **store-dependent and anchor-dependent** — in a contract with no event store there is nothing to bind against, so a self-consistent hand-written record satisfies it, and with a store but no `--expect-chain-head` a worker who rewrites artifacts *and* chain satisfies it too. Note (f) is not optional polish: it is the difference between "Succeeded now requires proven work" (false) and "Succeeded now requires evidence bound into an anchorable chain" (true). +- [ ] **Step 6: Gates.** Full suite in both dependency sets + `python3 -m loop --version` smoke + `self_eval` 13/13 + both dogfood examples doctor-clean. +- [ ] **Step 7: Land.** PR, required checks, merge. +- [ ] **Step 8: Publish.** `git tag v && git push origin v` → PyPI publish. If Step 1 determined `0.11.0`, the operator's standing Slice-2 gate is discharged by this tag; if it determined `0.12.0`, confirm `v0.11.0` is already published first. Verify `uvx loop-engineer@ inspect examples/coverage-repair` from a scratch clone. +- [ ] **Step 9: Refresh the harness.** `git archive HEAD | tar -x -C `; `diff -rq` to verify; restart Claude Code. + +**Acceptance:** tag published, PyPI funnel verified from a scratch clone, plugin cache `diff -rq` clean, and the version determination recorded with its reason. + +--- + +## Self-review + +**Spec coverage vs the Slice-3 scope contract:** + +| Scope item | Where | +|---|---| +| Content-addressed objects via `artifact_object_path` | Task 1 (writer + object store), decision 3 (derived, never stored — evidence@1 gains no field) | +| Doctor **hash-verifies** evidence referenced by criteria | Task 4 Step 4 (composed `verify_evidence` over every discovered record) + Task 5 Step 4 (`unverified_evidence_terminal` for criteria-referenced evidence under the strict policy) | +| Doctor compares `policy_digest` against the live TASKS.json entry | Task 4 `_policy_digest_issues`, decision 5 (latest-record-per-task, with both silences pinned) | +| Evidence artifacts bind into the Slice-1 hash chain | Tasks 1-3; decision 1 rules the mechanism (`artifact_hashes`, **no new event type**) with the rejected alternative costed | +| `Succeeded` tightens from non-empty paths to hash-verified evidence | Task 5, decisions 6-7 **and 14** (hash-verified *and* chain-bound *and* goalpost-agreeing at the two workspace-bearing layers); compat proven against both shipped examples and the runner's own auto-terminal | +| Carry-forward 1: FLIP, never delete, the two honest-limitation pins | Task 7 Step 1 (four flips, zero deletions, count unchanged) | +| Carry-forward 2: `missing_verify_bundle` counterpart | Task 3 `missing_bound_evidence` + decision 4's naming ruling | +| Carry-forward 3: reverse record-without-bundle check | Task 4 (`missing_evidence_path`, surfaced verbatim) | +| Carry-forward 4: `metrics.py` reads `verifier.source` | Task 6; §17's promise retired in Task 8 Step 6 | +| Carry-forward 5: `policy_digest` live comparison | Task 4 | +| Inherited constraints (zero deps, `@1`, typed fail-loud, byte-stability, structural.json, env quirks) | Global Constraints | +| Compat analysis for the G1 behavioral change | Global Constraints + decision 6 + Task 5 Steps 4-7; the four G1 call sites (`loop/emit.py:294`, `loop/contract.py:278`, `loop/reducer.py:32`, `loop/integrations.py:163`) are each named with the strength they can honestly reach | +| Honest framing; the §17 four-tier boundary updated with each movement named | Task 8 Step 5 (two tiers gain members, one empties, one gains **five** new residuals — the fifth is decision 14's store-less degradation) | +| Adversarial/honest-limitation task with proven non-vacuity | Task 7 (14 new pins + 4 flips + 1 carry-forward pin + **five** mutation probes with predicted *and* observed kill counts) | +| Docs task | Task 8, with a standing re-diff-before-editing instruction | +| Operator-gated, version-flexible release cut | Task 10 Step 1 | +| Carry-forward 10 (M-7 `unresolvable` wording) | Recorded discharged — already fixed on `origin/main:869` | +| Carry-forward 11 (T6 workspace-root symlink-loop pin) | Task 7 Step 2b | +| Post-review adjudications | **Post-review design changes** table (7 findings: 1 BLOCKER, 2 MAJOR, 4 MINOR) | + +**Deliberately absent, each with a reason:** no new event type (decision 1); no new evidence@1 field (decision 3); no `code_digest` live comparison (decision 5); no retroactive binding or backfill (decision 10); no `verify-*.json` naming enforcement (carry-forward 9); no `loop simulate` write prediction (decision 12); no CLI flag (nothing in this slice needs one); no change to `loop/events.py`, `loop/chain.py`, `schemas/event.schema.json`, `action.yml`, `ci.yml`, or `evals/cases/structural.json`. **Added post-review, and deliberately still absent:** the strict mode does **not** require an event store to exist (decision 14 — that would break the pinned writer-API path), does **not** enforce binding or goalposts in the reducer or `to_terminal_state` (they are pure), and does **not** require an anchor (`--expect-chain-head` stays the operator's call, as in Slice 1). All three are pinned, not merely stated: Task 7 pins 13-14 and Task 5's two refusal tests are the controls, and mutation probes 4-5 prove they have teeth. + +**Placeholder scan:** grep for `TBD`, `TODO`, `...`, "similar to Task" in task bodies → the only ellipses are the two labelled `# ... preamble as in Task 3 ...` markers in Tasks 4-6, which name the exact block to copy, and Task 7 Step 2's numbered list, whose **fourteen** test names and one-line behaviors are fully specified but whose bodies are left to the implementer *because each is a two-to-six-line assertion over fixtures Tasks 1-5 already define*. Pins 13 and 14 each additionally name their positive control, so neither can be written as a bare "assert clean". Every other block is literal code or a literal command. Fixture names (`ready_workspace`, `two_task_workspace`, `dispatched_workspace`, `predone_workspace`, `legacy_workspace`, `corrupt_store_workspace`, `bound_workspace`, and decision 14's store-less scaffold) are new per-file fixtures built from `scripts/test_runner_dispatch.py`'s and `scripts/test_doctor_eventstore.py`'s existing setup helpers — read both files before writing them; there is no `conftest.py` in this tree. + +**Type-consistency check:** +- `BuiltEvidence` is frozen and total: every field is populated in the single constructor call, so `write_verify_evidence` indexes it without `.get()` defaults and a foreign object fails loudly with `AttributeError`. +- `build_verify_evidence` raises exactly `EmitError` (from `ChainHashError` conversion or self-validation) and `OSError` never, because it performs no writes; `write_verify_evidence` raises `EmitError` or `OSError`, which is the tuple `dispatch_once` already catches (`loop/runner.py:267`). +- `artifact_hashes` entries are `dict[str, str]` with exactly `path` (workspace-relative POSIX, non-empty) and `sha256` (64 lowercase hex) — the two fields `schemas/event.schema.json:25-29` requires and `loop/events.py:214-219` type-checks in fallback mode, so no validation change is needed. +- `_bound_evidence_issues` returns `list[dict]` and never raises: `_events` failures are already caught by `event_consistency_issues`'s `RuntimeStoreError` branch (`loop/runtime.py:305`), and per-artifact `OSError` becomes a finding rather than an exception. +- `bound_artifact_digests` returns `dict[str, str] | None` and **does** raise `RuntimeStoreError` on an unreadable store — the tri-state is load-bearing: `None` (no store) is a documented degradation, `{}` (store, nothing bound) is a legitimate legacy run, and the raise is an errored check that must fail rather than skip (R007). Its two callers handle the raise differently and deliberately: `emit.terminate` converts it to `EmitError` (refuse the write); `loop.contract` converts it to `unverified_evidence_terminal` (report, because doctor's unreadable-store branch already carries its own `error_code` and must not start raising). +- `_strict_evidence_failure` returns `str | None` and is the single definition of the strict bar. It performs I/O (record read, artifact hash, store read) and therefore lives in `loop/contract.py` — not in `loop/completion.py`, which stays pure so the reducer and `to_terminal_state` can keep importing it without acquiring a filesystem dependency. +- `policy_requires_verified_evidence` and `evidence_entry_is_record_shaped` are pure and total; the former raises `CompletionPolicyError` only for a malformed policy, which every call site already handles. +- `_policy_digest_issues` swallows `ChainHashError` **only** for a non-canonicalizable *task entry* — that is TASKS.json's own defect and `_validate_tasks` is its reporter; it never swallows a record defect. +- `metrics._load_verify_bundles`'s dict gains `"source": str | None`; every downstream consumer (`_assign_bundles_to_iters`, `_claim_is_clean`, `_anchor_repair`) reads only keys it already read. + +**Open risks for the implementer:** +1. `scripts/test_doctor_evidence.py`'s fixture repair (Task 4 Step 1) is the highest-blast-radius edit in the plan — roughly eight assertions in a Slice-2 file depend on it. Do it **first**, run that file alone, and reconcile before writing a line of Task 4's implementation. +2. `write_verify_evidence`'s return dict and `dispatch_once`'s result dict each gain a key. Grep for whole-dict equality on both before Tasks 1 and 2 (Task 1 Step 4, Task 2 Step 4). Slice 2's own audit found `scripts/test_loop_simulate_cli.py:106` compares whole-dict only on the untouched `blocked` action — re-verify rather than trusting that line. +3. Moving `paths = _require_contract(target)` above `terminate`'s G1 block (Task 5 Step 4) changes which error surfaces first for a workspace that is *both* contract-less and G1-violating. `_require_contract`'s message is the more actionable one, but a Slice-2 test may pin the other order — grep `scripts/test_emit.py` for `refusing Succeeded` before making the move. +4. The object store adds files under `.loop/artifacts/objects/`. Grep for tree-byte-equality assertions over a *dispatched* workspace (`rglob` + sha256 comparisons) before Task 1; the zero-write pins compare trees around read-only verbs and are safe, but a smoke test that dispatches and then compares would need its expectation updated, not its assertion weakened. +5. Task 3's binding walk performs a third read-only fold per `doctor` invocation (decision 9). If a benchmark pin exists in `scripts/test_benchmark_harness.py` that bounds doctor's runtime, check it before merging. +6. The per-file counts in Task 9's table are this plan's predictions. Re-tally per file at Task 9 and treat any mismatch in either direction as a stop-and-explain (Step 8), not a number to adjust. **One of these predictions was already wrong once** — the pre-review draft claimed 11 for Task 3 against a block that collects 12 — so treat the table as a hypothesis, not a fact. +7. Task 7's mutation probes edit `loop/runtime.py` and `loop/contract.py` in place via heredoc. Confirm `git status` is clean after each; a probe left applied would make the whole suite lie. +8. **Decision 14 adds an import edge `loop/emit.py → loop/runtime.py`, and `loop/runtime.py` imports `loop/contract.py` at module level.** Both new edges (`emit → contract._strict_evidence_failure` and `contract → runtime.bound_artifact_digests`) MUST be function-local, exactly as the shipped doctor-events wiring does it. Run `python3 -c "import loop.emit, loop.contract, loop.runtime, loop.runner"` in a fresh interpreter after Task 5 Step 4 and before the commit — a cycle here surfaces as an `ImportError` only on the *first* import order a user happens to hit, so a passing test suite is not proof. +9. Decision 14 makes `emit.terminate` read `.loop/events.db` under the strict mode. Confirm the read is opened through the existing **sidecar-free** read-only helper — v0.10.0 fixed `events.db-wal`/`-shm` residue from `mode=ro` readers (issue closed by PR #80/#82), and a new reader that reintroduces sidecars would break tree-byte-equality smokes and re-open a closed bug. Reuse `runtime`'s helper; do not open a connection in `emit`. +10. The three decision-14 tests in Task 5 depend on `dispatched_workspace` having a task whose id matches the hand-written record's `produced_by.task_id`. If the fixture's TASKS.json uses a different id than `T-1`, the goalpost sub-check silently does not fire and the test passes for the wrong reason. Assert the task id explicitly inside `_handwritten_record` rather than assuming it.