diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,13 @@
+# 0.1.0.0 — 2026-10-24
+
+## New Features
+
+Initial framework-neutral mutation campaigns with checked edits, immutable
+source snapshots, bounded POSIX processes, precise classification, strict JSON
+manifests and portable reports. Optional Tasty/Hspec reporters provide named
+assertion evidence; required QuickCheck replay and fresh native probes retain
+separate original and reproduction observations.
+
+Includes scoped resources, bounded workers, conservative baseline sharing and
+verified Haskell build reuse, four representative migration families, strict
+report recovery, standalone archive validation and BSD-3-Clause licensing.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,27 @@
+BSD 3-Clause License
+
+Copyright (c) 2026, Nadeem Bitar
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors
+   may be used to endorse or promote products derived from this software
+   without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,82 @@
+# Heni
+
+Heni runs deliberate mutations and verifies that the intended tests catch them.
+Describe a checked edit, build/test commands and a precise failure witness. Heni
+first proves the unchanged baseline, then runs the mutation in a disposable copy
+and retains a portable report.
+
+Version 0.1.0.0 is unreleased. Local release validation and its limits are recorded
+in [release readiness](https://github.com/shinzui/heni/blob/master/docs/validation/release-readiness.md). Ordinary Cabal and
+released dependencies are sufficient; repository documentation tools and Nix are
+optional. Supported compiler/platform combinations are listed in
+[compatibility](docs/design/compatibility.md).
+
+From this checkout:
+
+```sh
+cabal build all -j1
+cabal run heni-cli:exe:heni -- check examples/minimal/heni.json
+cabal run heni-cli:exe:heni -- run examples/minimal/heni.json --case guard-bypass --output .heni-runs/first-caught
+cabal run heni-cli:exe:heni -- report .heni-runs/first-caught/report.json
+```
+
+Expect `caught` and exit 0. Selecting `--case survivor` with a fresh output path
+produces `survived` and exit 1. Configuration or infrastructure failures exit 2.
+The [quickstart](docs/quickstart.md) begins with the unchanged passing fixture and
+shows both cases. Commands use literal argv; no shell mutation runner is needed.
+
+| Package | Purpose |
+| --- | --- |
+| `heni` | Framework-neutral `Test.Heni` library, strict manifests, runner and reports. |
+| `heni-cli` | The `heni` executable using the same library classifier. |
+| `heni-tasty` | Optional complete named-test evidence for Tasty. |
+| `heni-hspec` | Optional Hspec evidence and native QuickCheck replay. |
+
+A Haskell consumer constructs `CaseDefinition`, calls `mkCampaign`, then
+`runCampaign (defaultRunOptions source output) campaign`. The library returns
+`RunResult`; the caller owns application rendering/exits. See the
+[short library example](examples/library/Definitions.hs) and the
+[independent consumer](examples/standalone/Main.hs). Use generic exact diagnostics
+when test counts are unknown, or install a reporter for selected/executed IDs and
+named assertions. Tasty/Hspec reporters are optional. Released Aeson brings a transitive
+QuickCheck dependency; the core exposes no framework test types.
+
+Heni supports checked UTF-8 replacement, create/delete, optional Git textual
+patches and trusted Haskell transformations. Baselines, controls, intentional
+compiler rejection, replay and fresh crash probes have separate evidence
+contracts. Timeouts, unrelated errors, stale edits and missing test evidence
+cannot count as successful semantic detection. Source snapshots include selected
+dirty and untracked regular files; the original checkout remains unchanged.
+
+Scoped resources and bounded workers support consumer-owned fixtures. Baseline
+sharing is opt-in and requires complete compatible identities. Incremental build
+reuse requires a trusted content-observation verifier; there is no generic
+portable switch that makes every build cache safe.
+
+The POSIX supervisor bounds commands, output capture and group cleanup. Detached
+descendants and runner SIGKILL need stronger consumer containment. Windows, AST
+mutation generation, distributed execution, persistent verdict caches and
+automatic database rollback are outside this release. Consumer hooks are trusted
+code, not a security sandbox.
+
+Read the [CLI reference](docs/cli.md), [troubleshooting](docs/troubleshooting.md),
+[adapter contracts](docs/design/adapters.md), [resource/reuse guide](docs/design/scheduling.md),
+and [migration guide](docs/migration-guide.md). Versioned [schemas](schemas/) cover
+manifests, reports and test evidence. The [migration evidence](docs/validation/migration-results.md)
+separates measured fixture results from historical research and future consumer work.
+
+For contributors:
+
+```sh
+cabal test all -j1 --test-show-details=direct
+sh scripts/test-migrations.sh
+sh scripts/check-sdist.sh
+```
+
+The archive check additionally needs development-only Python/jsonschema and Git
+for the explicit optional patch gate. Core tests run with a tool path excluding
+Git, Nix, Mori, OKF and Python. [Release preparation](docs/releasing.md) explains
+local archives and the separate later Hackage candidate/publication review.
+
+Heni derives its name from Japanese 変異 (*hen’i*), mutation or variation.
+Licensed BSD-3-Clause; copyright 2026 Nadeem Bitar.
diff --git a/bench/campaign/Main.hs b/bench/campaign/Main.hs
new file mode 100644
--- /dev/null
+++ b/bench/campaign/Main.hs
@@ -0,0 +1,56 @@
+module Main (main) where
+
+import Control.Exception
+import Data.Aeson
+import Data.ByteString.Lazy.Char8 qualified as LBS
+import Data.IORef
+import Data.Version (showVersion)
+import Definitions (exampleCampaign)
+import GHC.Clock (getMonotonicTimeNSec)
+import Paths_heni (getDataFileName)
+import System.Directory qualified as Dir
+import System.Environment (getArgs)
+import System.FilePath (takeDirectory, (</>))
+import System.Info qualified as Platform
+import System.Posix.Temp (mkdtemp)
+import Test.Heni
+import Text.Read (readMaybe)
+
+modify_ :: IORef a -> (a -> a) -> IO ()
+modify_ ref f = atomicModifyIORef' ref (\x -> (f x, ()))
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let parse [] = (1, "fresh")
+      parse ["--workers", n, "--mode", mode] | Just count <- readMaybe n, count > 0, count <= 256, mode `elem` ["fresh", "reuse"] = (count, mode)
+      parse _ = error "use --workers N --mode fresh|reuse"
+      (jobs, mode) = parse args
+  source <- getDataFileName "examples/minimal/heni-minimal-fixture.cabal" >>= pure . takeDirectory
+  bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "heni-cabal-bench-")) Dir.removePathForcibly $ \root -> do
+    totals <- newIORef (0 :: Int, 0 :: Double, 0 :: Int, 0 :: Int)
+    let observe (CaseStarted _) = modify_ totals (\(baselines, buildTime, active, peak) -> (baselines, buildTime, active + 1, max peak (active + 1)))
+        observe (CaseFinished _) = modify_ totals (\(b, t, a, p) -> (b, t, a - 1, p))
+        observe (StageFinished _ role StageEvidence {stage = Build, durationSeconds}) = modify_ totals (\(b, t, a, p) -> (b + if role == "baseline" then 1 else 0, t + durationSeconds, a, p))
+        observe _ = pure ()
+        options = (defaultRunOptions source (root </> "run")) {workers = jobs, progress = observe, reusePolicy = if mode == "reuse" then ReuseBaselines "current GHC/Cabal and released fixture dependencies; no external state" Nothing else Fresh}
+    start <- getMonotonicTimeNSec
+    result <- runCampaign options exampleCampaign
+    end <- getMonotonicTimeNSec
+    (baselines, buildTime, _, peak) <- readIORef totals
+    LBS.putStrLn
+      ( encode
+          ( object
+              [ "workload" .= String "three real Cabal cases: caught, survivor, compiler error",
+                "mode" .= mode,
+                "workers" .= jobs,
+                "perBuildJobs" .= (1 :: Int),
+                "peakCaseConcurrency" .= peak,
+                "baselineBuildInvocations" .= baselines,
+                "elapsedSeconds" .= (fromIntegral (end - start) / 1e9 :: Double),
+                "sumBuildStageSeconds" .= buildTime,
+                "platform" .= object ["os" .= Platform.os, "arch" .= Platform.arch, "compiler" .= Platform.compilerName, "compilerVersion" .= showVersion Platform.compilerVersion],
+                "result" .= runResultJSON result
+              ]
+          )
+      )
diff --git a/docs/adr/1-framework-neutral-core.md b/docs/adr/1-framework-neutral-core.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/1-framework-neutral-core.md
@@ -0,0 +1,21 @@
+# Framework-neutral public library
+
+Status: accepted. Date: 2026-10-24.
+
+Heni owns definitions, checked execution, evidence classification and reports.
+The CLI wraps those library APIs. Optional test frameworks supply evidence in
+separate packages and never export their types through Test.Heni. Consumers own
+service fixtures and effects, discharged into plain IO hooks. Portable case
+data and executable hooks remain separate; arbitrary functions are not JSON.
+
+Use strict concrete records, GHC2024 and a small internal prelude. Constructor
+matching and explicit combinators keep generic-lens's transitive label orphan
+out of the public import closure. This is a scoped adaptation of
+mori://shinzui/haskell-jitsurei/docs/core-record-patterns. A downstream compile
+fixture defines its own IsLabel instance while importing Test.Heni.
+
+This boundary keeps ordinary Cabal consumers independent of application effect
+stacks, Mori, OKF, Nix and test-framework dependencies. The cost is separate
+optional packages and explicit codecs. It follows the boundary lessons of
+mori://shinzui/mori-app/okf/adrs/concepts/ADR-1 and
+mori://shinzui/keiro/okf/adrs/concepts/ADR-28 without adopting their frameworks.
diff --git a/docs/adr/10-source-archive-release-boundary.md b/docs/adr/10-source-archive-release-boundary.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/10-source-archive-release-boundary.md
@@ -0,0 +1,33 @@
+# Source archives define the release validation boundary
+
+Status: accepted. Date: 2026-10-24.
+
+Validate the four Heni source distributions outside the checkout, with released
+non-Heni dependencies. A core-only project must work independently; inspect the
+reachable component graph rather than every node in Cabal's elaborated plan.
+Aeson brings transitive QuickCheck, while Tasty/Hspec and Heni reporters remain
+optional. Keep framework test APIs outside the core.
+
+Run core tests with a restricted tool path, then separately require positive Git
+patch integration and optional framework reporters. Archive tests also prove
+complete Haddock, schema agreement, the standalone library/CLI exit contract and
+representative migrations. Preserve failed extractions and bounded validation
+logs. Do not let absence of a capability silently satisfy its positive gate.
+
+Advertise only successfully tested OS/compiler combinations and dependency sets.
+GHC 9.12.4 is both ends of the initial admitted compiler range; widening base
+bounds is a compatibility task. Keep -Werror in owned CI policy. Version public
+Haskell APIs under PVP independently from wire schemas.
+
+The maintainer selected BSD-3-Clause and supplied metadata by reference to their
+other packages. Name lookups are neither reservations nor ownership checks.
+Source/docs hashes and readiness evidence are repository-only records excluded
+from source distributions to avoid self-reference. Candidate upload, rendered
+Hackage review, account authorization and publication remain separate release
+actions after local readiness; local artifacts never imply an upload occurred.
+
+Haddock instance-origin links can target hidden definition modules. Release docs
+selectively generate the two implementation definition pages with `--show`,
+marking them `not-home` and explicitly outside the supported import API. Public
+facades remain documentation homes and installed Cabal module exports do not
+change. Check generated module and source links as well as coverage percentages.
diff --git a/docs/adr/2-evidence-owned-classification.md b/docs/adr/2-evidence-owned-classification.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/2-evidence-owned-classification.md
@@ -0,0 +1,25 @@
+# Evidence owns the verdict
+
+Status: accepted. Date: 2026-10-24.
+
+Every execution path calls the same pure classifyCase and summarizeRun.
+Expectations cannot override process outcomes, baseline success, changed-byte
+requirements, setup, timeout or cleanup validity. Evidence is distinct from a
+verdict and retains secondary failures. Generic diagnostics disclose their
+weaker attribution instead of inventing framework test counts.
+
+Named detection requires the exact declared assertion failure set and actual
+execution. Required replay carries the same token, generator and failure
+identity. Explicit crash probes and intentional build rejections are distinct
+from semantic kills. Timeouts remain unresolved. Unchanged controls are the
+only permitted no-op cases and invalidate run confidence when they fail.
+
+A validated selection ledger owns exactly-once accounting; missing results are
+NotRun, and duplicate/foreign evidence is rejected. Infrastructure takes
+precedence over survivors in run status. The semantic fraction excludes
+controls, crashes, build rejection and unresolved outcomes, and is marked
+incomplete when any run obligation remains. This conservatism prevents ordinary
+command errors from turning a broken mutation campaign green.
+
+The executable truth table and ledger invariants live in test/unit/Main.hs;
+the detailed precedence contract is in docs/design/classification.md.
diff --git a/docs/adr/3-immutable-snapshots-and-staged-edits.md b/docs/adr/3-immutable-snapshots-and-staged-edits.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/3-immutable-snapshots-and-staged-edits.md
@@ -0,0 +1,23 @@
+# Immutable snapshots and staged edits
+
+Status: accepted. Date: 2026-10-24.
+
+The selected working tree is the source of truth. Capture names, bytes,
+executable bits and policy into an immutable content-identified value, then
+materialize exclusive disposable workspaces. Never back up and restore over
+the active checkout. Snapshot identity covers policy as well as bytes, and
+double capture rejects observed concurrent changes without claiming an atomic
+filesystem snapshot.
+
+Apply an entire edit sequence before publishing a candidate to the runner.
+Recompute the actual final inventory; hook declarations are not proof of byte
+changes. Reject no-op mutations, changed controls, symlinks, special files and
+edits to runner-owned metadata. Keep stale mutation errors distinct from IO or
+cleanup infrastructure errors. Optional patch subprocesses use the shared
+supervisor; Git is not a core runtime requirement for other edits.
+
+The first implementation stores immutable bytes in memory and rejects source
+symlinks. These deliberate limits simplify ownership and make active-checkout
+preservation independently verifiable. A capability API constrains ordinary
+hooks but is not a security sandbox for arbitrary IO. Reset failure invalidates
+worker reuse, and fresh workspaces remain the default.
diff --git a/docs/adr/4-posix-process-ownership.md b/docs/adr/4-posix-process-ownership.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/4-posix-process-ownership.md
@@ -0,0 +1,34 @@
+# POSIX process groups own command lifetimes
+
+Status: accepted. Date: 2026-10-24.
+
+All external commands, including Git patch checks, setup, teardown and replay,
+use the same supervisor. It owns a POSIX group, drains both pipes, enforces a
+monotonic budget, terminates remaining group members even after the leader
+exits, and bounds escalation, direct-child reaping and reader joins. Structured
+exit/signal/timeout evidence takes precedence over terminal text. Cancellation
+retains process evidence while preserving the original exception for the caller.
+
+Bounded raw logs are presentation artifacts. Exact diagnostic witnesses are
+scanned through all bytes and retained separately; invalid UTF-8 display
+decoding must never create detection evidence. Observation failures and cleanup
+failures remain infrastructure information, not successful detections.
+
+Linux and macOS are the first target platforms; Windows is deliberately
+unsupported. Ordinary POSIX groups do not contain detached descendants or
+recover from runner SIGKILL. Document and test this boundary instead of
+promising an OS sandbox. Consumers requiring stronger containment supply one.
+
+Host executable availability is checked before a campaign. Explicit `./...`
+programs are workspace-local build products: validate their contained relative
+syntax at preflight and resolve them in the stage's checked cwd after build.
+Reject runtime symlink escapes. Applicability checking can defer their existence
+without running a build; this must not be presented as proof they already exist.
+
+Group signalling errors are provisional until bounded cleanup ends. macOS can
+return EPERM while an exiting group's members are being reaped, then ESRCH.
+Only a subsequent definite ESRCH clears those errors; persistent permission or
+observation failure remains infrastructure failure. Stop signalling after
+definite absence, and reap the direct child independently. Repeated short
+timeouts exercise this lifecycle race. Apple's [group signalling source](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_sig.c)
+filters zombie group members before computing whether any target was found.
diff --git a/docs/adr/5-campaign-ledger-and-report-trust.md b/docs/adr/5-campaign-ledger-and-report-trust.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/5-campaign-ledger-and-report-trust.md
@@ -0,0 +1,27 @@
+# One campaign ledger and portable evidence
+
+Status: accepted. Date: 2026-10-24.
+
+The library owns the complete baseline/edit/build/test/classify lifecycle and
+the selected-ID ledger. CLI and optional framework paths consume it. Baseline
+and candidate configuration must match; failed baselines never produce mutant
+kills. Only a successful build permits semantic tests. Checked hooks may enrich
+evidence but cannot override actual outcome, timeout, diagnostic, source-change
+or cleanup validity. Every external stage, including Git, uses the supervisor.
+
+Write a sequenced ledger snapshot before work and after meaningful observations;
+finalize summaries by atomic rename. Include baseline/patch evidence separately
+from candidate classification inputs, retain secondary cleanup/report errors,
+and fill all queued IDs on interruption. Recovery is read-only and explicitly
+untrustworthy; a journal is evidence, not permission to resume commands.
+
+Use explicit versioned codecs and relative artifact paths. Reject duplicate JSON
+keys before map conversion, and reject malformed ledgers and escaping/missing
+artifacts when reading. Preserve exact witnesses independently of bounded logs.
+Reports redact environment values and explicitly sensitive argv indexes without
+claiming complete secret-bearing reproducibility. Consumers own what their
+commands emit. Atomic rename does not promise durability when storage refuses IO.
+
+The cost of full ledger snapshots is larger journals. The initial serial runner
+chooses inspectable recovery and fresh workspace isolation before optimizing
+scheduling or reuse. Later scheduling must preserve these accounting invariants.
diff --git a/docs/adr/6-explicit-portable-wire-boundaries.md b/docs/adr/6-explicit-portable-wire-boundaries.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/6-explicit-portable-wire-boundaries.md
@@ -0,0 +1,24 @@
+# Explicit portable input and thin CLI
+
+Status: accepted. Date: 2026-10-24.
+
+Versioned JSON manifests lower into the same validated Campaign as Haskell
+definitions. The CLI owns arguments, rendering and exit mapping, while the
+library owns applicability, execution, classification and report reading.
+Reject unsupported versions, unknown/duplicate keys, invalid references and
+unsupported hooks before execution. Defaults apply only to missing nonnullable
+fields, not explicit nulls. JSON Schema describes shape; the decoder additionally
+checks semantic relationships that schema cannot express.
+
+Anchor source paths to the manifest file. Keep explicit caller-relative output
+paths deliberate and document their different role. Repeat filters by union
+within each kind and intersection between case/tag kinds, retaining author
+order. Empty selection is an error. Check/list/report never imply a baseline
+or execute mutation tests. Recovery is read-only. New adapter/resource syntax
+must wait for real execution support rather than silently doing nothing.
+
+Machine output remains clean JSON; observation and errors use stderr. Human
+color follows terminal detection by default. Package version identity comes
+from Cabal archives without Git. These boundaries follow the small applicable
+parts of mori://shinzui/haskell-jitsurei/docs/cli-overview and preserve the
+library-owned behavior established in docs/adr/1-framework-neutral-core.md.
diff --git a/docs/adr/7-attempt-bound-test-evidence.md b/docs/adr/7-attempt-bound-test-evidence.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/7-attempt-bound-test-evidence.md
@@ -0,0 +1,27 @@
+# Attempt-bound test evidence and replay
+
+Status: accepted. Date: 2026-10-24.
+
+Framework adapters describe observations through a dedicated versioned JSON
+Lines artifact. Only the core classifier chooses verdicts. Bind every record
+to a fresh run/case/stage/role/attempt identity and cross-check completion,
+counts, test transitions and failure membership. Reject stale, malformed and
+partial evidence without falling back to diagnostic matching. A framework
+exception after completion appends explicit invalidation while retaining facts.
+
+Stable escaped path IDs distinguish nested or parameterized display names.
+Unknown unfiltered discovery remains unknown. Report selected/executed IDs
+exactly and require intended assertions to execute in both baseline and
+candidate. Optional framework packages own version-specific public APIs;
+framework types never enter the core public import closure.
+
+Required replay is a supervised second attempt in the same edited workspace.
+Preserve both observations and exact framework token/configuration. A replay
+timeout is a failed reproduction, with the underlying Timeout retained, not
+a semantic detection. Support one explicit QuickCheck replay property per
+selected attempt initially; use typed builders for other native protocols.
+
+A declared crash needs a fresh attempt-bound reachability probe and actual
+native signal, with no timeout/cancellation. Probes do not prove arbitrary
+root cause. These artifacts assume cooperating test programs and do not
+provide cryptographic attestation against them.
diff --git a/docs/adr/8-scoped-resources-and-conservative-reuse.md b/docs/adr/8-scoped-resources-and-conservative-reuse.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/8-scoped-resources-and-conservative-reuse.md
@@ -0,0 +1,37 @@
+# Scoped resources and conservative campaign reuse
+
+Status: accepted. Date: 2026-10-24.
+
+Use callback-scoped providers for consumer-owned values. Campaign, worker and
+case scopes make lifetime explicit; reset external state before baseline and
+candidate, acquire declared providers in order and release in reverse order.
+Namespace environment additions and serialize only nonsecret identity/version/
+epoch metadata. Default resource capacity to one. Acquire multi-resource claims
+atomically within a finite campaign, preserving deterministic result order.
+Separate Heni processes require external coordination.
+
+Case-local evidence belongs to one worker; shared result updates are atomic and
+journal writes/progress callbacks are serialized. Cancel and join every worker
+before finalizing. Preserve original cancellation and all cleanup errors.
+Fresh private workspaces remain the correctness reference.
+
+Reuse only passing in-campaign baselines whose complete input contracts match.
+Compare sensitive command/environment values in memory, without portable weak
+hashes. Require nonsecret toolchain identity, exact structured producer identity
+and known resource epochs; disable reuse for unidentified custom hooks. Record
+baseline-owner provenance, never invent a fresh attempt artifact for a reused
+baseline. No cross-run verdict cache is provided.
+
+Incremental builds require an explicit Haskell provider that verifies content-key
+observation before tests. Restore and inventory source independently, keep all
+writable artifacts worker-private, and retire retained state after suspicious
+source, reset, build observation or cleanup. Generic commands cannot prove a
+universal invalidation rule, so no portable incremental-build flag is offered.
+Same-size/coarse-mtime adversarial fixtures and fresh-mode parity are required
+for a provider's claimed compatibility. Timings remain observations, while
+isolation and invocation counts are correctness gates.
+
+Emit the final case progress event only after case-scoped resources have been
+disposed. Teardown can revise a semantic detection to infrastructure failure;
+observers must receive that same final case verdict. Worker/campaign teardown
+remains run-level confidence evidence because those scopes span multiple cases.
diff --git a/docs/adr/9-migration-proof-and-domain-ownership.md b/docs/adr/9-migration-proof-and-domain-ownership.md
new file mode 100644
--- /dev/null
+++ b/docs/adr/9-migration-proof-and-domain-ownership.md
@@ -0,0 +1,34 @@
+# Migration proof and consumer-owned fault meaning
+
+Status: accepted. Date: 2026-10-24.
+
+Heni owns disposable source lifecycles, supervision, baseline validity,
+attribution and reports. Consumers retain fault meaning, selected assertions,
+generator/checker code and external resource reset semantics. Representative
+fixtures are independently authored and redistributable; do not copy or silently
+migrate the surveyed repositories as part of validating this boundary.
+
+For generated code, mutate generator input before generation when possible.
+An output mutation requires a consumer workflow that preserves and verifies the
+artifact through compilation. Do not hide a regenerating build behind a claim
+that the original edit reached the test. Workspace-built ./program executables
+resolve after build inside the checked stage cwd.
+
+A portable manifest is sufficient for common edits and exact diagnostics.
+Haskell-only transformations and verifiers remain explicit trusted callbacks;
+no second general-purpose runner belongs in migration examples. Strong named
+protocols retain their exact identities instead of falling back to terminal
+scraping. Database-state fault injection and live-service acceptance remain
+consumer work beyond filesystem isolation.
+
+The cross-package migration trial is an explicit integration gate with a
+separately built CLI executable. Keep it out of default core test dependencies
+to preserve independent core archives and avoid Cabal build-tool cycles. Release
+CI must run the integration gate and optional reporter tests.
+
+Measure fixture lifecycle scaffolding separately from mutation definitions,
+domain/build helpers, automated interface actions and compiler time. Agent-run
+observations do not establish human usability or production-wide savings.
+Historical survey evidence, refreshed source identities and newly executed
+trials are separate claims. No migration or publication is implied by local
+parity evidence.
diff --git a/docs/cli.md b/docs/cli.md
new file mode 100644
--- /dev/null
+++ b/docs/cli.md
@@ -0,0 +1,75 @@
+# CLI quickstart
+
+Build with ordinary Cabal and run from this checkout:
+
+```sh
+cabal build heni-cli:exe:heni
+cabal run heni-cli:exe:heni -- check examples/minimal/heni.json
+cabal run heni-cli:exe:heni -- list examples/minimal/heni.json
+cabal run heni-cli:exe:heni -- run examples/minimal/heni.json --case guard-bypass --output .heni-runs/cli-example
+cabal run heni-cli:exe:heni -- report .heni-runs/cli-example/report.json
+```
+
+Use a new output directory each time. The selected guard mutation is caught;
+the example's survivor and unrelated compile-error cases deliberately fail the
+contract. `check` checks structure, command availability and edit applicability
+in disposable copies. It does not run builds, tests, discovery or baselines.
+`list` reads definitions without launching commands. `report` only reads stored
+evidence. `report DIRECTORY --recover` reconstructs an explicitly incomplete
+journal snapshot without executing or resuming anything.
+
+Manifest source roots are relative to the manifest's directory, defaulting to
+`.`; mutation targets and command working directories are relative to the
+snapshot root. An explicit CLI `--output` path is relative to the caller's
+directory. Without it, output is SOURCE/mutation-results, a default excluded
+name. A custom nested output location must be excluded from source capture.
+
+Repeat `--case` and `--tag` freely. Values within each kind form a union; the
+case and tag sets are intersected. Selection retains author order. Unknown
+explicit IDs and empty intersections fail before execution. The current
+default is serial fresh-copy execution. `--jobs N` selects 1–256 workers and
+overrides manifest `execution.workers`; resource capacities still apply.
+
+`run` exits 0 only when the library reports a trustworthy met contract, 1 for
+contract failure, and 2 for configuration or infrastructure failure. SIGINT
+exits 130 after partial evidence finalization. `check` exits 2 when applicability
+or configuration fails. `list` and successful `report` rendering exit 0 even
+when the rendered run failed; use `run` for the CI gate.
+
+All commands support `--json`, with JSON on stdout and progress/errors on stderr.
+Run JSON contains schemaVersion, reportPath, selectionRule and the library result.
+Report JSON is the full stored report envelope. Human output includes verdict
+counts, unresolved cases, witnesses and separate run failures. `--color auto`
+uses ANSI only on a terminal; `always` and `never` override human output, while
+machine JSON remains uncolored. `--version` comes from the Cabal package and
+works without Git. Shell wrappers can use the installed `heni` executable;
+Python callers should invoke argv and inspect its exit/JSON instead of rebuilding
+mutation orchestration.
+
+Version-one manifests reject unknown keys at every level, unsupported versions,
+duplicate object keys, unsafe targets, bad budgets, duplicate IDs and unknown
+references. Missing timeoutSeconds defaults to 30; outputLimitBytes defaults to
+1048576 per stream; arguments, sensitiveArguments and tags default to empty;
+replacement occurrences defaults to exactly one. Explicit null is accepted only
+for nullable fields such as selector, test and workingDirectory. Effective
+microsecond budgets and output limits appear in reports. Command environment
+values are redacted; sensitiveArguments lists zero-based argv indexes to redact.
+
+The [manifest schema](../schemas/manifest-v1.schema.json) defines wire shape;
+the decoder also checks semantic references and edit/expectation constraints.
+Generic diagnostics, intentional build rejection, unchanged controls, named-test
+protocol adapters, required replay and attempt-bound crash probes are supported.
+Custom Haskell functions and build-tool observation verifiers cannot be serialized.
+Scoped resource commands and baseline reuse use the shared runner. Unsupported features never silently
+fall back to ordinary diagnostic matching.
+
+Named-test adapters, required replay and explicit crash probes are described in
+[adapter contracts](design/adapters.md), with runnable manifests in examples/adapters.
+
+See [scheduling and resource contracts](design/scheduling.md) for portable resource
+claims, baseline reuse identities, external reset obligations and build-job budgets.
+
+Host tools are checked before execution. An explicit `./path/to/program` is a
+workspace-local build product; `check` validates its path but defers existence
+until the build/test lifecycle. It never builds the program during applicability
+checking. Traversal and runtime symlink escapes are rejected.
diff --git a/docs/design/adapters.md b/docs/design/adapters.md
new file mode 100644
--- /dev/null
+++ b/docs/design/adapters.md
@@ -0,0 +1,56 @@
+# Named assertions, replay and native probes
+
+Install `heni-tasty` or `heni-hspec` in the consumer test executable. Replace
+Tasty's `defaultMain` with `Test.Heni.Tasty.heniMain`, or Hspec's `hspec` with
+`Test.Heni.Hspec.heniHspec`. Ordinary direct execution keeps the native runner.
+Inside an attempt the reporter writes a dedicated versioned JSON Lines file;
+stdout, colors and display formatting are not the protocol.
+
+Set `adapter: "heni-v1"` in a manifest case and use
+`expect: {"kind":"named-tests","failures":["suite/intended"],"replayRequired":false}`.
+Stable IDs join nested groups/example names with `/`, escaping literal `~`
+and `/` as `~0` and `~1`. Duplicate IDs are rejected. Tasty selectors use native
+pattern syntax; Hspec selectors match substrings of the stable ID. Both
+intersect with native command filters. Tasty and Hspec report exact selected
+and executed IDs; unfiltered discovery is currently unknown, not invented.
+Hspec history reruns and dry-run are unsupported inside an attempt. Fail-fast partial
+execution is infrastructure evidence, never a completed successful detection.
+
+The core requires an unchanged baseline with the intended tests executed and
+passed. A candidate must fail precisely the allowed named assertions. Skips,
+setup errors, other failures, count mismatches and incomplete records cannot
+count as caught. Every record binds run, case, stage, role and fresh attempt.
+Producer version must agree with baseline. The protocol and reporters are an
+evidence contract for cooperating programs, not an attestation against a
+malicious executable.
+
+For QuickCheck with Hspec, wrap one explicitly selected property example:
+
+```haskell
+it "property" $ heniProperty "suite/property"
+  (QC.stdArgs {QC.maxSuccess = 1, QC.maxSize = 30}) property
+```
+
+Use `replayRequired: true`. The helper records QuickCheck's native seed/size,
+version, generator arguments and counterexample. Heni supervises a separate
+Replay stage on the same edited workspace. Missing/invalid tokens, changed
+configuration, another failure or replay timeout reject reproduction; original
+and replay facts remain available. One replay property per selected attempt
+is supported. Other frameworks can supply `CaseHooks.replayBuilder` and emit
+the same protocol. Counterexamples may contain sensitive application data;
+the consumer must redact them before recording when needed.
+
+For a declared native crash use
+`expect: {"kind":"crash","signal":6,"probe":"operation-reached"}`
+and call `Test.Heni.Crash.recordCrashProbe "operation-reached"` immediately
+before the operation. Signal numbers are platform-specific; Haskell users can
+use `System.Posix.Signals`. This channel requires no test adapter. Fresh
+identity, the declared signal and absence of timeout/cancellation are all
+required. A probe proves reachability, not the cause of an arbitrary crash.
+Numeric exits and ordinary stdout markers do not prove a native signal.
+
+`adapters/heni-tasty/test/fixture/Main.hs` and
+`adapters/heni-hspec/test/fixture/Main.hs` are complete runnable examples.
+The core standalone producer tests keep both frameworks outside its dependency
+closure. `schemas/test-evidence-v1.schema.json` documents record shape; the
+reader additionally verifies sequence, membership, transitions and totals.
diff --git a/docs/design/api-contract.md b/docs/design/api-contract.md
new file mode 100644
--- /dev/null
+++ b/docs/design/api-contract.md
@@ -0,0 +1,60 @@
+# Public API and ownership
+
+The root `heni` library exposes `Test.Heni` and focused Case, Command,
+Expectation and Result modules. It has no test-framework dependency, mandatory
+terminal output, process exit, or repository-tool requirement. IDs, relative
+targets, budgets and selected ledgers have hidden constructors. Call
+`validateCase` and `validateCommand` before execution; their `ConfigError`
+names the rejected field. Case data and evidence use concrete strict records.
+
+The minimal construction sequence is visible in `test/api/Consumer.hs`: create
+IDs and a relative target, construct a `CaseDefinition`, validate it, then
+classify evidence. That fixture uses synthetic evidence only. It covers a
+replacement, textual patch catalogue, generated-code hook, and external
+diagnostic. The model also expresses named tests, replay, build rejection,
+unchanged controls and signal probes. The real campaign path is demonstrated in
+`examples/library/` and the optional adapter packages.
+
+`CaseDefinition` contains serializable data. `CaseHooks` holds trusted IO
+functions separately. `WorkspaceOps` supplies checked reads and writes; the
+workspace owner supplies checked creation/deletion. Arbitrary IO can escape
+these operations: this is an API capability, not an OS sandbox. A custom
+evidence callback returns evidence or a rejection; it cannot return a verdict.
+The execution owner must preserve actual process outcome and all validity
+gates when interpreting custom evidence.
+
+The execution API is:
+
+```haskell
+runCampaign :: RunOptions -> Campaign -> IO RunResult
+```
+
+These argument types now live in `Test.Heni.Runner`. `mkCampaign` validates
+commands, references and custom edit registrations. RunOptions includes
+source/output locations, selection, an IO progress callback, and a cleanup hook
+for each baseline/candidate workspace. Command.sensitiveArguments marks
+zero-based argv indexes for report redaction; environment values are always
+redacted from portable configuration. The workspace owner supplies scoped immutable
+snapshots and independently checked changes. The process owner supplies actual
+outcomes, bounded logs and durations as StageEvidence. The campaign owner
+validates references, baselines and final accounting, then calls the pure
+classifier and summary. The adapter owner supplies versioned selection and
+replay evidence. The report owner writes explicit versioned codecs rather than
+deriving a wire contract from Haskell constructor spelling. The CLI renders the
+same RunResult and never classifies failures itself.
+
+Later owners may extend these records before publication; update the model
+tests and this contract together. The public import closure intentionally has
+no generic-lens label orphan; `test/api/Labels.hs` compiles a consumer's own
+overloaded label instance. The internal prelude exports only foundational
+types, following the scoped adaptation in [the boundary ADR](../adr/1-framework-neutral-core.md).
+
+`CaseDefinition.evidenceMode` declares generic commands or the strong v1
+protocol. `CaseHooks.replayBuilder` can build a native framework replay command;
+strong protocol facts remain authoritative even with a custom observer. See
+[adapter contracts](adapters.md) for named identities, replay and fresh probes.
+
+`RunOptions` also accepts bounded workers, scoped resources/claims and conservative
+reuse policies. Construct from `defaultRunOptions` and update named fields; portable
+callers use `manifestRunOptions`. [Scheduling contracts](scheduling.md) explain the
+Haskell-only build observation provider and resource ownership boundaries.
diff --git a/docs/design/classification.md b/docs/design/classification.md
new file mode 100644
--- /dev/null
+++ b/docs/design/classification.md
@@ -0,0 +1,51 @@
+# Evidence classification
+
+`classifyCase` is the only verdict function. It retains the complete
+CaseEvidence, including secondary cleanup errors. It evaluates these gates in
+order: invalid definition; infrastructure, cleanup or malformed protocol;
+duplicate stage; stopped execution; missing/failed baseline; rejected or
+unchanged mutation (or changed control); runner timeout; cancellation; required
+stage and expectation. Stage order does not affect a verdict. Duplicate stages
+are invalid evidence and cannot inflate counts.
+
+A failed baseline yields `not-run` for its candidate and a run-level
+BaselineFailure with affected IDs. A failing control yields its observed
+case verdict and a ControlFailure. Neither contributes to semantic scoring.
+Infrastructure takes precedence over matching diagnostic output. Setup and
+teardown nonzero exits are infrastructure failures. Evidence marked truncated is
+untrustworthy. The supervisor may truncate raw log artifacts while retaining
+exact byte-matched witnesses separately; this does not mark evidence unavailable. Timeout
+and runner cancellation never prove a crash or assertion detection.
+
+Semantic expectations require a successful Build. A build failure is
+`unexpected-build-failure`. Named tests additionally require known positive
+selected/executed counts and execution of every intended ID without skipping.
+Absent execution is `uncovered`; missing stage evidence is infrastructure.
+Exit 1 with exactly the declared failed-ID set proves detection. Extra failed
+assertions are `wrong-failure`; exit 0 with no failed IDs survives. Malformed
+counts, failed IDs outside execution, and simultaneous executed/skipped IDs are
+protocol infrastructure failures. Unknown counts never become zero counts.
+
+A generic diagnostic requires the declared nonzero exit code and literal
+nonempty output witness. Its counts remain unknown; its evidence strength is
+explicitly GenericDiagnostic. Build-stage diagnostics and BuildRejection count
+as ExpectedBuildRejection, separately from semantic detections. A CrashProbe
+requires the declared signal and nonempty case-specific probe. It counts as
+ExplicitCrash. A normal exit cannot substitute for a signal.
+
+RequireReplay demands an identical token, generator configuration and exact
+named failure set in the original and Replay stages, plus actual replay test
+execution and exit 1. Missing or mismatched replay is `wrong-failure`.
+
+`summarizeRun` accepts a validated selected ledger. It rejects duplicate or
+foreign results, preserves selection order, and fills every missing result with
+`not-run`. Baseline and control failures are deduplicated and retained at run
+level. RunInfrastructure or an infrastructure case gives RunUntrustworthy;
+interruption next gives RunInterrupted; any unresolved case or run failure gives
+RunFailed. Only all-caught, baseline/control-valid runs pass. CLI exit mapping
+belongs to its presentation owner.
+
+The score numerator is caught semantic cases and its denominator adds survived
+semantic cases. Other detection kinds never enter it. Zero denominator yields
+Nothing. An unresolved case or run failure marks the ratio incomplete, even if
+the visible fraction is one. Scores cannot make the CI contract pass.
diff --git a/docs/design/compatibility.md b/docs/design/compatibility.md
new file mode 100644
--- /dev/null
+++ b/docs/design/compatibility.md
@@ -0,0 +1,112 @@
+# Compatibility evidence
+
+Initial local validation uses GHC 9.12.4 and cabal-install 3.16.1.0 on
+aarch64 macOS. The [official GHC release](https://www.haskell.org/ghc/blog/20260327-ghc-9.12.4-released.html)
+confirms this is a released compiler. The initial CI matrix requests Linux and
+macOS with this compiler; writing that job is not evidence it ran remotely.
+GHC2024 and the GHC 9.12 minimum follow the project standard at
+mori://shinzui/haskell-jitsurei/docs/core-standards.
+
+Dependency lookup on 2026-10-24 used `mori registry list`, `registry search`,
+`registry show --full` and `registry docs`. Source exports were inspected in
+mori://haskell/text and mori://haskell/containers (Data.Text and Data.Set;
+file-level artifact URIs pending). Their registry records have no curated
+guides. Authoritative Hackage preferred-version JSON reports text 2.1.4 and
+containers 0.8.1 as the latest normal releases. Upstream Git tags confirm
+text `2.1.4` at `423fd981e576bd17a8b5fa48d0ad6b9a0c370e77` and containers
+`v0.8.1` dereferenced at `953428ad079d5708df696442f9bdeaccd1acfc6c`.
+
+The initial locally tested dependency set is base 4.21.2.0, text 2.1.4 and
+containers 0.7. Bounds admit base 4.21.2 only within its major release,
+text 2.1.4 within 2.1, and containers 0.7–0.8. Broader containers bounds use
+stable shared operations; they are not a claim that all admitted versions have
+been tested. EP-9 owns final bound testing and the advertised release matrix.
+No local checkout pin or unreleased dependency is required by Cabal.
+
+Workspace dependencies were checked on 2026-10-24. Hackage reports directory
+1.3.11.0 (upstream `v1.3.11.0`, `bf2afc2ddf9d3bcf360ba2eec08d3deb70874c3a`),
+process 1.6.30.0 (`v1.6.30.0`, `11fd247ad33208da7a914acf15d4a09d64a6a4c4`),
+crypton 2.2.5 (`crypton-v2.2.5`, `74738d1c8da69649f3e629f49a964fe942a9e79a`),
+bytestring 0.12.2.0 (`0.12.2.0`, `d984ad00644c0157bad04900434b9d36f23633c5`),
+unix 2.8.8.0 (`v2.8.8.0`) and filepath 1.5.5.0. The locally tested workspace
+set uses directory 1.3.10.1, bytestring 0.12.2.0, unix 2.8.8.0, filepath
+1.5.5.0, process 1.6.26.1 (test helper) and released crypton 2.2.5. Latest
+versions and tested versions are deliberately recorded separately.
+
+Mori located source and hashing guidance at mori://kazu-yamamoto/crypton,
+mori://haskell/bytestring and mori://haskell/process. The registered
+mori://haskell/directory source was absent (FRIC-1 in docs/friction-log), so
+System.Directory was inspected from the official v1.3.10.1 tag. Unix and
+filepath had no owning registry entries; official tagged source was used for
+POSIX file status, temporary directories, signal groups and path operations.
+
+Report support uses released aeson 2.3.2.0, verified against Hackage preferred
+versions and upstream tag `v2.3.2.0` at
+`682162c66d26a770fbfb6271c797e646bc5c4f2e`. Mori source inspection at
+mori://haskell/aeson covered explicit object codecs and the exposed JSON token
+stream; token validation rejects duplicate keys before map conversion. The
+development-only schema checker uses jsonschema 4.26.0, verified against PyPI
+and upstream `v4.26.0` at `c87a92202e986be7e3ae0db992cdc2608bbfd815`.
+The Haskell runtime does not depend on that Python tooling.
+
+Optional reporters were checked against released Tasty 1.5.5, tasty-hunit
+0.10.2, Hspec/hspec-core/hspec-api 2.11.18 and QuickCheck 2.19.0.0. Mori's
+mori://UnkindPartition/tasty source supplied public runner/fold/ingredient APIs.
+Its newest core tag is `core-1.5.4` (`0394c73e647834829e7f500c9dab0be3ff9e4f11`),
+so the actual Hackage 1.5.5 archive was also inspected rather than assuming tag
+parity. `hunit-0.10.2` is `298f69b99b7f1561c93e0eff87dde57be5bbb079`.
+Hspec and QuickCheck had no owning Mori corpus entries; authoritative source
+at Hspec `2.11.18` (`96242bf996b3eccc10cfe3406c16ffcb519b422d`) and
+QuickCheck `2.19` (`3d8fe8686024760006ca1ca656cbdd82cd71f11e`) supplied
+the stable V2 formatter and native replay APIs. Hackage preferred versions
+were checked separately. STM 2.5.3.1 was inspected at mori://haskell/stm and
+verified against `v2.5.3.1` (`48bfb8812633e9b9d4fd1cdeb4c795cefb8f8014`).
+
+The core scheduler now uses the same verified released STM 2.5.3.1 API for
+atomic queue/claim transitions; no async framework or application effect stack
+is required. Worker processes and resource commands retain the shared POSIX
+supervisor. Timing observations and explicit per-build job limits are recorded
+in [scheduling](scheduling.md), separately from compatibility claims.
+
+Cabal's current normal Hackage release was independently checked as 3.18.2.0
+(upstream `cabal-install-v3.18.2.0`, dereferenced
+`45f982c12ef78ea8ec5beb43cb8c71c8e8f237c1`). Local measurements still use
+3.16.1.0 (`cabal-install-v3.16.1.0`, dereferenced
+`8d1f5a33662be0db0654061fb53fb00e3f4417e0`). The qualified build-tool test
+cycle was observed on 3.16.1.0, not asserted to have been reproduced on 3.18.2.0.
+Mori supplied solver source at mori://haskell/cabal/packages/cabal-install-solver;
+the optional integration boundary also preserves standalone core tests regardless
+of the solver version.
+
+The first release admits GHC 9.12.4/base 4.21.2.0 only. This is both the minimum
+and newest compiler in the supported range; earlier 9.12 patch releases have
+older base versions. The official GHC site also lists 9.14.1, whose base 4.22
+is outside the admitted range. GHC 9.14 and Windows are not advertised or
+validated by these bounds. Widening compiler support requires a fresh test matrix.
+
+Release validation uses explicit direct dependency sets: the lower set listed
+above and a newest-admitted set changing containers to 0.8.1, directory to
+1.3.11.0 and process to 1.6.30.0. Other direct lower bounds already equal the
+registry's latest admitted releases as checked again on 2026-10-24. Solver
+plans retain exact transitive versions and Hackage Cabal/source hashes. Aeson
+itself depends on QuickCheck; core has no reachable Tasty, Hspec, CLI or Heni
+reporter dependency. Public core modules expose no framework test API.
+
+The archive gate validates actual extracted packages and an independent consumer;
+core tests exclude Git/Nix/Mori/OKF/Python from executable search. Optional Git
+patches and framework reporters run in separate explicit gates. CI configures
+Linux/macOS and lower/newest sets with GHC 9.12.4 and owned-package `-Werror`;
+that option is not imposed in published Cabal metadata. Remote CI execution is
+not claimed by writing the workflow. Exact locally passed combinations and
+archive identities are in the repository's release-readiness record.
+
+| Locally executed platform | Compiler | Cabal | Evidence |
+| --- | --- | --- | --- |
+| aarch64 macOS / Apple M1 Max | 9.12.4 | 3.16.1.0 | All core, CLI, Tasty and Hspec suites; representative migration trials. |
+| aarch64 Linux / Debian 12 container, kernel 6.8.0 | 9.12.4 | 3.14.1.1 | All core, CLI, Tasty and Hspec suites from extracted initial archives. |
+
+The Linux runtime used the official `haskell:9.12.4-slim-bookworm` arm64 image
+at digest `sha256:a017c85d1557f6098cfb6356e13a4b9ef16dfd89010cd1708ac4e01251c95402`,
+with container init reaping descendants. This is native Linux in a task-owned VM,
+not remote GitHub Actions evidence. Final immutable archive/bounds results are
+tracked separately in the readiness record; the table above records suite runs.
diff --git a/docs/design/process-supervision.md b/docs/design/process-supervision.md
new file mode 100644
--- /dev/null
+++ b/docs/design/process-supervision.md
@@ -0,0 +1,50 @@
+# Process ownership and evidence
+
+`runCommand` launches executable plus literal argv in the workspace (or a
+checked relative directory) with explicit environment overrides and removals.
+It inherits other environment entries for execution but never records them.
+`validateExecutable` provides preflight diagnostics; launch races still become
+LaunchFailed evidence. Heni uses a POSIX process group, masked acquisition,
+monotonic wall-clock budgets, and two concurrently draining output threads.
+Embedding executables must enable the GHC threaded runtime.
+
+The supervisor distinguishes a normal exit, negative process-package signal
+status, timeout and cancellation. Shell exit 128+signal is only an exit code.
+On every completion path it sends SIGTERM to remaining owned group members,
+waits up to the grace budget, then sends SIGKILL and polls direct-child reaping
+within another grace budget. This handles a leader exiting while a descendant
+retains the pipes, and descendants ignoring SIGTERM. Group shutdown is briefly
+uninterruptible around bounded nonblocking polling, so repeated asynchronous
+cancellation cannot skip escalation. Output-reader joins and log flushes defer
+further cancellation until their bounded cleanup completes.
+
+Each stream retains at most its configured bytes, with total bytes seen and
+truncation recorded in LogArtifact. Diagnostics are scanned as UTF-8 bytes
+through all chunks with sufficient overlap, independently per stream. Requested
+witnesses total at most 64KiB; exact matched witnesses remain in the report even
+when the raw log stopped retaining data. Only these byte-proved witnesses enter
+StageEvidence.transcript. Display decoding cannot fabricate a match from
+invalid UTF-8. A raw log artifact's truncation is distinct from stage evidence
+being unavailable. ProcessReport retains primary evidence and secondary reader,
+shutdown and log errors. Observer failure terminates the group and produces
+infrastructure information.
+
+Asynchronous cancellation raises ProcessAborted with final process evidence and
+the original exception. A campaign must finalize its selected ledger before
+rethrowing that cause. The library never exits the host program or prints
+mandatory output. Arbitrary observers/hooks are trusted IO and must cooperate
+with cancellation; hostile uninterruptible code cannot be contained by this API.
+
+Local `heni-process` execution on macOS/GHC 9.12.4 proves literal metacharacter
+arguments, exit 1 versus SIGABRT, witness-before-timeout, simultaneous 6.5MiB
+stdout/stderr floods capped to 1024 bytes each, cross-chunk witness retention,
+malformed UTF-8, leader/descendant pipe inheritance, ignored SIGTERM, observer
+failure and cancellation. Its outer watchdog is 20 seconds; shutdown assertions
+allow two seconds of CI scheduling tolerance. Linux runs are configured in CI
+and remain part of final release evidence.
+
+Descendants deliberately creating a new session/group can escape supervision.
+The detached fixture proves this boundary and explicitly kills its owned PID.
+Abrupt runner SIGKILL, power loss and uninterruptible kernel operations cannot
+execute normal cleanup. Stronger guarantees require an external sandbox.
+No process-name search or broad PID sweep is an authorized recovery method.
diff --git a/docs/design/reports.md b/docs/design/reports.md
new file mode 100644
--- /dev/null
+++ b/docs/design/reports.md
@@ -0,0 +1,68 @@
+# Campaign evidence and recovery
+
+`mkCampaign` checks cases, commands, references and Haskell edit registrations.
+`runCampaign` validates the requested nonempty selected ledger, executable
+availability and output location before execution. ConfigurationFailure is
+separate from a runtime result. The output directory is exclusively created;
+existing evidence is never merged or overwritten by a new run. Runtime IO,
+observation and cleanup failures are retained as infrastructure information.
+
+The serial path uses a fresh workspace for every baseline and candidate. A
+baseline has the same commands, environment and selector; HENI_SELECTOR carries
+the declared selector to a command or adapter. Generic commands must implement
+their own selection and do not invent framework counts. Baseline failure stops
+its candidate. Build rejection runs the unchanged build successfully first.
+Tests run only after a successful candidate build when the expectation requires
+them. Every process, including optional Git patch check/apply, uses the same
+bounded supervisor. Custom evidence hooks can supply selection/replay facts but
+cannot replace actual process outcome, unavailable-evidence flag, diagnostic
+witness, duration or log references.
+
+RunOptions provides an observation callback and an explicit workspace cleanup
+hook invoked for both baseline and candidate before built-in disposal. Cleanup
+errors retain observed candidate stages and invalidate confidence. Hooks are
+trusted cooperative IO; arbitrary uninterruptible code is outside the process
+supervisor's containment contract. Later resources compose their own lifetimes
+with this lifecycle rather than choosing another verdict path.
+
+The selected ledger is written before cases start. Each journal record contains
+a sequenced complete ledger snapshot plus effective context, after source
+capture, stage observations and case completion. Pending work is explicitly
+NotRun. Cancellation finalizes interrupted and queued IDs before rethrowing its
+original exception. The final JSON is written to a unique temporary file and
+renamed atomically within the owned report directory. This prevents partial
+JSON reads; it does not guarantee persistence through power loss or disk refusal.
+Final-write failure returns an untrustworthy in-memory result with best-effort
+existing journal evidence.
+
+Version-one codecs use explicit keys and tags. Reports include tool/schema
+versions, source identity and policy defaults, platform/compiler, definitions,
+effective command budgets/output limits, case changes, baseline/candidate
+process observations, durations, diagnostic witnesses, optional test counts and
+replay, secondary errors, log byte/truncation metadata and relative references.
+Source changes record before/after hashes, executable bits and UTF-8 text when
+available. Baseline and patch observations live under metadata.caseDetails;
+the case result's evidence contains candidate classification inputs.
+
+Environment override values are always redacted in portable configuration;
+inherited values are never recorded. Command.sensitiveArguments identifies
+zero-based argument indexes whose values are replaced with `<redacted>`.
+Redaction is explicit and means the report is not a complete secret-bearing
+reproduction recipe. Consumer command output remains retained evidence: callers
+must avoid emitting credentials in diagnostics or logs.
+
+`readReport` checks versions, unknown fields, summary accounting, every baseline,
+patch and candidate artifact's containment and existence, and symlink components.
+Original JSON token streams reject duplicate keys (including escaped equal
+spellings) before map conversion; nesting is bounded. `recoverReport` never
+executes commands or writes over a report. It reads sequenced journal snapshots,
+rejects interior corruption or semantic/schema/sequence errors, and tolerates
+only an unfinished final non-newline record whose parser reaches end-of-input.
+Recovered reports are explicitly untrustworthy even when their last snapshot
+contained completed cases. Missing work never becomes caught. Relative artifact
+references remain usable when the whole report directory moves.
+
+The JSON Schema describes wire shape. The decoder additionally checks semantic
+relationships, ledger uniqueness and recomputed summary status/score. Schema
+validation is development-only Python tooling; neither the Haskell library nor
+CLI requires Python. CI validates a real emitted fixture against Draft 2020-12.
diff --git a/docs/design/scheduling.md b/docs/design/scheduling.md
new file mode 100644
--- /dev/null
+++ b/docs/design/scheduling.md
@@ -0,0 +1,116 @@
+# Resources, workers and conservative reuse
+
+Fresh execution is the reference mode. `RunOptions.workers` defaults to one and
+accepts 1–256; CLI `--jobs N` overrides `execution.workers`. Each case gets fresh
+baseline/candidate copies unless a Haskell build provider explicitly opts in.
+Selection and final report order remain author order, even when independent
+cases complete in another order. Journal writes and progress callbacks have one
+owner; case evidence is private to its scheduled worker.
+
+Resource names use uppercase ASCII with digits/underscores. Providers choose
+campaign, worker or case scope. `managedResource` brackets a consumer value,
+returns nonsecret identity/version/epoch plus namespaced environment additions,
+and resets before both baseline and candidate. Acquisitions compose in declared
+order and release in reverse order, including partial acquisition failure.
+Primary and teardown errors are both preserved. A failed resource operation is
+infrastructure evidence. Trusted Haskell callbacks must make their own foreign
+calls interruptible; Heni cannot promise a deadline for uninterruptible code.
+
+Portable resources use named setup/reset/teardown commands, each with its own
+budget and output limit, in a private resource workspace. Example root fields:
+
+```json
+{
+  "execution": {"workers": 2},
+  "resources": [{
+    "name": "DB", "scope": "case", "capacity": 1,
+    "identity": "local-fixture", "version": "v1", "epoch": "clean",
+    "environment": {"HENI_RESOURCE_DB_PORT": "allocated-by-consumer"},
+    "setup": "prepare-db", "reset": "reset-db", "teardown": "release-db"
+  }]
+}
+```
+
+Only resources claimed by selected cases are acquired. Each case declares
+`resources: ["DB"]`; commands reference ordinary manifest
+command definitions. Environment names must start `HENI_RESOURCE_DB_` for this
+resource and values are redacted. The consumer supplies actual allocated ports
+or paths, reset/teardown semantics and meaningful epochs. An absent epoch is
+honest unknown state. A failed acquire must clean up its own partial effects;
+Heni releases earlier successfully acquired providers. Workspace deletion does
+not roll back a service.
+
+Multiple claims are acquired atomically against campaign-wide capacities, so
+opposite declaration orders cannot deadlock. Capacity defaults to one. Worker-scoped capacities also bound the worker pool,
+so acquiring idle worker fixtures cannot exceed the declared budget. Higher
+capacity is an explicit consumer assertion that simultaneous use/reset is safe.
+Finite queued work can pass blocked items to run independent cases; blocked
+items become eligible when owners release capacity. Cancellation stops the
+queue, terminates owned commands, joins workers, releases providers and
+finalizes selected IDs once. These limits coordinate one Heni process only;
+independent processes need external locks or separate fixture namespaces.
+
+`execution.baselineReuse: {"toolchain":"ghc/dependency/build-contract"}` opts
+into campaign-local baseline sharing. Haskell uses `ReuseBaselines` with the
+same nonsecret contract. The key compares snapshot bytes/policy, build/test
+commands and arguments, environment overrides and inherited values, selector,
+expectation/evidence mode, resource identity/version/epoch and toolchain
+contract. Secrets are compared in memory, never exported or weakly hashed.
+Structured suites additionally declare the exact producer string as `adapter`;
+it must agree with the observed producer. Unknown resource epochs or custom
+hooks without identities disable sharing. A consumer opting in asserts that
+external inputs are represented and tests do not depend semantically on fresh
+run/case/attempt IDs. Reports record owner-case provenance and reasons.
+
+Only passing baselines are shared. One producer serves matching waiters;
+cancellation wakes them without publishing success. Failure/cleanup errors
+invalidate the cache. A new campaign has no trusted prior verdicts. Resource
+identity/epoch/environment must agree between a case's baseline and candidate.
+This is deliberately more conservative than caching only source filenames.
+
+Incremental build artifacts require the Haskell-only `BuildReuse` provider.
+It declares a build-tool contract, retained `dist`, `dist-newstyle` or
+`.heni-build` directories, optional input preparation and a verifier that the
+build observed `HENI_BUILD_INPUT_ID`. Each worker owns its own retained tree.
+Heni restores exact staged source bytes, verifies source independently before
+and after commands, and checks the build observation before tests or accepting an expected compiler
+rejection. Command or
+environment changes discard retained artifacts. Failed reset, unexpected
+generated inputs or an invalid observation retires the tree; its next use starts
+with all artifacts removed. Generic commands have no automatic timestamp or
+invalidation guarantee, so this provider cannot be serialized as a universal
+manifest option. The verifier is trusted consumer integration, not attestation.
+
+`test/scheduler/Main.hs` proves same-size alternating edits with all input mtimes
+set to the same epoch. Fresh and retained builds agree; a deliberately stale
+build fails observation validation, and the following case starts fresh. It
+also proves baseline invocation counts, selector/command/epoch/environment
+invalidation, isolated writes, opposite-order claims and cancellation cleanup.
+
+The real Cabal workload is `heni-campaign-bench`, using three fixture cases.
+Run fresh and reuse modes repeatedly and compare baseline invocations, wall
+time, the sum of build-stage durations, and peak case concurrency separately.
+Build-stage time includes Cabal/compiler orchestration; it is not compiler CPU
+time. Timing is observational, never a correctness threshold. Each example
+uses Cabal `-j1`: budget total build jobs as mutation workers times per-build
+jobs, plus declared fixture capacity. Increasing workers does not imply a
+universal speedup. Measurements for this checkout follow.
+
+
+On 2026-10-24, Apple M1 Max/aarch64 macOS, GHC 9.12.4, Cabal 3.16.1.0:
+
+| Mode | Repeat | Workers | Baseline builds | Wall seconds | Sum build-stage seconds | Peak cases |
+| --- | --- | --- | --- | --- | --- | --- |
+| fresh | 1 | 1 | 3 | 81.118 | 52.079 | 1 |
+| fresh | 2 | 1 | 3 | 81.132 | 51.864 | 1 |
+| reuse | 1 | 2 | 1 | 37.707 | 35.152 | 2 |
+| reuse | 2 | 2 | 1 | 37.896 | 35.196 | 2 |
+
+Fresh wall-time range was 81.118–81.132s; reuse/two-worker range was
+37.707–37.896s. Each run used a new source snapshot/private workspace with the
+same warm installed dependency cache. These figures combine baseline sharing
+and parallelism, so they do not isolate either effect. Both modes preserved
+Caught/Survived/UnexpectedBuildFailure outcomes. Correctness is established by
+fresh parity and 3→1 baseline counts, not by these timing differences. The sum
+of stage times is work across processes and can exceed wall time when tasks
+overlap. This small fixture does not predict consumer-project speedups.
diff --git a/docs/design/workspaces.md b/docs/design/workspaces.md
new file mode 100644
--- /dev/null
+++ b/docs/design/workspaces.md
@@ -0,0 +1,58 @@
+# Source capture and checked edits
+
+`withSnapshot` reads selected working-tree files twice and rejects an observed
+inventory change. This includes dirty edits and untracked regular files; it
+does not read Git HEAD. Default exclusions at every depth are `.git`, `dist`,
+`dist-newstyle`, `cabal-dev`, `.direnv`, `result`, `result-*`,
+`mutation-results`, `.heni`, `.heni-*` and `.DS_Store`. Explicit relative
+inclusion subtrees and exclusion subtrees are recorded in SnapshotPolicy.
+Included names must use normalized relative paths. Symlinks and special files
+are rejected, including a symlink source root. Select a smaller source root or
+explicitly exclude unsupported inputs. Binary files are preserved unchanged.
+
+Snapshot bytes are immutable in-memory ByteStrings. SHA-256 covers sorted,
+length-framed relative names, executable bits, contents and policy metadata.
+No later operation can mutate a snapshot through a filesystem path. Capturing
+twice detects ordinary changes; it is not an atomic OS snapshot against
+adversarial concurrent writers. Large source trees currently cost memory
+proportional to their selected bytes. Streaming/disk-backed snapshots are a
+future optimization, not a different identity contract.
+
+`withWorkspace` creates a unique private POSIX temporary directory, materializes
+fresh writable files, and removes only that owned directory on exit. Copies
+are never hard links. Build directories are independent. An output directory
+must be outside the source root or excluded by the effective policy; ancestors
+and the source root itself are never valid output locations. Canonical paths
+are checked before accepting this relationship.
+
+`withEditedWorkspace` applies the complete ordered edit sequence to one private
+candidate. Its callback is invoked only after all edits and an independent
+full inventory succeed. A replacement decodes strict UTF-8, checks the exact
+non-overlapping anchor count, and preserves all other bytes. Create requires
+absence; Delete requires exact old UTF-8 contents. A mutation must change final
+bytes or executable bits. A sequence that undoes itself is invalid. An explicit
+Control must remain unchanged. FileChange records actual before/after contents
+and executable bits, including additions and removals.
+
+Custom edit hooks receive checked read/write/create/delete operations. Every
+existing path component is checked without following symlinks before an
+operation; the final inventory rejects links introduced by hooks. Mutations of
+runner-owned build/evidence paths are rejected. Trusted arbitrary Haskell IO
+or consumer subprocesses can escape these capabilities. This API is not an OS
+sandbox and does not defend against hostile concurrent link replacement.
+
+Optional Git patches use a supplied bounded PatchRunner: the process owner
+provides the production implementation. EP-2 tests use a timeout-bounded helper.
+Git is unnecessary for replacement, create/delete or custom edits. The accepted
+subset is unquoted `diff --git a/path b/path` textual patches, with matching
+names, normal headers and ordinary file creation/deletion. Binary, rename,
+copy, symlink and mode-change patches are rejected. All diff and old/new header
+paths are checked, including `/dev/null` only as the absent file header. Git
+checks before applying; failure discards the entire candidate even if a tool
+partially wrote it. Patch content lives in a separate owned temporary directory.
+
+EditError distinguishes InvalidEdit from EditInfrastructure. Stale anchors and
+unsafe patch definitions are invalid; ordinary permission and missing-tool IO
+failures are infrastructure. Cleanup exceptions remain observable. Reset only
+operates on an opaque owned Workspace and independently verifies pristine
+bytes; any failure invalidates reuse. Initial campaigns use fresh copies.
diff --git a/docs/initial-spec.md b/docs/initial-spec.md
new file mode 100644
--- /dev/null
+++ b/docs/initial-spec.md
@@ -0,0 +1,357 @@
+# Heni initial specification
+
+Status: proposed design, 2026-10-24. There is no implementation yet. Command
+examples, module boundaries, and manifest fields below describe the intended
+interface and must be exercised in a prototype before becoming a released API.
+
+## 1. Purpose and audience
+
+Heni is a Haskell library and CLI for running deliberate mutations and verifying
+that tests catch the intended faults. It helps authors answer: “Does this test
+actually protect the behavior I care about?”
+
+The intended users are Haskell package maintainers, application teams, compiler
+and DSL authors, and maintainers of validators or evidence checkers. A user
+should be able to add one useful mutation without first adopting a new test
+framework or infrastructure stack.
+
+Success means less runner code, more precise failure attribution, and mutation
+evidence that another contributor can reproduce. A mutation score alone cannot
+establish that a program is correct.
+
+## 2. Distribution and portability
+
+Publish a library package, `heni`, and a CLI package, `heni-cli`, whose executable
+is named `heni`. Keep command-line parsing and terminal presentation dependencies
+in the CLI package. Keep the core's dependency set small and its public types
+independent of any one test framework. Verify package-name availability before
+the first upload.
+
+Both packages must build from their Hackage source distributions with Cabal and
+released dependencies. Git, Nix, OKF, Mori, remote builders, and services such as
+Kafka or PostgreSQL are optional integrations or consumer-supplied test resources.
+They must not be required to use the generic library or run its core test suite.
+The patch adapter may require Git and must declare that requirement explicitly.
+
+The initial supported execution platforms are Linux and macOS. Path handling and
+process supervision must be designed so Windows support can be added; document
+platform limits explicitly until that support is tested. Select supported GHC
+versions and dependency bounds from successful CI builds and current upstream
+releases before publication. This specification does not invent those bounds.
+
+## 3. First-release scope
+
+The first release must support:
+
+1. Curated source or fixture mutations with stable IDs and a human explanation.
+2. Patch application, checked UTF-8 text replacement, and a Haskell API for
+   custom transformations in an isolated workspace.
+3. An unchanged baseline, explicit build/check and test stages, and nonempty
+   test selection where the expectation names tests.
+4. Precise expected failures, bounded subprocesses, and complete result records.
+5. A library and CLI with identical execution and classification semantics.
+6. Versioned JSON manifests and reports, readable terminal output, and CI exits.
+7. Optional property-test replay hooks and explicitly declared crash probes.
+
+Automatic AST mutation generation, a GHC plugin, distributed scheduling,
+coverage-derived test selection, and framework-specific in-process test runners
+are later extensions. Define seams for them without making them prerequisites
+for the initial execution core. Most transformations and diagnostic expectations
+remain in the consumer's repository.
+
+Small invalid-input unit tests can continue to use their existing test framework.
+Heni earns its place when a case needs mutation application, isolation, staged
+execution, attribution, or durable evidence.
+
+## 4. Core model and public library
+
+Use the `Test.Heni` namespace. The top-level module should expose a small,
+documented path from defining a case to obtaining a structured result. Proposed
+internal responsibilities are:
+
+| Responsibility | Contract |
+| --- | --- |
+| Case definition | Stable ID, description, tags, edits, stages, selection, expectation, and budgets. |
+| Workspace | An immutable source snapshot and a disposable mutable workspace. |
+| Edit | Checked transformations with explicit preconditions and touched files. |
+| Process | Structured executable/arguments, working directory, environment overrides, budgets, and termination. |
+| Expectation | A predicate over stage, exit/signal, selection evidence, and diagnostics. |
+| Runner | Baselines, controls, scheduling, reset, cancellation, and classification. |
+| Report | Versioned serializable results and evidence references. |
+
+Prefer concrete data types and functions for common cases. Custom edit and
+expectation hooks must return structured errors and evidence. Keep process,
+workspace, and test-framework details behind explicit interfaces; avoid a large
+typeclass hierarchy before multiple real consumers establish the need.
+
+The CLI must call this library. It must not maintain a second outcome classifier.
+The library must return results without calling `exitWith` or printing mandatory
+terminal output. Supply a progress callback or event stream for callers that
+want live feedback. Asynchronous cancellation must still terminate owned
+children and close evidence files.
+
+Haskell-only hooks may exceed the portable manifest vocabulary. The API must
+describe that boundary honestly; every built-in manifest feature must have a
+library equivalent, but arbitrary Haskell functions need not serialize to JSON.
+
+## 5. Execution contract
+
+For each selected case, the runner must:
+
+1. Validate the manifest, referenced commands, paths, IDs, selectors, and budgets.
+2. Snapshot the declared source inputs and record their identity. Define whether
+   the snapshot includes committed files, dirty edits, and untracked files;
+   never silently test HEAD when the user selected working-tree contents.
+3. Prepare a disposable workspace with its own writable build directory and any
+   declared resource setup. Record the effective build configuration.
+4. Build and run the unchanged baseline for the same selection and configuration
+   the mutant will use. Verify that the selected tests exist and pass.
+5. Restore the pristine snapshot, apply exactly this case's edit sequence, and
+   verify that the mutation changed the intended files.
+6. Build or check the mutated candidate. Run the selected test command only when
+   the declared expectation requires it and the preceding stage succeeded.
+7. Evaluate the expected outcome using structured stage and failure evidence.
+   Run any declared replay step, preserving the original and replay outcomes.
+8. Write the case result and logs, then reset or discard the workspace. A failed
+   reset invalidates reuse of that worker; continue only in a fresh workspace.
+
+An unchanged baseline may be reused for cases with the same snapshot,
+configuration, environment contract, and selector. An incremental build directory
+may be reused within an isolated worker if every case starts from identical
+source bytes and the build tool observes restored inputs correctly. Separate
+workers must not share a writable candidate tree or build directory.
+
+Support explicit no-op control cases. A no-op must leave bytes unchanged and its
+tests must pass; controls are the sole exception to the changed-bytes rule. A
+failed or timed-out control invalidates the run's confidence in its detections.
+Baseline success and controls reduce false attribution but do not prove absence
+of flakiness.
+
+Source snapshots must exclude run outputs and build artifacts by default. The
+report records inclusion/exclusion rules and the manifest, tests, configuration,
+and runner version used. Cleanup must preserve logs needed to explain failures.
+Heni must never restore a backup over a user's active checkout.
+
+## 6. Edits and workspace boundaries
+
+Text replacements declare a target file, old text, new text, and required match
+count. The default match count is exactly one. Missing or ambiguous anchors and
+an empty change are invalid mutations, not survivors or successful detections.
+Apply multi-edit cases in a declared order; later edits observe earlier edits.
+
+Patch application must be checked and atomic with respect to the case workspace.
+A partially applied patch must not contaminate the next case. File creation and
+deletion must have preconditions and appear in the recorded diff. Invalid UTF-8
+must produce an explicit edit error; binary edits require a separate API.
+
+All file mutations and working directories must stay inside the disposable
+workspace. Reject absolute mutation targets, parent traversal, and symlink
+escapes. Execute commands as an executable plus argument list, without implicit
+shell interpolation. Shell scripts remain available through an explicit shell
+command. A workspace isolates files; it is not an operating-system sandbox for
+consumer-supplied commands or a way to roll back external service effects.
+
+## 7. Expected failures and verdicts
+
+The default semantic expectation is: the candidate builds, a nonempty selection
+executes, and the named assertion fails. A plain nonzero subprocess exit is
+insufficient. Standard test adapters should provide test IDs, selected/executed
+counts, and named failures. Generic command expectations must declare the
+accepted exit code and a specific diagnostic witness, and document their weaker
+ability to distinguish assertion failure from setup failure.
+
+| Result | Meaning |
+| --- | --- |
+| `caught` | The declared fault was detected through the required stage and witness. |
+| `survived` | Valid mutation, passing baseline, and executed selection, but the required detection did not occur. |
+| `invalid-mutation` | Edit preconditions failed, the patch is stale, or a required change was absent. |
+| `uncovered` | A required selector matched no tests, or execution evidence shows the intended selection did not run. |
+| `unexpected-build-failure` | A semantic mutant did not reach its test stage. |
+| `wrong-failure` | The command failed through a different test, diagnostic, exit, or signal. |
+| `timed-out` | A stage exceeded its declared budget. |
+| `infrastructure-error` | Preparation, resource setup, process launch, reporting, or cleanup failed. |
+| `not-run` | A selected case was skipped because execution stopped or its baseline failed. |
+
+Record baseline and control failures as run-level failures with affected cases.
+Every selected case must appear exactly once in the final report, including
+cases that never started. An invalid configuration is a configuration error,
+with a diagnostic naming the field and case where possible.
+
+An intentional build-rejection case must declare the build/check stage and
+expected diagnostic. Report it as `caught` with detection kind
+`expected-build-rejection`, separately from semantic test kills. An unrelated
+compiler error does not satisfy that expectation.
+
+A crash expectation must declare the accepted signal and a case-specific probe
+showing that the intended native operation was reached. Generic crashes and
+runner-enforced termination never count as successful semantic detections.
+Timeouts remain distinct outcomes. Future hang-detection features require a
+separate explicit contract.
+
+For property cases, record the generator configuration and replay token. When
+replay is required, the counterexample must reproduce the same named failure;
+otherwise classify the result as `wrong-failure` with replay evidence.
+
+## 8. Commands, adapters, and resources
+
+The generic command interface is the baseline integration. Supply Cabal-oriented
+examples and optional adapters for Tasty and Hspec. The generic core must not
+depend on those test libraries or require migrating consumer test suites.
+
+Each stage has a wall-clock budget, output policy, working directory, and
+environment overrides. Distinguish a normal exit, signal termination, and
+runner-enforced timeout. On timeout or interruption, terminate and reap the
+owned process group where supported, using a bounded grace period. Specify and
+test descendant-process behavior on each supported platform.
+
+Setup/teardown hooks and resource concurrency limits must be explicit. Consumers
+own broker, database, and other external fixtures. Parallel execution is opt-in
+for cases using shared services unless the user declares independent resource
+names or an appropriate concurrency limit. Avoid multiplying Cabal build jobs
+by mutation-worker jobs without a documented resource budget.
+
+## 9. CLI and portable manifests
+
+The proposed command surface is:
+
+```text
+heni check heni.json
+heni list heni.json
+heni run heni.json --case guard-bypass --output mutation-results
+heni run heni.json --tag regression --jobs 2 --output mutation-results
+heni report mutation-results/report.json
+```
+
+`check` validates the definition and edit applicability without running mutation
+tests. It must clearly distinguish static checks from test-discovery or baseline
+execution, which may need a build. `list` explains IDs, tags, selectors, and
+expectations. `run` records the selected cases and supports cancellation.
+`report` renders stored evidence without rerunning commands. Selecting nothing
+must be explicit in the output and fail `run` by default.
+
+Use JSON as the initial interchange format, with a published JSON Schema and a
+top-level `schemaVersion`. Reject unsupported versions and unknown keys to catch
+typos. Commands are reusable named definitions; cases reference them by ID.
+Describe defaults in the schema and include the effective values in reports.
+
+This illustrative case uses a generic diagnostic expectation. It does not
+provide the richer test-count and named-assertion evidence of a test adapter:
+
+```json
+{
+  "schemaVersion": 1,
+  "commands": {
+    "build": {"executable": "cabal", "arguments": ["build", "example-tests"], "timeoutSeconds": 120},
+    "test": {"executable": "cabal", "arguments": ["test", "example-tests", "--test-show-details=direct"], "timeoutSeconds": 30}
+  },
+  "cases": [{
+    "id": "guard-bypass",
+    "description": "Removing the capacity guard must be detected.",
+    "tags": ["regression"],
+    "edits": [{"kind": "replace", "path": "src/Capacity.hs", "old": "used < limit", "new": "True", "occurrences": 1}],
+    "build": "build",
+    "test": "test",
+    "expect": {"kind": "diagnostic", "stage": "test", "exitCode": 1, "outputContains": "rejects full capacity: FAIL"}
+  }]
+}
+```
+
+Run the unchanged command with the same selector before using this expectation.
+The example is a design sketch; release examples must be runnable and checked
+against the actual manifest decoder and JSON Schema.
+
+Proposed exit contract: `0` means every selected case met its expectation and
+baselines/controls passed; `1` means a completed run failed that contract; `2`
+means configuration or infrastructure prevented a trustworthy complete run.
+On interruption, use the platform's documented interruption convention and
+retain a partial report. Infrastructure errors take precedence over detected
+survivors when selecting the overall exit status.
+
+## 10. Reports, selection, and efficiency
+
+The report must include schema and tool versions, source snapshot identity and
+dirty-state policy, effective configuration, platform/tool versions, selected
+IDs, case descriptions, applied diffs, stage durations, commands, outcomes,
+diagnostic witnesses, selected/executed test counts when available, replay data,
+and relative log paths. Record external resource versions when supplied by the
+consumer. Do not dump the full inherited environment or credentials into reports.
+
+Write progress and case results incrementally, then finalize the summary
+atomically. Failed and cancelled runs must retain useful evidence. Allow output
+retention and log-volume limits, but never silently truncate the witness used to
+justify a detection. Reports should be portable with their referenced log files.
+
+Start with explicit case and tag selection. Later changed-file selection must
+account for tests, fixtures, the runner configuration, and source changes. When
+impact cannot be established, rerun conservatively; a source-only filter can
+miss a weakened test. Coverage-driven selection requires versioned coverage
+provenance and must not silently drop missing mutation IDs.
+
+Always show result counts and incomplete cases. If a semantic mutation score is
+displayed, define it as caught semantic cases divided by caught plus survived
+semantic cases, alongside the separate counts for invalid, uncovered, timed-out,
+wrong-failure, and unrun cases. A score with unresolved cases must be labeled
+incomplete and cannot make the CI gate pass. Report expected build rejections,
+controls, and explicit reviewed exclusions separately. Do not automatically
+declare an undetected mutation equivalent.
+
+## 11. Validation and release criteria
+
+The runner's own tests must cover false-positive classifications, baseline and
+control failure, empty selection, ambiguous/no-op edits, partial patch failure,
+restoration, timeout and cancellation cleanup, wrong named failures, replay
+mismatch, partial reports, and worker independence. Use small self-contained
+fixture packages or subprocesses; heavyweight services belong in optional
+integration suites.
+
+Before the first public release:
+
+1. Demonstrate a useful case from a fresh standalone Cabal project using only
+   released dependencies, through both the library and the CLI.
+2. Exercise representative patch, replacement, named-test, diagnostic,
+   intentional build-rejection, and replay cases. Run baseline/control checks
+   and prove that wrong failures cannot produce a successful result.
+3. Provide a quickstart, runnable examples, complete Haddock for exposed
+   modules, manifest/report schemas, command help, and a troubleshooting guide
+   for every outcome. The first successful mutation should take only a small
+   manifest or a short Haskell definition.
+4. Run `cabal check` for each package; build and test from extracted `cabal sdist`
+   archives; build Haddock; verify examples and all required fixtures are in the
+   archives. Validate against the advertised Linux/macOS and GHC matrix.
+5. Include accurate package metadata, maintainer/contact and source links,
+   changelogs, and a maintainer-selected open-source license and license file.
+   Follow the Haskell Package Versioning Policy for public API changes. Choose
+   tested dependency bounds and document compatibility.
+6. Verify intended Hackage names, prepare candidate uploads, and review their
+   rendered documentation and installability before publication. Publication
+   itself is a later release action.
+
+These are Heni's release quality criteria. Cabal's official
+[packaging guide](https://cabal.readthedocs.io/en/stable/how-to-package-haskell-code.html)
+and [command reference](https://cabal.readthedocs.io/en/stable/cabal-commands.html)
+describe package construction, checks, source distributions, documentation, and
+uploads; the [PVP](https://pvp.haskell.org/) defines versioning policy.
+
+## 12. Delivery order and evidence of value
+
+First implement the result model and classification fixtures, then isolated
+workspaces and checked edits, followed by generic process execution. Add the
+portable manifest and CLI through the same library entry point. Introduce test
+adapters and replay only after the core can demonstrate the full baseline to
+report cycle.
+
+Validate extraction against the patterns in [RES-1](research/mutation-testing-survey.md):
+one compact replacement runner, a patch catalogue, a generated-code mutation,
+and an external-command diagnostic case. Preserve the specific expectation and
+compare outcomes before and after migration. Keep these consumer migrations as
+separate work from bootstrapping this repository.
+
+Measure new-case authoring effort, shared versus consumer-specific runner code,
+failure diagnosis effort, and elapsed build/test time independently. The initial
+claim is reduced orchestration and more trustworthy verdicts; runtime speedups
+must be measured. Keep unsupported cases and integration costs in the results.
+
+The maintainer selected BSD-3-Clause on 2026-10-24. Open design decisions include the final GHC matrix, exact
+adapter output protocols, patch implementation, resource-hook API, and which
+report fields become stable in version 1. Resolve them with prototypes and real
+consumer evidence before committing to a broad public abstraction.
diff --git a/docs/migration-guide.md b/docs/migration-guide.md
new file mode 100644
--- /dev/null
+++ b/docs/migration-guide.md
@@ -0,0 +1,90 @@
+# Moving a mutation script to Heni
+
+Keep the fault and its assertion in the consumer project. Move snapshotting,
+checked edits, baselines, subprocess ownership, attribution and reporting into
+Heni. Start with one meaningful case and a precise witness; migrate a service
+fixture only after its owner supplies reset and teardown semantics.
+
+The local examples are independently authored BSD-3-Clause fixtures. No code
+or license grant was copied from the surveyed consumers, and their repositories
+were not modified or executed. The source inventory was refreshed through Mori
+on 2026-10-24; exact HEAD/file hashes are in
+[the identity record](validation/migration-source-identities.json). The rdkafka
+runner changed after RES-1: its refreshed file has 453 lines rather than the
+historical 349. Neither count estimates removable orchestration. File-level
+Mori artifact URIs are pending; each relative source path below is paired with
+its canonical owner.
+
+| Local family | Surveyed pattern | Fault and witness |
+| --- | --- | --- |
+| [replacement](../examples/migrations/replacement/heni.json) | [rdkafka-hs](mori://shinzui/rdkafka-hs/packages/rdkafka-hs), `scripts/test-mutations.hs`; [Shibuya](mori://shinzui/shibuya), `scripts/audit/run-websocket-mutation-controls.py` | Replace one capacity guard; require the full-capacity diagnostic. |
+| [patch catalogue](../examples/migrations/patch-catalogue/heni.json) | [Nagare](mori://shinzui/nagare), `cli/nagare-harness/src/Nagare/Harness/Mutation.hs` and `cli/nagarectl/test/mutations/records.json` | Apply a named checked textual patch; stale patches are invalid mutations. |
+| [generated code](../examples/migrations/generated-code/heni.json) | [Keiro DSL](mori://shinzui/keiro/packages/keiro-dsl), `keiro-dsl/test/history-dependent-holes-mutation-test.sh` | Mutate generator input, generate, compile, then require the generated guard assertion to fail. |
+| [diagnostic checker](../examples/migrations/diagnostic-checker/heni.json) | The external-rule checker pattern in [RES-1](research/mutation-testing-survey.md) | Change capacity policy; require exit 1 and the exact capacity-rule diagnostic. |
+
+Each checked-in default manifest selects an intended-caught case. The trial
+adds a deliberately surviving label change through both independent Haskell
+case definitions and the portable manifest. Replacement also distinguishes an
+unrelated compile error from an intentional compiler-rejection expectation.
+Use a new output directory for every invocation:
+
+```bash
+cabal run heni-cli:exe:heni -- run examples/migrations/replacement/heni.json --output .heni-runs/migration-replacement
+cabal run heni-cli:exe:heni -- run examples/migrations/patch-catalogue/heni.json --output .heni-runs/migration-patches
+cabal run heni-cli:exe:heni -- run examples/migrations/generated-code/heni.json --output .heni-runs/migration-generated
+cabal run heni-cli:exe:heni -- run examples/migrations/diagnostic-checker/heni.json --output .heni-runs/migration-diagnostic
+```
+
+These examples need released GHC/Cabal, and only the patch family needs Git.
+They require no database, broker, Mori, OKF or Nix at runtime. The checker builds
+`./dist-newstyle/checker`; availability of explicit workspace executables is
+checked after build rather than against the caller's active directory.
+
+For generated code, identify what the edit changes. This fixture edits
+`model.txt` before `tools/Build.hs` creates `generated/Generated.hs`. The build
+log records the generated definition and the test executes that compiled
+module. The test command never regenerates it. Editing generated output before
+a build that overwrites it can produce a survivor without testing the intended
+fault. Use generator input where possible; otherwise supply a consumer-owned
+build/edit workflow that preserves and verifies the generated artifact.
+
+A Haskell consumer can replace a declarative edit with `CustomEdit` and a
+`CaseHooks.customEdits` function using checked `WorkspaceOps`. The generated
+trial demonstrates that boundary while its CLI equivalent uses `Replace`.
+The hook owns the transformation only. It does not copy/restore a checkout,
+spawn cleanup loops, or decide the mutation verdict. Arbitrary Haskell hooks
+and build observation verifiers cannot be serialized into a manifest.
+
+| Capability | First-release contract |
+| --- | --- |
+| Checked UTF-8 replacement, create/delete | Direct Haskell and manifest support, ordered exact preconditions. |
+| Textual patch catalogues | Optional Git; no rename/copy/mode-only/binary patch claim. |
+| Generated artifacts | Input mutation is direct; output ordering/preservation needs a consumer-owned workflow or hook. |
+| Generic external checker | Exact stage, exit and byte witness; test counts remain unknown. |
+| Named assertions | Optional Tasty/Hspec reporters, complete attempt-bound IDs/counts, no colored-output scraping. |
+| Property replay | Native QuickCheck helper with Hspec, one selected replay property per attempt; other native protocols need typed builders. |
+| Native crashes | POSIX signal plus a fresh case/stage probe; reachability does not establish arbitrary root cause. |
+| Controls and compiler rejection | Explicit separate expectations; an unrelated compiler failure cannot satisfy a semantic assertion. |
+| Multi-stage commands | Build/test/replay are supervised; consumer generation/setup commands remain explicit. |
+| Shared services | Scoped resources, namespaced bindings, reset/teardown, capacities and meaningful epochs; live service proof belongs to the consumer. |
+| Workers and reuse | Private trees, bounded claims; opt-in compatible baselines. Incremental builds need a verified Haskell provider; no universal Cabal timestamp guarantee. |
+| Binary files | Snapshot/copy support; no first-release binary mutation vocabulary. |
+| Database-state fault injection | External consumer fixture work; filesystem isolation does not roll back a database. |
+| Detached processes/cross-process locks | Consumer ownership; the in-process scheduler cannot coordinate separate campaigns. |
+| AST operators, distributed execution, persistent verdict cache, Windows | Unsupported in this release. |
+
+Run the combined trial with `sh scripts/test-migrations.sh`. It separately builds
+the CLI, then enables the core archive's `migration-tests` flag with
+`HENI_CLI_EXE`. Default core tests have no CLI/framework dependency. Repository
+release CI runs this explicit integration gate as well as the optional reporter
+packages. The Haskell comparison harness in `test/migration/Legacy.hs` is an
+independently authored finite measurement fixture with deliberately exit-only
+verdicts, not a recommended runner or a copy of production scripts.
+
+The [value record](validation/migration-results.md) separates executable parity,
+consumer definitions/domain helpers, lifecycle scaffolding, automated authoring
+and diagnosis actions, and elapsed build time. Automated agent observations are
+not a human usability study. Full consumer migrations remain later work: start
+with one replacement in its owning project, then a small patch batch, generated
+workflows with explicit artifact ordering, and finally live resource cases with
+consumer-owned acceptance. No fleet migration has occurred here.
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,54 @@
+# First caught mutation and survivor
+
+Use GHC 9.12.4 and Cabal with released dependencies. From this checkout, build
+the packages normally; Nix and repository indexing tools are optional developer
+tooling. Heni is currently unreleased, so Hackage installation is a later step.
+
+First observe the unchanged target passing:
+
+```bash
+cd examples/minimal
+cabal test fixture-test --offline -j1 --test-show-details=direct
+cd ../..
+```
+
+Then check the manifest's structure and edit applicability. This command does
+not run tests or discovery:
+
+```bash
+cabal run heni-cli:exe:heni -- check examples/minimal/heni.json
+```
+
+Run one capacity-guard mutation. Heni first builds/tests the unchanged snapshot,
+then edits and tests a separate disposable copy:
+
+```bash
+cabal run heni-cli:exe:heni -- run examples/minimal/heni.json --case guard-bypass --output .heni-runs/quickstart-caught
+```
+
+Expect `caught` and exit 0, with the precise full-capacity assertion diagnostic.
+The report and logs are under the chosen output directory. A harmless marker
+change demonstrates a survivor:
+
+```bash
+cabal run heni-cli:exe:heni -- run examples/minimal/heni.json --case survivor --output .heni-runs/quickstart-survivor
+```
+
+Expect `survived` and exit 1. A survivor means the valid change was not detected;
+it does not establish equivalence. Use fresh output paths when repeating these
+commands. `heni report PATH/report.json` renders evidence without rerunning it.
+
+For Haskell, [Definitions.hs](../examples/library/Definitions.hs) shows short
+case definitions and [Main.hs](../examples/library/Main.hs) calls the same
+`runCampaign` API. Construct options with `defaultRunOptions` and update named
+fields. The library returns `RunResult` and leaves application exits/rendering
+to the caller:
+
+```bash
+cabal run heni:exe:heni-library-example -- --output .heni-runs/quickstart-library
+```
+
+That example returns caught, survivor and unrelated compiler-failure evidence.
+See [CLI usage](cli.md), [migration patterns](migration-guide.md),
+[named adapters and replay](design/adapters.md), and
+[resource ownership](design/scheduling.md) for stronger integrations.
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,71 @@
+# Preparing a release
+
+The bootstrap produces local source/docs archives and a readiness record. It does
+not upload candidates, publish packages, push commits or create tags. Recheck the
+[readiness record](https://github.com/shinzui/heni/blob/master/docs/validation/release-readiness.md) and current package-name state
+before a later release action. An absent package name is not a reservation or an
+ownership grant; verify the intended Hackage account's upload permissions.
+
+The archive gate requires released cabal-install 3.16.1.0 or newer for its
+selective Haddock options. Core build/runtime tests also passed with 3.14.1.1,
+but that version hides the requested implementation pages. Use the tested
+GHC/Cabal combinations in [compatibility](design/compatibility.md).
+The four packages share version 0.1.0.0; CLI/reporters require that exact core
+version. Public API versioning follows the [PVP](https://pvp.haskell.org/): breaking
+API/type/instance changes increase A.B, additive APIs increase C, and fixes may
+use the fourth component. JSON schema versions evolve independently. Do not use
+`--allow-newer` to replace validation of admitted dependencies.
+
+Run package checks in each package directory and then validate fresh source
+archives. Development-only schema validation requires jsonschema 4.26.0.
+
+```sh
+cabal build all -j1
+cabal test all -j1 --test-show-details=direct
+sh scripts/test-migrations.sh
+HENI_SCHEMA_PYTHON=/path/to/schema-venv/bin/python sh scripts/check-sdist.sh --bounds lower --output dist-release/lower
+HENI_SCHEMA_PYTHON=/path/to/schema-venv/bin/python sh scripts/check-sdist.sh --bounds newest --output dist-release/newest
+```
+
+The script records the fresh extraction path, source archive hashes, compiler,
+Cabal, each check's exit/duration and solver plans. It retains failed extractions
+for diagnosis. Only extracted Heni packages are project sources; other dependencies
+come from released repositories. Core-only tests have no CLI/framework closure
+or Git/Nix/Mori/OKF/Python executable in their PATH. A separate positive patch gate
+requires Git. CLI/reporters, Haddock, schemas, standalone 0/1/2 exit checks and the
+four-family migration trial run from archives. Use `--archives DIRECTORY` to
+validate an existing immutable archive set on another machine.
+
+The script selectively generates two non-exposed implementation pages so
+Haddock's instance-origin links resolve. Their `not-home` attributes keep public
+facades as documentation homes; this does not expose package modules or private
+constructors. Use the script's Haddock flags when rebuilding release docs.
+
+Inspect generated module pages and source links, command help, examples,
+troubleshooting, package contents and dependency plans. Copy accepted Haddock
+archives from the fresh extraction's dist-newstyle tree beside the source
+archives; record their hashes. Never modify an accepted archive after hashing:
+rebuild and rerun relevant gates after source changes. The readiness record and its hashes are repository-only evidence, excluded
+from source archives to avoid self-referential archive hashes. Keep those
+results under version control; retain large build logs and local tarballs outside
+Git. Confirm no credential or secret environment value appears in evidence.
+
+For the separately authorized Hackage step, consult the current official
+[Cabal packaging guide](https://cabal.readthedocs.io/en/stable/how-to-package-haskell-code.html)
+and [command reference](https://cabal.readthedocs.io/en/stable/cabal-commands.html#cabal-upload).
+As checked on 2026-10-24, `cabal upload SOURCE.tar.gz` uploads a candidate by
+default; `--publish` immediately publishes. `cabal upload --documentation DOCS.tar.gz`
+uploads candidate documentation. Keep authentication in the user's normal secure
+Cabal configuration; no credential is part of this validation script or record.
+
+Upload candidates only after that action is authorized. Review each candidate's
+rendered documentation, source contents, license, dependency bounds and installability
+with all matching Heni candidate archives in a fresh project. A candidate cannot
+make another unpublished core version a normal released dependency. Coordinate
+publication in dependency order (core, then CLI/reporters) after candidate review;
+verify normal Hackage installation and rendered docs afterward. Candidate review,
+account authorization, publication and release tags remain explicit handoff items.
+
+Actual consumer migrations are a separate roadmap in [the migration guide](migration-guide.md).
+Each owning project must preserve its fault, witness and live resource/reset
+contract; local representative fixtures do not establish those migrations.
diff --git a/docs/research/ecosystem-mutation-testing-design.md b/docs/research/ecosystem-mutation-testing-design.md
new file mode 100644
--- /dev/null
+++ b/docs/research/ecosystem-mutation-testing-design.md
@@ -0,0 +1,228 @@
+---
+type: Research Document
+title: Mutation-testing ecosystem lessons for Heni's API and execution model
+description: Compare mutation tools across ecosystems and derive bounded, testable design choices for Heni.
+generated:
+  by: process:codex
+  at: "2026-10-24T14:36:00Z"
+researchId: RES-2
+status: complete
+reviews:
+  - kind: model
+    reviewer: process:codex
+    reviewed_at: "2026-10-24T14:53:00Z"
+    document_timestamp: "2026-10-24T14:36:00Z"
+    scope: content-and-metadata
+    outcome: commented
+    provider: openai
+    model: gpt-6-astra
+    effort: unspecified
+    context: >-
+      Author self-review of primary-source comparisons, canonical references,
+      adoption inferences, and prototype/evidence limits. No tool benchmarks,
+      implementation verification, or independent approval is claimed.
+scope: >-
+  Documentation review of cargo-mutants, StrykerJS, Stryker.NET, PIT, and
+  mutmut on 2026-10-24, combined with Heni RES-1 and selected local Haskell
+  standards and architectural decisions. No comparative benchmarks or tool
+  executions were performed. This record informs prototypes; it does not
+  certify APIs, package compatibility, or productivity improvements.
+sources:
+  - id: cargo-baseline
+    resource: https://mutants.rs/baseline.html
+    title: cargo-mutants baseline tests
+  - id: cargo-results
+    resource: https://mutants.rs/using-results.html
+    title: cargo-mutants result interpretation
+  - id: cargo-timeouts
+    resource: https://mutants.rs/timeouts.html
+    title: cargo-mutants hangs and timeouts
+  - id: stryker-states
+    resource: https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/
+    title: Stryker mutant states and metrics
+  - id: stryker-incremental
+    resource: https://stryker-mutator.io/docs/stryker-js/incremental/
+    title: StrykerJS incremental execution and limitations
+  - id: stryker-workers
+    resource: https://stryker-mutator.io/docs/stryker-js/parallel-workers/
+    title: StrykerJS worker resource conflicts
+  - id: stryker-net
+    resource: https://stryker-mutator.io/docs/stryker-net/configuration/
+    title: Stryker.NET coverage and baseline configuration
+  - id: pit-basics
+    resource: https://pitest.org/quickstart/basic_concepts/
+    title: PIT operators, coverage, and results
+  - id: pit-incremental
+    resource: https://pitest.org/quickstart/incremental_analysis/
+    title: PIT incremental analysis assumptions
+  - id: mutmut
+    resource: https://mutmut.readthedocs.io/en/latest/
+    title: mutmut usage, selection, and process isolation
+  - id: haskell-standards
+    resource: mori://shinzui/haskell-jitsurei/docs/core-standards
+    title: Haskell core standards
+  - id: records
+    resource: mori://shinzui/haskell-jitsurei/docs/core-record-patterns
+    title: Record conventions and transitive orphan-instance exposure
+  - id: prelude
+    resource: mori://shinzui/haskell-jitsurei/docs/core-custom-prelude
+    title: Small project preludes and import boundaries
+  - id: library-boundary
+    resource: mori://shinzui/mori-app/okf/adrs/concepts/ADR-1
+    title: A base library owns transport and delivery, not the domain effect stack
+  - id: cli-boundary
+    resource: mori://shinzui/keiro/okf/adrs/concepts/ADR-28
+    title: Operator commands wrap supported library APIs and respect schema ownership
+---
+
+# Mutation-testing ecosystem lessons for Heni's API and execution model
+
+## Question and evidence boundary
+
+Which ideas from established mutation tools help Heni replace bespoke orchestration
+without losing the precise fault expectations identified in
+[RES-1](mutation-testing-survey.md)? The research index (`docs/research/index.md`) is the entry
+point for both records. RES-1 supplies the local use-case evidence; this record
+adds cross-ecosystem design comparisons before the public API is implemented.
+
+The sources below are primary project documentation, consulted on 2026-10-24.
+Their URLs describe evolving products rather than pinned releases. No tool was
+installed or benchmarked for this comparison. Claims about speed, completeness,
+or superiority are consequently not established. Dependency versions and bounds
+must be checked against Hackage and upstream tags when selected, not inferred
+from either these pages or the local Mori corpus.
+
+## Observations and implications
+
+| Tool | Documented behavior | Proposed Heni response |
+| --- | --- | --- |
+| cargo-mutants, Rust | Runs an unchanged baseline in a temporary copy, then distinguishes caught, missed, unviable, and timeout outcomes. Build and test timeout controls are separate. | Adopt the baseline/candidate stage boundary and explicit budgets. Require the declared witness as well as a failed command. |
+| StrykerJS and shared reporting | Provides separate mutant states, partial incremental results, and reuse informed by changes to source and tests. Its score counts timeout as detection. | Adopt durable per-case evidence and distinct states; retain Heni's stricter timeout and CI semantics. |
+| Stryker.NET, C# | Offers per-test coverage selection and isolated test runs; static initialization may need all tests because precise coverage is unavailable. | Model selection evidence and identity explicitly; postpone coverage selection until its provenance and fallback behavior can be proved. |
+| PIT, Java | Selects tests using line coverage and timing; incremental analysis acknowledges incomplete dependency tracking. | Leave room for selection and scheduling strategies, but start with explicit selection and conservative reuse. |
+| mutmut, Python | Supports focused runs and persistent progress; documents extra fixture copying, relevance filtering, and inherited-state hazards in forked workers. | Make snapshot contents explicit and start stages in supervised external processes. Provide an easy focused-case workflow. |
+
+The cargo-mutants baseline verifies that tests still pass after copying the tree;
+this matters when fixtures or location assumptions are missing. Its result model
+separates inability to compile from a successful test detection. Heni should
+also distinguish intentional build rejection from an accidental compiler error.
+These are execution-stage lessons, not a reason to copy Rust's mutation operators.
+Sources: [baseline](https://mutants.rs/baseline.html),
+[results](https://mutants.rs/using-results.html), and
+[timeouts](https://mutants.rs/timeouts.html).
+
+Stryker's shared metrics explicitly include timeouts among detections. Heni's
+curated cases ask whether a particular fault reached a particular assertion;
+elapsed time alone cannot supply that evidence. Keep `timed-out` separate and
+make unresolved cases fail the run contract. If a reporting adapter is added
+later, it must disclose this semantic difference instead of mapping a timeout
+to a successful Heni detection. Source:
+[states and metrics](https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/).
+
+StrykerJS incremental execution retains partial results and considers source
+and test changes, but documents gaps for configuration, dependencies, environment,
+and incomplete test-location reporting. PIT likewise notes that changes in
+dependencies can invalidate otherwise unchanged class/test results. Heni should
+first reuse baselines only inside a run with an explicit identity comprising
+source bytes, commands, selector, adapter, build configuration, and environment
+and resource contracts. Persistent cross-run verdict reuse is outside the first
+release. Sources: [StrykerJS incremental](https://stryker-mutator.io/docs/stryker-js/incremental/)
+and [PIT incremental](https://pitest.org/quickstart/incremental_analysis/).
+
+StrykerJS documents false kills caused by workers competing for the same port
+or resource and offers worker identities for partitioning. Heni should give each
+worker independent writable source/build directories, plus explicit resource
+claims and limits. A worker identity alone does not reserve a port, isolate a
+database, or undo external effects. Source:
+[parallel workers](https://stryker-mutator.io/docs/stryker-js/parallel-workers/).
+
+PIT's coverage/timing selection and Stryker.NET's conservative treatment of
+static initialization show why selection belongs in an explicit adapter
+contract. Named-test evidence must distinguish discovered, selected, executed,
+failed, skipped, and setup-failed tests. Heni must not treat an unknown count as
+zero or assume a filter actually executed its intended test. Sources:
+[PIT basics](https://pitest.org/quickstart/basic_concepts/) and
+[Stryker.NET configuration](https://stryker-mutator.io/docs/stryker-net/configuration/).
+
+mutmut documents extra files needed in its working directory, relevance limits
+that change which tests count, and process-state inheritance that can cause hangs
+or misleading outcomes. Heni should make inclusion rules visible, preserve
+selection policy in evidence, and avoid an in-process framework runner in the
+initial design. This is an inference about Heni's needs, not a claim that the
+same Python process mechanisms apply to Haskell. Source:
+[mutmut documentation](https://mutmut.readthedocs.io/en/latest/).
+
+## Haskell design constraints
+
+Apply the relevant [core standards](mori://shinzui/haskell-jitsurei/docs/core-standards):
+GHC2024, the GHC 9.12-or-newer policy, common extension stanzas, postpositive
+qualified imports, explicit deriving strategies, and strict record fields.
+The actual tested compiler matrix and dependency bounds remain release evidence,
+not facts established by the policy. The existing repository scaffold names
+GHC 9.12.4; this is a local development choice, not a verified compatibility range.
+
+Use concrete values and smart constructors for IDs, relative paths, positive
+budgets, and nonempty case collections. Keep wire codecs explicit rather than
+allowing constructor renaming to change JSON. A small internal prelude may
+reduce repetition, but do not make its convenience exports the public API.
+[Record patterns](mori://shinzui/haskell-jitsurei/docs/core-record-patterns) warn
+that label instances propagate transitively. Therefore the public library's
+import closure must not introduce generic-lens's orphan label instance; plain
+constructor matching and explicit combinators are a justified application of
+the pattern's isolation concern. Optional CLI internals may use lenses without
+exposing them to library consumers. Document this scoped adaptation rather than
+silently importing a large application prelude. See also the
+[prelude guidance](mori://shinzui/haskell-jitsurei/docs/core-custom-prelude).
+
+Two relevant cross-repository ADRs were inspected through Mori. The
+[base-library boundary decision](mori://shinzui/mori-app/okf/adrs/concepts/ADR-1)
+shows why consumer hooks should discharge their own domain effects into plain
+`IO`. The [CLI ownership decision](mori://shinzui/keiro/okf/adrs/concepts/ADR-28)
+supports one library-owned execution/classification path with alternative
+presentations. These are architectural analogies; their event stores, service
+stacks, and operational authorization policies are not Heni requirements.
+No local Heni ADR existed at inspection time.
+
+## API direction to test
+
+A caller should construct a case, validate it, run a selected campaign, and
+inspect a structured report without changing test framework. Keep a short
+`Test.Heni` entry path, explicit option records with safe defaults, and optional
+progress callbacks. Avoid a custom effect language or a hierarchy of classes
+until two consumers require the same abstraction.
+
+Separate a portable case description from Haskell-only transformations and
+expectation hooks. A custom hook returns structured evidence; it cannot bypass
+baseline success, required byte changes, timeout handling, or cleanup errors.
+Built-in CLI cases must lower into the same library model. Framework reporters
+produce versioned evidence; the core owns the verdict. Resource providers should
+have scoped lifetimes so acquisition, use, and release cannot accidentally be
+separated by normal control flow. Their cancellation behavior needs executable
+tests, not just a convenient type signature.
+
+Defer automatic source/AST operators, mutation switching, compiler plugins,
+distributed workers, automatic equivalence claims, and coverage-driven
+selection. Keep compatible extension points, but do not pay their complexity
+before curated script replacement works.
+
+## Prototype and adoption tests
+
+The API must express all four RES-1 extraction patterns: checked replacement,
+patch catalogue, generated-code change, and external diagnostic validation.
+Add named assertions, property replay, explicit crash probes, and serialized
+shared-resource use to exercise the difficult extensions. Each trial needs an
+unchanged baseline and an intentionally broken detector or infrastructure path.
+Otherwise a successful run may merely preserve an existing false positive.
+
+Measure consumer-owned orchestration removed separately from fault definitions,
+new-case authoring time, diagnosis effort, and elapsed build/test time. Record
+hardware, compiler, commands, warm/cold conditions, fixture identity, and every
+unsupported pattern. A best-in-class aspiration becomes credible through these
+acceptance results and portable packaging; this research alone does not establish it.
+
+The coordinated implementation is
+[MasterPlan 1](../masterplans/1-bootstrap-heni-for-trustworthy-mutation-testing-and-hackage-release.md).
+The first child proves the model with executable fixtures; the first real
+baseline-to-report run is required before adapters, concurrency, or migration
+variants expand. Actual cross-repository migrations remain separate work;
+Heni contains reproducible, independently authored conformance fixtures.
diff --git a/docs/research/index.md b/docs/research/index.md
new file mode 100644
--- /dev/null
+++ b/docs/research/index.md
@@ -0,0 +1,12 @@
+---
+okf_version: "0.2"
+---
+
+# Files
+
+- [profile.dhall](profile.dhall)
+
+# Research Document
+
+- [Mutation-testing ecosystem lessons for Heni's API and execution model](ecosystem-mutation-testing-design.md) - Compare mutation tools across ecosystems and derive bounded, testable design choices for Heni.
+- [Mutation testing across Haskell projects and the case for Heni](mutation-testing-survey.md) - Evaluate existing mutation runners, identify reusable contracts, and bound a public Haskell library and CLI.
diff --git a/docs/research/log.md b/docs/research/log.md
new file mode 100644
--- /dev/null
+++ b/docs/research/log.md
@@ -0,0 +1,8 @@
+# Bundle Update Log
+
+## 2026-10-24
+
+* **Add**: Add RES-2 comparing mutation tools across Rust, JavaScript, C#, Java, and Python, with explicit Heni adoption decisions and prototype gates.
+* **Adoption**: Adopt the [published research-document profile](mori://shinzui/okf-profiles/profiles/research-documents), export `documentation.researchDocuments`, pinned at `v0.25.0` with a Dhall integrity hash.
+* **Add**: Capture [RES-1: Mutation testing across Haskell projects and the case for Heni](mutation-testing-survey.md), including source identities, measured runner counts, reusable contracts, and evidence limits.
+* **Update**: Link the investigation to the [initial public library and CLI specification](../initial-spec.md) and record author self-review without claiming independent verification.
diff --git a/docs/research/mutation-testing-survey.md b/docs/research/mutation-testing-survey.md
new file mode 100644
--- /dev/null
+++ b/docs/research/mutation-testing-survey.md
@@ -0,0 +1,302 @@
+---
+type: Research Document
+title: Mutation testing across Haskell projects and the case for Heni
+description: Evaluate existing mutation runners, identify reusable contracts, and bound a public Haskell library and CLI.
+generated:
+  by: process:codex
+  at: "2026-10-24T14:01:14Z"
+researchId: RES-1
+status: complete
+scope: >-
+  Source inspection of mutation infrastructure in Nagare, Keiro, rdkafka-hs,
+  and Shibuya, with a discovery scan of 148 Mori-registered project paths in
+  the shinzui and tan namespaces and selected adjacent checker tests. Counts
+  and primary-source identities were refreshed on 2026-10-24. Existing reports
+  were read; mutation suites were not rerun. This is an extraction assessment,
+  not an exhaustive ecosystem survey or a measured productivity benchmark.
+reviews:
+  - kind: model
+    reviewer: process:codex
+    reviewed_at: "2026-10-24T14:08:32Z"
+    document_timestamp: "2026-10-24T14:01:14Z"
+    scope: content-and-metadata
+    outcome: commented
+    provider: openai
+    model: gpt-6
+    effort: unspecified
+    context: >-
+      Author self-review of source counts, primary file fingerprints, stage
+      classification examples, canonical references, and stated evidence limits.
+      This is not independent review or an approval attestation. Source suites
+      were not rerun and future API feasibility remains unverified.
+sources:
+  - id: nagare
+    resource: mori://shinzui/nagare
+    title: Mutation manifest, Haskell harness, and incremental sweep workers
+  - id: keiro
+    resource: mori://shinzui/keiro/packages/keiro-dsl
+    title: Generated-code and DSL conformance mutation scripts
+  - id: rdkafka
+    resource: mori://shinzui/rdkafka-hs/packages/rdkafka-hs
+    title: Consolidated semantic mutation runner and parity evidence
+  - id: shibuya
+    resource: mori://shinzui/shibuya
+    title: WebSocket and queue mutation controls
+  - id: profiles
+    resource: mori://shinzui/okf-profiles
+    title: Product-discovery fixture mutations and precise diagnostic checks
+  - id: kenshou
+    resource: mori://shinzui/keiro-runtime-kenshou
+    title: Checker self-tests using doctored ledgers
+  - id: kikan
+    resource: mori://shinzui/kikan
+    title: Inline-document contract fixture mutation tests
+  - id: keiki
+    resource: mori://shinzui/keiki
+    title: Reusable shape-hash sensitivity tests
+  - id: kotei
+    resource: mori://shinzui/kotei
+    title: Release-feature validator self-tests
+  - id: kiroku
+    resource: mori://shinzui/kiroku
+    title: Benchmark acceptance validator fault checks
+  - id: mls
+    resource: mori://tan/mls-reconciler
+    title: Database-state and schema fault cases for chapter synchronization
+  - id: cabal
+    resource: https://cabal.readthedocs.io/en/stable/cabal-commands.html
+    title: Cabal package checks, source distributions, documentation, and uploads
+  - id: pvp
+    resource: https://pvp.haskell.org/
+    title: Haskell Package Versioning Policy
+---
+
+# Mutation testing across Haskell projects and the case for Heni
+
+## Question and conclusion
+
+Would a shared Haskell library save effort across the existing Bash, Python, and
+Haskell mutation runners, and could it be useful to the wider Haskell ecosystem?
+
+**The source evidence justifies a small shared execution library with a CLI.**
+The repeated work is preparing a baseline, isolating and applying a deliberate
+fault, invoking the appropriate build/tests, attributing the failure, restoring
+the candidate, and preserving evidence. The faults and expected diagnostics
+remain project-specific.
+
+The public product should make one useful mutation easy to define, work with
+existing test suites, and run from ordinary Cabal packages. Hackage publication
+is an explicit project goal. Internal repository conventions are evidence for
+the design, not required infrastructure for downstream users.
+
+This completed record captures a bounded investigation. Its review entry records
+an author self-review, not independent approval; no verification attestation is claimed.
+Implementation feasibility, developer-time savings, and runtime improvements
+remain to be established through prototypes and migration trials.
+
+## Method and evidence boundary
+
+Mori located the owning repositories and documentation. A filename and tracked
+code-content scan covered 148 registered project paths in the `shinzui` and
+`tan` namespaces under the local project tree. The scan found candidates;
+selected source files were then read to distinguish test sensitivity from
+ordinary application/database operations also called mutations.
+
+This scan can miss unregistered repositories, ignored or untracked scripts,
+different terminology, and ephemeral investigation scripts. It is sufficient
+to establish repeated implementations, not a complete inventory of all uses.
+No mutation suite, broker, database, remote builder, or timing benchmark was run
+for this investigation. Existing success reports are historical evidence, not
+fresh verification.
+
+Primary source snapshots refreshed while capturing this record:
+
+| Project | Inspected HEAD | Working-tree qualification |
+| --- | --- | --- |
+| [Nagare](mori://shinzui/nagare) | `4c99ffe3df84af1f16a2161944ac790197dd63fa` | Clean. |
+| [Keiro](mori://shinzui/keiro) | `e6ef560b0a8db0dc091d0f8d7da1bb08c29155be` | Clean. |
+| [rdkafka-hs](mori://shinzui/rdkafka-hs) | `5a80c66d7dcccc74062e14f146d18d32b33587ff` | Checkout contained unrelated manifest/plan edits; the inspected runner and parity record were unmodified. |
+| [Shibuya](mori://shinzui/shibuya) | `0967f450b659b48d24cb49f199718451a65d2d48` | Clean. |
+
+The file lists below are paired with canonical project/package URIs. File-level
+artifact URI coverage is pending; retain the owning URI with each relative path.
+The package URI for the Nagare harness was not resolvable in the local registry
+at inspection time, so its owning project URI is used.
+
+## Primary implementations
+
+### Nagare: a patch catalogue and staged proof runner
+
+In [Nagare](mori://shinzui/nagare), the relevant files are
+`cli/nagare-harness/src/Nagare/Harness/Mutation.hs`,
+`cli/nagarectl/test/mutations/records.json`, and `nix/test-runs.nix`.[^nagare]
+
+The manifest contains **189 records: 188 `test-fails` and one `build-fails`**.
+Each patch has a record name, suite, test pattern, and expected failure stage.
+The fast gate checks catalogue consistency, duplicate names, patch applicability,
+and whether patterns select tests. The Haskell result model distinguishes killed,
+survived, wrong-stage, stale, and not-run records.
+
+The remote sweep prepares one workspace and baseline build per worker, then
+patches, builds incrementally, tests a selected pattern, and reverses the patch.
+Source comments describe the full-rebuild cost and explain the incremental
+strategy; those timings were not independently measured here.
+
+Two gaps are visible in the source. `classifyProof` accepts any failed test-stage
+exit as a kill; the sweep uses `timeout 1800`, so a timeout exit can be classified
+as detection. Also, `selectRecords` follows changes to patch files, their source
+targets, and the manifest, without generally tracking which changed test files
+could weaken a mutation's detector. A shared runner should separate timeouts and
+include test changes in any conservative impact selection.
+
+### Keiro: repeated shell orchestration with improving attribution
+
+In [Keiro's DSL package](mori://shinzui/keiro/packages/keiro-dsl),
+`keiro-dsl/test/*mutation-test.sh` contains **21 top-level scripts totaling 1,558
+lines**, excluding four additional mutation helper scripts in a fixture
+subdirectory. The total includes consumer-specific faults and assertions as
+well as orchestration; it is not an estimate of removable code.[^keiro]
+
+Repeated code includes temporary backups, restoration traps, baseline runs,
+embedded Python/sed/perl edits, command exit capture, diagnostic matching, and
+post-restoration checks. The cases cover generated transducers, behavior holes,
+replay, structural/nominal mappings, and DSL validation.
+
+The older `keiro-dsl/test/mutation-test.sh` accepts any failed `cabal test`,
+despite describing a specific named assertion in its comment. An unrelated build
+failure can satisfy its success branch. Newer scripts, including
+`keiro-dsl/test/history-dependent-holes-mutation-test.sh` and
+`keiro-dsl/test/mapped-register-construction-mutation-test.sh`, check precise
+named failures or diagnostics; some also hash or compare restored bytes.
+
+Many scripts modify the active checkout and later copy backups over it. That
+restoration strategy can interfere with concurrent editing or another mutation
+run. Disposable workspaces would remove this recurring source of coordination
+risk while preserving the project-owned mutation definitions.
+
+### rdkafka-hs: an existing compact Haskell extraction candidate
+
+In [rdkafka-hs](mori://shinzui/rdkafka-hs/packages/rdkafka-hs),
+`scripts/test-mutations.hs` is a **349-line Haskell runner** with five families:
+producer, seek, property, topic, and callback. It defines **37 mutations and
+seven unchanged controls**.[^rdkafka]
+
+The runner makes an isolated source distribution, resets edited sources between
+cases, checks replacement anchors, builds each candidate, and requires a named
+regression failure. Property cases record and rerun generated counterexamples.
+Certain native lifetime faults explicitly permit a signal failure after a
+case-specific safety marker. Evidence includes logs, revision, source fingerprint,
+archive hash, and native library version.
+
+`docs/testing/mutation-runner-parity.json` reports parity for 32 original
+mutations and five controls, plus five new callback mutations and two controls.
+That is evidence of a previous consolidation from separate runners. Its results
+were inspected, not rerun or recertified against the refreshed HEAD.
+
+This is a useful starting point for extracting common orchestration, but it is
+still specialized: source archive layout, Cabal component names, output parsing,
+fingerprint inputs, native crash probes, and replay conventions belong to this
+consumer. Generalization should expose those choices explicitly.
+
+### Shibuya: the same pattern in Python
+
+In [Shibuya](mori://shinzui/shibuya),
+`scripts/audit/run-websocket-mutation-controls.py` applies three behavioral
+controls to queue and WebSocket implementation code. It requires a disposable
+checkout, edits known anchors, runs focused tests with a timeout, restores in a
+`finally` block, and records source hashes and results.[^shibuya]
+
+Its failure check combines a nonzero exit with evidence that examples ran.
+That is stronger than accepting an arbitrary build failure, but weaker than
+requiring the exact named assertion. It independently repeats the same core
+responsibilities as the Haskell and shell runners.
+
+## Adjacent patterns and their limits
+
+| Source | Observed pattern | Implication for Heni |
+| --- | --- | --- |
+| [OKF Profiles](mori://shinzui/okf-profiles), `scripts/test-product-discovery.py` | Copies fixture trees, applies exact-count edits, requires exit 1 and the expected rule/source/field diagnostic, rejects unrelated rule errors, and checks the restored baseline. | A strong model for diagnostic expectations over external commands.[^profiles] |
+| [Kenshou](mori://shinzui/keiro-runtime-kenshou), `kenshou-check/src/Kenshou/Check/Selftest.hs` | Nine deliberately doctored ledgers must produce checker violations. | Useful sensitivity controls; they can remain lightweight in-process tests.[^kenshou] |
+| [Kikan](mori://shinzui/kikan), `docs/architecture/evolution/conformance/inline-document-conversations/test_verify.py` | Copies contract fixtures, changes them, refreshes the manifest, and checks semantic rejection codes. | Fixture mutation needs the same attribution discipline as source mutation.[^kikan] |
+| [Keiki](mori://shinzui/keiki), `keiki-codec-json-test/src/Keiki/Codec/JSON/Test.hs` | A reusable helper checks that schema-shape variations change the hash. | Evidence that small domain-specific sensitivity helpers are already useful; no subprocess runner is needed for every case.[^keiki] |
+| [Kotei](mori://shinzui/kotei), `scripts/check-release-features.py` | Four altered inventory variants must be rejected. | Keep simple validator self-tests small.[^kotei] |
+| [Kiroku](mori://shinzui/kiroku), `bench/mp13-release/test-validation.py` | Twelve altered evidence fixtures must raise validator errors. | Evidence corruption is adjacent to mutation testing, with distinct domain assertions.[^kiroku] |
+| [MLS Reconciler](mori://tan/mls-reconciler), `scripts/test_chapter_sync.py` | Alters database state/schema and checks refusal without changing target snapshots. | External fault injection has fixture and side-effect obligations that workspace isolation alone cannot solve.[^mls] |
+
+Do not count every occurrence of “mutation” as program mutation testing. Database
+DML, application commands, schema-evolution fixtures, and adversarial inputs may
+test product behavior directly. They inform extension points but do not all
+justify migration to a shared runner.
+
+## Reusable contract and extraction recommendation
+
+The common abstraction is a case with a stable identity, deliberate change,
+passing baseline, selected execution, expected detection, and evidence. Shared
+infrastructure can own isolation, checked edits, stages, process budgets,
+classification, restoration, controls, and reports. Consumers should own fault
+meaning, exact diagnostic/test expectations, fixtures, and service setup.
+
+A CLI makes that infrastructure available to Python and shell callers; a Haskell
+API supports typed custom cases without requiring subprocess manifest generation.
+Both should call one runner and one classifier. Separate library and CLI packages
+keep command-line dependencies out of library consumers.
+
+The existing code is enough to justify an extraction prototype. It does not yet
+establish the best generic API or a numerical return on investment. Start with a
+checked replacement case, a patch catalogue case, a generated-code mutation, and
+an external checker diagnostic case. Compare before/after behavior, including
+deliberately broken infrastructure, empty selection, wrong diagnostics, and
+timeouts. Measure authoring effort and runtime separately.
+
+The likely savings are in repeated orchestration, maintenance, and failure
+diagnosis. Compilation, test runtime, fixture costs, and choosing meaningful
+faults remain. Automatic generation and remote scheduling can be added later
+when real consumers establish their contracts.
+
+## Public-package implications
+
+The initial public product should need only Cabal and its declared runtime
+tools, provide runnable examples for existing Haskell test suites, and explain
+the distinction between a survivor and an unverifiable run. A documented
+Haskell API, versioned interchange formats, complete reports on failure, and
+predictable CI exits are central to its usefulness.
+
+Release readiness includes successful builds from source archives, package
+checks, Haddock, verified examples, tested dependency bounds, a supported GHC/OS
+matrix, package metadata and licensing, and candidate review. These are design
+criteria informed by Cabal's package tooling and the PVP.[^cabal][^pvp]
+
+The [initial specification](../initial-spec.md) makes these proposed contracts
+explicit. It is a design artifact, not a claim that the package exists on
+Hackage or that its proposed commands already run.
+
+## Evidence fingerprints
+
+SHA-256 of the primary inspected files, allowing the source observations to be
+compared with later revisions:
+
+| Owning project | Relative path | SHA-256 |
+| --- | --- | --- |
+| [Nagare](mori://shinzui/nagare) | `cli/nagare-harness/src/Nagare/Harness/Mutation.hs` | `bb16987bb45e18b609bf1c7e5bbd2bb0b07ae9b67aef01507d97a8cec8485f6b` |
+| [Nagare](mori://shinzui/nagare) | `nix/test-runs.nix` | `650a318671a0ff31622455252e552722095e6dbe8042ee07a487cf29e2e3c658` |
+| [Nagare](mori://shinzui/nagare) | `cli/nagarectl/test/mutations/records.json` | `f412a0c4191d90e60c47716d222f26bd56473c22278ca993e40b56aeacbcdd63` |
+| [Keiro](mori://shinzui/keiro) | `keiro-dsl/test/mutation-test.sh` | `1aa082f1df2730db8087f005ea8c9b6077e5bc13ad3402e5f813ca90914aaa47` |
+| [Keiro](mori://shinzui/keiro) | `keiro-dsl/test/history-dependent-holes-mutation-test.sh` | `a7b25458f41c9c2953877b3d4c8d4b0f8d98e5ac4f4ab4522a51b9777b7c2a08` |
+| [Keiro](mori://shinzui/keiro) | `keiro-dsl/test/mapped-register-construction-mutation-test.sh` | `be7ff63c33580429f1e81b790055d17cf87b9d44a45fd73f043ecf2a5752a4f2` |
+| [rdkafka-hs](mori://shinzui/rdkafka-hs) | `scripts/test-mutations.hs` | `c4959158e199fad5d2148a90f6201ae679a4f153a2979ca05c58ff074dd790b6` |
+| [rdkafka-hs](mori://shinzui/rdkafka-hs) | `docs/testing/mutation-runner-parity.json` | `7a5f7a136c7d7ec761678cc511e2e9ecbbb671273cf677275ac2133de3b11349` |
+| [Shibuya](mori://shinzui/shibuya) | `scripts/audit/run-websocket-mutation-controls.py` | `f6201fe05cfbff1ca73cc9fff3e24ba2196a4631cc9825c8eca9e87f22634b55` |
+
+[^nagare]: Source files listed under the canonical Nagare project above; no sweep was executed.
+[^keiro]: Top-level script count and line count refreshed directly from the DSL test directory.
+[^rdkafka]: Runner source and the existing parity JSON; historical results were not rerun.
+[^shibuya]: Python runner source, inspected without executing its mutations.
+[^profiles]: Product-discovery mutation table, `check_rules`, and `check_mutations` inspected directly.
+[^kenshou]: `nonVacuityVerdict` and its targeted checker/fact pairs inspected directly.
+[^kikan]: Contract verifier test documentation and fixture-copy/mutate helpers inspected directly.
+[^keiki]: `regFileShapeSensitivitySpec` and its documented shape variations inspected directly.
+[^kotei]: `--self-test` mutation list and validator rejection loop inspected directly.
+[^kiroku]: `test_rejects_faults` fixture mutations inspected directly.
+[^mls]: SQL/schema fault loop and unchanged-target snapshot assertions inspected directly.
+[^cabal]: [Cabal command reference](https://cabal.readthedocs.io/en/stable/cabal-commands.html), consulted for the role of package checks, distributions, documentation, and uploads.
+[^pvp]: [Haskell Package Versioning Policy](https://pvp.haskell.org/), consulted for public API versioning.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,77 @@
+# Understanding results
+
+`caught` requires the declared exit/signal and witness after the validity gates.
+Generic diagnostic evidence has weaker attribution than a framework assertion.
+Inspect the case definition, exact witness and relative logs. Build rejection,
+explicit crash and controls are tallied separately from semantic detections.
+
+`survived` means the valid candidate passed without the expected detection.
+Strengthen the relevant assertion or review whether the deliberate fault tests
+the intended behavior. Heni does not automatically declare equivalence.
+
+`invalid-mutation` means an anchor/count, patch, creation/deletion precondition,
+or final byte-change requirement failed. Refresh the edit against the selected
+working tree. Multi-edit sequences see prior edits and must not undo themselves.
+Only an explicit unchanged control is allowed to change no bytes.
+
+`uncovered` means the intended named selection did not execute. Check exact test
+IDs, filter configuration and skipped tests. Unknown counts are not zero counts;
+generic commands cannot manufacture named-test evidence from stdout.
+
+`unexpected-build-failure` means a semantic candidate failed to build and never
+reached its assertion. Inspect compiler logs. If rejection itself is intentional,
+declare a build-rejection expectation with a precise nonempty diagnostic.
+
+`wrong-failure` means a different assertion, diagnostic, exit, signal or replay
+was observed. Fix attribution rather than accepting arbitrary command failure.
+Additional undeclared named assertion failures also invalidate a named detection.
+
+`timed-out` means a stage exceeded its wall-clock budget. Diagnose the process
+and resource state before increasing the budget. Output printed before a hang
+does not turn timeout into detection.
+
+`infrastructure-error` means launch, setup, protocol, output, observation,
+reporting or cleanup could not be trusted. Inspect primary and secondary errors;
+a named witness cannot override them. Command/environment metadata redacts
+secrets, but consumer commands should not emit credentials into retained logs.
+
+`not-run` accounts for selected work that could not execute, including failed
+baselines, cancellation and queued work. Baseline/control failures also appear
+at run level with affected IDs. Every selected ID must appear exactly once.
+
+For interrupted runs, inspect report.json or use `heni report DIRECTORY
+--recover`. Recovery reads evidence and never reruns commands. A truncated last
+journal record can be ignored; interior corruption, wrong sequence/schema and
+missing or escaping artifacts fail clearly. Move the whole directory with its
+logs to keep reports portable. Recovery is explicitly untrustworthy, so run a
+new campaign before relying on a passing CI result.
+
+Existing output directories are refused. Choose a new location. If output is
+inside the source tree, exclude it explicitly or use the default
+mutation-results name. Source symlinks and special files are unsupported in the
+first capture policy; select/exclude inputs explicitly instead of following
+them silently. Disposable workspaces protect the active checkout, while external
+service state and detached processes remain consumer responsibilities.
+
+For generated code, inspect the final build artifact and its generator input.
+The migration example changes input before generation; changing output before
+a regenerating build can erase the intended fault. A surviving marker change
+in the trial is deliberate and verifies the distinction.
+
+A required replay mismatch remains `wrong-failure`, including a replay timeout;
+the underlying Replay outcome and original failure are retained. Invalid
+QuickCheck tokens become skipped/pending reproductions and cannot count as
+caught. Multiple replay properties need a consumer-specific protocol rather
+than the initial one-property helper.
+
+For resource failures, inspect acquire/reset/release records and their command
+logs. Both primary and teardown errors matter. Check that baseline and candidate
+have equivalent identity/version/epoch and namespaced bindings. Worker capacities
+also limit fixture acquisition. Separate Heni processes require external locking.
+Unknown epochs disable baseline reuse; an unverified build observation or
+unexpected generated source retires retained artifacts and invalidates the case.
+
+`check` cannot prove an explicit `./...` build product already exists. It checks
+contained syntax and defers availability until after build. A missing or
+symlinked workspace program is a launch/infrastructure failure. Host tools are
+still checked before a campaign starts.
diff --git a/docs/validation/migration-measurements.json b/docs/validation/migration-measurements.json
new file mode 100644
--- /dev/null
+++ b/docs/validation/migration-measurements.json
@@ -0,0 +1,270 @@
+{
+  "operator": "automated Python observation of agent-authored fixtures; no human effort inference",
+  "method": "nonblank/noncomment physical lines inside named Legacy.hs regions; local wrapper line included; definitions subtracted",
+  "comparisonSharedOrchestrationLOC": 17,
+  "platform": {
+    "arch": "aarch64",
+    "compilerVersion": "9.12",
+    "os": "darwin"
+  },
+  "families": [
+    {
+      "family": "replacement",
+      "comparisonLocalOrchestrationLOC": 4,
+      "comparisonDefinitionLOC": 2,
+      "manifestLifecycleScaffoldingLOC": 0,
+      "retainedDomainBuildHelperLOC": 0,
+      "legacyIntendedCaught": true,
+      "legacyUnrelatedCompileAccepted": true,
+      "typedFreshSeconds": 100.067377,
+      "cliParallelReuseSeconds": 47.105884,
+      "wholeCampaignRepeatsPerCondition": 1,
+      "outcomes": [
+        "Caught",
+        "Survived",
+        "UnexpectedBuildFailure",
+        "Caught"
+      ],
+      "typedFresh": {
+        "baselineBuildInvocations": 4,
+        "baselineBuildTiming": {
+          "repeats": 4,
+          "minSeconds": 8.836197,
+          "maxSeconds": 9.027514,
+          "medianSeconds": 8.910768000000001
+        },
+        "sumBuildStageSeconds": 70.697988,
+        "sumAllStageSeconds": 100.009445
+      },
+      "cliParallelReuse": {
+        "baselineBuildInvocations": 2,
+        "baselineBuildTiming": {
+          "repeats": 2,
+          "minSeconds": 9.096244,
+          "maxSeconds": 9.131108,
+          "medianSeconds": 9.113676
+        },
+        "sumBuildStageSeconds": 52.13598,
+        "sumAllStageSeconds": 70.068589
+      },
+      "typedElapsedMinusStagesSeconds": 0.05793199999999388
+    },
+    {
+      "family": "patch-catalogue",
+      "comparisonLocalOrchestrationLOC": 4,
+      "comparisonDefinitionLOC": 2,
+      "manifestLifecycleScaffoldingLOC": 0,
+      "retainedDomainBuildHelperLOC": 0,
+      "legacyIntendedCaught": true,
+      "legacyUnrelatedCompileAccepted": null,
+      "typedFreshSeconds": 59.412925,
+      "cliParallelReuseSeconds": 30.661492,
+      "wholeCampaignRepeatsPerCondition": 1,
+      "outcomes": [
+        "Caught",
+        "Survived"
+      ],
+      "typedFresh": {
+        "baselineBuildInvocations": 2,
+        "baselineBuildTiming": {
+          "repeats": 2,
+          "minSeconds": 8.709212,
+          "maxSeconds": 9.054494,
+          "medianSeconds": 8.881853
+        },
+        "sumBuildStageSeconds": 35.634768,
+        "sumAllStageSeconds": 59.373957
+      },
+      "cliParallelReuse": {
+        "baselineBuildInvocations": 1,
+        "baselineBuildTiming": {
+          "repeats": 1,
+          "minSeconds": 8.835608,
+          "maxSeconds": 8.835608,
+          "medianSeconds": 8.835608
+        },
+        "sumBuildStageSeconds": 27.25745,
+        "sumAllStageSeconds": 46.281157
+      },
+      "typedElapsedMinusStagesSeconds": 0.03896800000000411
+    },
+    {
+      "family": "generated-code",
+      "comparisonLocalOrchestrationLOC": 5,
+      "comparisonDefinitionLOC": 2,
+      "manifestLifecycleScaffoldingLOC": 0,
+      "retainedDomainBuildHelperLOC": 16,
+      "legacyIntendedCaught": true,
+      "legacyUnrelatedCompileAccepted": null,
+      "typedFreshSeconds": 62.960805,
+      "cliParallelReuseSeconds": 31.536973,
+      "wholeCampaignRepeatsPerCondition": 1,
+      "outcomes": [
+        "Caught",
+        "Survived"
+      ],
+      "typedFresh": {
+        "baselineBuildInvocations": 2,
+        "baselineBuildTiming": {
+          "repeats": 2,
+          "minSeconds": 9.055222,
+          "maxSeconds": 9.373377,
+          "medianSeconds": 9.2142995
+        },
+        "sumBuildStageSeconds": 37.77156,
+        "sumAllStageSeconds": 62.877587
+      },
+      "cliParallelReuse": {
+        "baselineBuildInvocations": 1,
+        "baselineBuildTiming": {
+          "repeats": 1,
+          "minSeconds": 9.24417,
+          "maxSeconds": 9.24417,
+          "medianSeconds": 9.24417
+        },
+        "sumBuildStageSeconds": 28.935714,
+        "sumAllStageSeconds": 47.479472
+      },
+      "typedElapsedMinusStagesSeconds": 0.08321800000000223
+    },
+    {
+      "family": "diagnostic-checker",
+      "comparisonLocalOrchestrationLOC": 4,
+      "comparisonDefinitionLOC": 2,
+      "manifestLifecycleScaffoldingLOC": 0,
+      "retainedDomainBuildHelperLOC": 22,
+      "legacyIntendedCaught": true,
+      "legacyUnrelatedCompileAccepted": null,
+      "typedFreshSeconds": 4.973488,
+      "cliParallelReuseSeconds": 2.729233,
+      "wholeCampaignRepeatsPerCondition": 1,
+      "outcomes": [
+        "Caught",
+        "Survived"
+      ],
+      "typedFresh": {
+        "baselineBuildInvocations": 2,
+        "baselineBuildTiming": {
+          "repeats": 2,
+          "minSeconds": 0.864715,
+          "maxSeconds": 0.918225,
+          "medianSeconds": 0.89147
+        },
+        "sumBuildStageSeconds": 3.558678,
+        "sumAllStageSeconds": 4.954149
+      },
+      "cliParallelReuse": {
+        "baselineBuildInvocations": 1,
+        "baselineBuildTiming": {
+          "repeats": 1,
+          "minSeconds": 0.877249,
+          "maxSeconds": 0.877249,
+          "medianSeconds": 0.877249
+        },
+        "sumBuildStageSeconds": 2.775368,
+        "sumAllStageSeconds": 3.93278
+      },
+      "typedElapsedMinusStagesSeconds": 0.01933899999999955
+    }
+  ],
+  "authoring": [
+    {
+      "family": "replacement",
+      "templateFieldsChanged": [
+        "id",
+        "description",
+        "edits"
+      ],
+      "validationCommands": 1,
+      "testsExecutedDuringCheck": false,
+      "automatedSerializationAndCheck": {
+        "repeats": 3,
+        "minSeconds": 0.025291917379945517,
+        "maxSeconds": 0.05823345901444554,
+        "medianSeconds": 0.02542133303359151
+      }
+    },
+    {
+      "family": "patch-catalogue",
+      "templateFieldsChanged": [
+        "id",
+        "description",
+        "edits"
+      ],
+      "validationCommands": 1,
+      "testsExecutedDuringCheck": false,
+      "automatedSerializationAndCheck": {
+        "repeats": 3,
+        "minSeconds": 0.37392104091122746,
+        "maxSeconds": 0.40745075000450015,
+        "medianSeconds": 0.38921954203397036
+      }
+    },
+    {
+      "family": "generated-code",
+      "templateFieldsChanged": [
+        "id",
+        "description",
+        "edits"
+      ],
+      "validationCommands": 1,
+      "testsExecutedDuringCheck": false,
+      "automatedSerializationAndCheck": {
+        "repeats": 3,
+        "minSeconds": 0.024125791154801846,
+        "maxSeconds": 0.024874708615243435,
+        "medianSeconds": 0.024490791838616133
+      }
+    },
+    {
+      "family": "diagnostic-checker",
+      "templateFieldsChanged": [
+        "id",
+        "description",
+        "edits"
+      ],
+      "validationCommands": 1,
+      "testsExecutedDuringCheck": false,
+      "automatedSerializationAndCheck": {
+        "repeats": 3,
+        "minSeconds": 0.023509624879807234,
+        "maxSeconds": 0.024647582788020372,
+        "medianSeconds": 0.024002416990697384
+      }
+    }
+  ],
+  "diagnosticRuleMismatch": {
+    "verdict": "wrong-failure",
+    "expected": "policy.capacity: maximum must be positive",
+    "observed": "policy.timeout: duration must be positive",
+    "exit": 1
+  },
+  "diagnosis": {
+    "artifactReads": 2,
+    "expectedFailures": [
+      "suite/intended"
+    ],
+    "actualFailures": [
+      "suite/other"
+    ],
+    "automatedReadTime": {
+      "repeats": 5,
+      "minSeconds": 0.0006172088906168938,
+      "maxSeconds": 0.0029806247912347317,
+      "medianSeconds": 0.0006547500379383564
+    }
+  },
+  "reproduction": {
+    "commands": 1,
+    "case": "diagnostic-checker/invalid-capacity",
+    "exit": 0,
+    "verdict": "caught",
+    "seconds": 2.5890352916903794
+  },
+  "limits": [
+    "Whole-campaign variance is not estimated from one run per condition.",
+    "Fresh library and parallel/reuse CLI conditions differ.",
+    "Elapsed-minus-stages includes copies, edits and report IO; it is not isolated CPU overhead.",
+    "Authoring/diagnosis timings exclude human reasoning."
+  ]
+}
diff --git a/docs/validation/migration-results.md b/docs/validation/migration-results.md
new file mode 100644
--- /dev/null
+++ b/docs/validation/migration-results.md
@@ -0,0 +1,111 @@
+# Representative migration evidence
+
+Accepted local trials on 2026-10-24 used Apple M1 Max/aarch64 macOS,
+GHC 9.12.4 and Cabal 3.16.1.0, with a warm installed dependency cache and fresh
+case workspaces. All four independently authored families ran through public
+Haskell definitions and CLI manifests. The generated Haskell case used a custom
+checked transformation; its manifest equivalent used replacement. No surveyed
+consumer repository or service was modified or executed.
+
+Reproduce with `sh scripts/test-migrations.sh`. The script explicitly enables
+cross-package tests and supplies a separately built CLI. It retains reports and
+logs under `.heni-runs/heni-migration-*`, including failed attempts. Condensed
+observations are in [migration-measurements.json](migration-measurements.json);
+[source identities](migration-source-identities.json) distinguish the refreshed
+files from the historical RES-1 snapshots. The current rdkafka runner is 453
+lines; the historical research described a different 349-line revision.
+
+Every family produced Caught and Survived with matching typed/CLI meaning and
+identical source snapshot identities. Replacement additionally produced
+UnexpectedBuildFailure for an unrelated compiler error, and Caught only when
+that rejection was explicitly declared with its compiler diagnostic. The
+exit-only comparison actually accepted the unrelated compiler failure, proving
+the attribution improvement. The external checker also rejected a timeout-rule
+error as WrongFailure when the case required a capacity-rule diagnostic.
+
+The combined trial matched wrong named assertion, known zero selection, stale
+edit, test hang, failed setup, broken teardown, replay, fresh native crash and
+unchanged control outcomes. Reports with a stale schema were rejected. The
+real native QuickCheck counterexample integration remains in the optional
+Hspec package's accepted test, while the combined core trial exercises its
+framework-neutral replay channel. All selected IDs were accounted for. The
+process and scheduler suites separately prove cancellation, worker isolation,
+resource capacities, cleanup and post-teardown final progress events.
+
+An initial combined run conservatively reported infrastructure failure during a
+serial timeout. Failure retention was corrected, and subsequent regressions
+identified a macOS group-exit race: killpg briefly returned EPERM before ESRCH.
+An independent 100-iteration probe reproduced the transition. The supervisor
+now polls within its existing shutdown bounds and clears provisional errors
+only after definite disappearance; persistent errors still invalidate evidence.
+The focused process suite includes twelve short-timeout repetitions, and the
+adapter suite verifies retained replay-timeout evidence. The accepted full
+migration run and its retained artifacts remain valid; no failed timeout was
+counted as detection.
+
+The following counts describe only the independently authored comparison
+harness, not removable code in production projects. A further 17 shared
+nonblank/noncomment lines implement copying, scoped disposal and command
+capture. Per-family orchestration counts exclude the two definition lines and
+include the local wrapper. Manifest consumers add zero lifecycle-scaffolding
+lines; command configuration, edits and witnesses remain necessary data.
+Generator/checker build helpers remain consumer-owned in both paths.
+
+| Family | Comparison orchestration LOC | Definition LOC | Retained domain/build helper LOC | Typed fresh seconds | CLI parallel/reuse seconds | Baseline builds, fresh / reuse |
+| --- | --- | --- | --- | --- | --- | --- |
+| replacement | 4 | 2 | 0 | 100.067 | 47.106 | 4 / 2 |
+| patch-catalogue | 4 | 2 | 0 | 59.413 | 30.661 | 2 / 1 |
+| generated-code | 5 | 2 | 16 | 62.961 | 31.537 | 2 / 1 |
+| diagnostic-checker | 4 | 2 | 22 | 4.973 | 2.729 | 2 / 1 |
+
+The library conditions use one worker and fresh baselines; CLI conditions use
+two workers and compatible baseline sharing. They preserve meaning but combine
+multiple performance changes. There is one accepted whole-campaign timing per
+condition, so whole-campaign variance is not estimated. Unchanged baseline
+builds repeat within the fresh campaigns; their ranges and the sum of build
+stage times are recorded in the JSON. The controlled Cabal workload in
+[the scheduling evidence](../design/scheduling.md) has two repeats per condition
+and reports its variation independently. These data do not predict universal
+speedups.
+
+Fresh library elapsed time minus the sum of supervised stage durations was
+0.019–0.083s across these tiny fixtures. This difference includes source copies,
+checks, custom edits, reporting IO and timing granularity; it is not isolated
+runner CPU time. Build/test work dominates these examples, while stronger
+failure attribution is independently observable.
+
+An automated template-addition trial changed three case fields (ID, description
+and edits), serialized one manifest and ran one `check` command. Each family
+was repeated three times; `check` confirmed it executed no tests. The figures
+below measure automation plus process/static-check latency, excluding human
+fault selection or reasoning.
+
+| Family | Median automated addition/check ms | Range ms |
+| --- | --- | --- |
+| replacement | 25.42 | 25.29–58.23 |
+| patch-catalogue | 389.22 | 373.92–407.45 |
+| generated-code | 24.49 | 24.13–24.87 |
+| diagnostic-checker | 24.00 | 23.51–24.65 |
+
+Diagnosis required two artifact reads: report expectation/actual IDs and the
+dedicated test protocol. It identified expected `suite/intended` versus actual
+`suite/other`. Five automated reads took a median of
+0.655ms; no human diagnosis
+time is inferred. One CLI command reproduced the checker Caught result with
+exit 0 in 2.589s.
+
+To repeat the observations after a complete trial:
+
+```bash
+python3 scripts/measure-migration.py --results .heni-runs/migration-results.json --cli /path/from/cabal-list-bin/heni --output docs/validation/migration-measurements.json
+```
+
+The API review found concrete case data, checked custom transformations,
+framework-neutral evidence, explicit resource scopes and redacted reports
+sufficient for these four families. It exposed and fixed workspace-built
+executable preflight and post-teardown progress ordering. It did not establish
+support for binary mutation, arbitrary generated-output ordering, all property
+frameworks, database-state rollback or unverified incremental build tools.
+Those limits and later consumer migration batches are explicit in
+[the guide](../migration-guide.md). No human usability study, fleet migration,
+Hackage candidate review or publication is claimed.
diff --git a/docs/validation/migration-source-identities.json b/docs/validation/migration-source-identities.json
new file mode 100644
--- /dev/null
+++ b/docs/validation/migration-source-identities.json
@@ -0,0 +1,56 @@
+[
+  {
+    "owner": "mori://shinzui/rdkafka-hs/packages/rdkafka-hs",
+    "head": "2917f3af396dd1536944951a65946ada87ceb874",
+    "files": [
+      {
+        "path": "scripts/test-mutations.hs",
+        "sha256": "bc1ff5f598a122928e187f30b4c929cfa805f79133c086fb7ad288d5c1888b5b",
+        "lines": 453
+      }
+    ]
+  },
+  {
+    "owner": "mori://shinzui/nagare",
+    "head": "4c99ffe3df84af1f16a2161944ac790197dd63fa",
+    "files": [
+      {
+        "path": "cli/nagare-harness/src/Nagare/Harness/Mutation.hs",
+        "sha256": "bb16987bb45e18b609bf1c7e5bbd2bb0b07ae9b67aef01507d97a8cec8485f6b",
+        "lines": 315
+      },
+      {
+        "path": "cli/nagarectl/test/mutations/records.json",
+        "sha256": "f412a0c4191d90e60c47716d222f26bd56473c22278ca993e40b56aeacbcdd63",
+        "lines": 1136
+      }
+    ]
+  },
+  {
+    "owner": "mori://shinzui/keiro/packages/keiro-dsl",
+    "head": "e6ef560b0a8db0dc091d0f8d7da1bb08c29155be",
+    "files": [
+      {
+        "path": "keiro-dsl/test/mutation-test.sh",
+        "sha256": "1aa082f1df2730db8087f005ea8c9b6077e5bc13ad3402e5f813ca90914aaa47",
+        "lines": 37
+      },
+      {
+        "path": "keiro-dsl/test/history-dependent-holes-mutation-test.sh",
+        "sha256": "a7b25458f41c9c2953877b3d4c8d4b0f8d98e5ac4f4ab4522a51b9777b7c2a08",
+        "lines": 85
+      }
+    ]
+  },
+  {
+    "owner": "mori://shinzui/shibuya",
+    "head": "0967f450b659b48d24cb49f199718451a65d2d48",
+    "files": [
+      {
+        "path": "scripts/audit/run-websocket-mutation-controls.py",
+        "sha256": "f6201fe05cfbff1ca73cc9fff3e24ba2196a4631cc9825c8eca9e87f22634b55",
+        "lines": 75
+      }
+    ]
+  }
+]
diff --git a/examples/adapters/README.md b/examples/adapters/README.md
new file mode 100644
--- /dev/null
+++ b/examples/adapters/README.md
@@ -0,0 +1,8 @@
+# Framework examples
+
+The fixture executables are built in the optional packages. Add their executable
+directories to PATH (obtain paths using `cabal list-bin heni-tasty-fixture` and
+`cabal list-bin heni-hspec-fixture`), then run `heni run examples/adapters/tasty.json`,
+`heni run examples/adapters/hspec.json`, or `heni run examples/adapters/property.json`.
+The fixtures use the same reporter installation a consumer test main uses.
+See [adapter contracts](../../docs/design/adapters.md) for identity and selector rules.
diff --git a/examples/adapters/hspec.json b/examples/adapters/hspec.json
new file mode 100644
--- /dev/null
+++ b/examples/adapters/hspec.json
@@ -0,0 +1,38 @@
+{
+  "schemaVersion": 1,
+  "sourceRoot": ".",
+  "commands": {
+    "build": {
+      "executable": "heni-hspec-fixture",
+      "arguments": [
+        "--heni-build"
+      ]
+    },
+    "test": {
+      "executable": "heni-hspec-fixture"
+    }
+  },
+  "cases": [
+    {
+      "id": "named",
+      "description": "Detect the intended assertion",
+      "adapter": "heni-v1",
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "input",
+          "old": "anchor",
+          "new": "intended"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "named-tests",
+        "failures": [
+          "suite/intended"
+        ]
+      }
+    }
+  ]
+}
diff --git a/examples/adapters/input b/examples/adapters/input
new file mode 100644
--- /dev/null
+++ b/examples/adapters/input
@@ -0,0 +1,1 @@
+anchor
diff --git a/examples/adapters/property.json b/examples/adapters/property.json
new file mode 100644
--- /dev/null
+++ b/examples/adapters/property.json
@@ -0,0 +1,40 @@
+{
+  "schemaVersion": 1,
+  "sourceRoot": ".",
+  "commands": {
+    "build": {
+      "executable": "heni-hspec-fixture",
+      "arguments": [
+        "--heni-build"
+      ]
+    },
+    "test": {
+      "executable": "heni-hspec-fixture"
+    }
+  },
+  "cases": [
+    {
+      "id": "property",
+      "description": "Detect the intended assertion",
+      "adapter": "heni-v1",
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "input",
+          "old": "anchor",
+          "new": "property"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "named-tests",
+        "failures": [
+          "suite/property"
+        ],
+        "replayRequired": true
+      },
+      "selector": "property"
+    }
+  ]
+}
diff --git a/examples/adapters/tasty.json b/examples/adapters/tasty.json
new file mode 100644
--- /dev/null
+++ b/examples/adapters/tasty.json
@@ -0,0 +1,38 @@
+{
+  "schemaVersion": 1,
+  "sourceRoot": ".",
+  "commands": {
+    "build": {
+      "executable": "heni-tasty-fixture",
+      "arguments": [
+        "--heni-build"
+      ]
+    },
+    "test": {
+      "executable": "heni-tasty-fixture"
+    }
+  },
+  "cases": [
+    {
+      "id": "named",
+      "description": "Detect the intended assertion",
+      "adapter": "heni-v1",
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "input",
+          "old": "anchor",
+          "new": "intended"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "named-tests",
+        "failures": [
+          "suite/intended"
+        ]
+      }
+    }
+  ]
+}
diff --git a/examples/library/Definitions.hs b/examples/library/Definitions.hs
new file mode 100644
--- /dev/null
+++ b/examples/library/Definitions.hs
@@ -0,0 +1,33 @@
+module Definitions (exampleCampaign) where
+
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Test.Heni
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+exampleCampaign :: Campaign
+exampleCampaign = checked (mkCampaign commands [(c, noHooks) | c <- cases])
+  where
+    commandId = checked . mkCommandId
+    budget = checked (mkStageBudget 60000000)
+    command args = Command "cabal" args Set.empty Nothing [] budget (OutputPolicy 1048576)
+    commands =
+      Map.fromList
+        [ (commandId "build", command ["build", "fixture-test", "--offline", "-j1"]),
+          (commandId "test", command ["test", "fixture-test", "--offline", "-j1", "--test-show-details=direct"])
+        ]
+    target = checked (mkRelativeTarget "src/Capacity.hs")
+    define ident old new =
+      CaseDefinition
+        (checked (mkCaseId ident))
+        "The capacity boundary assertion must detect a removed guard."
+        (Set.singleton "regression")
+        [Replace target old new 1]
+        (commandId "build")
+        (Just (commandId "test"))
+        Nothing
+        (Diagnostic Test 1 "rejects full capacity: FAIL")
+        GenericCommands
+    cases = [define "guard-bypass" "used < limit" "True", define "survivor" "baseline" "mutant", define "compile-error" "used < limit" "missingCapacityGuard"]
diff --git a/examples/library/Main.hs b/examples/library/Main.hs
new file mode 100644
--- /dev/null
+++ b/examples/library/Main.hs
@@ -0,0 +1,12 @@
+module Main (main) where
+
+import Definitions (exampleCampaign)
+import System.Environment (getArgs)
+import Test.Heni
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let output = case args of ["--output", path] -> path; _ -> ".heni-runs/library-smoke"
+  result <- runCampaign (defaultRunOptions "examples/minimal" output) exampleCampaign
+  print result
diff --git a/examples/migrations/README.md b/examples/migrations/README.md
new file mode 100644
--- /dev/null
+++ b/examples/migrations/README.md
@@ -0,0 +1,11 @@
+# Representative migrations
+
+Each family has a one-case default heni.json, a passing unchanged target and a
+precise intended witness. Replacement and patch-catalogue use a standalone Cabal
+capacity assertion. Generated-code changes generator input before compilation.
+Diagnostic-checker compiles and executes its own workspace-local validator.
+
+All files are independently authored BSD-3-Clause examples. Read
+[the migration guide](../../docs/migration-guide.md) for commands, surviving and
+adversarial variants, capability boundaries and the measured comparison method.
+The integration trial is `sh scripts/test-migrations.sh` from the repository root.
diff --git a/examples/migrations/diagnostic-checker/LICENSE b/examples/migrations/diagnostic-checker/LICENSE
new file mode 100644
--- /dev/null
+++ b/examples/migrations/diagnostic-checker/LICENSE
@@ -0,0 +1,27 @@
+BSD 3-Clause License
+
+Copyright (c) 2026, Nadeem Bitar
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors
+   may be used to endorse or promote products derived from this software
+   without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/examples/migrations/diagnostic-checker/heni.json b/examples/migrations/diagnostic-checker/heni.json
new file mode 100644
--- /dev/null
+++ b/examples/migrations/diagnostic-checker/heni.json
@@ -0,0 +1,38 @@
+{
+  "schemaVersion": 1,
+  "commands": {
+    "build": {
+      "executable": "runghc",
+      "arguments": [
+        "tools/Build.hs"
+      ],
+      "timeoutSeconds": 30
+    },
+    "test": {
+      "executable": "./dist-newstyle/checker",
+      "timeoutSeconds": 5
+    }
+  },
+  "cases": [
+    {
+      "id": "invalid-capacity",
+      "description": "The external checker must identify the exact capacity rule.",
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "policy.txt",
+          "old": "capacity=10",
+          "new": "capacity=-1"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "policy.capacity: maximum must be positive"
+      }
+    }
+  ]
+}
diff --git a/examples/migrations/diagnostic-checker/policy.txt b/examples/migrations/diagnostic-checker/policy.txt
new file mode 100644
--- /dev/null
+++ b/examples/migrations/diagnostic-checker/policy.txt
@@ -0,0 +1,3 @@
+capacity=10
+timeout=30
+label=baseline
diff --git a/examples/migrations/diagnostic-checker/tools/Build.hs b/examples/migrations/diagnostic-checker/tools/Build.hs
new file mode 100644
--- /dev/null
+++ b/examples/migrations/diagnostic-checker/tools/Build.hs
@@ -0,0 +1,10 @@
+module Main (main) where
+
+import System.Directory (createDirectoryIfMissing)
+import System.Exit (exitWith)
+import System.Process (rawSystem)
+
+main :: IO ()
+main = do
+  createDirectoryIfMissing True "dist-newstyle"
+  rawSystem "ghc" ["-O0", "-outputdir", "dist-newstyle", "-o", "dist-newstyle/checker", "tools/Check.hs"] >>= exitWith
diff --git a/examples/migrations/diagnostic-checker/tools/Check.hs b/examples/migrations/diagnostic-checker/tools/Check.hs
new file mode 100644
--- /dev/null
+++ b/examples/migrations/diagnostic-checker/tools/Check.hs
@@ -0,0 +1,16 @@
+module Main (main) where
+
+import Data.List (isInfixOf)
+import System.Exit (ExitCode (..), exitWith)
+
+main :: IO ()
+main = do
+  input <- readFile "policy.txt"
+  if "capacity=-1" `isInfixOf` input
+    then reject "policy.capacity: maximum must be positive"
+    else
+      if "timeout=-1" `isInfixOf` input
+        then reject "policy.timeout: duration must be positive"
+        else putStrLn "policy: PASS"
+  where
+    reject why = putStrLn why >> exitWith (ExitFailure 1)
diff --git a/examples/migrations/generated-code/LICENSE b/examples/migrations/generated-code/LICENSE
new file mode 100644
--- /dev/null
+++ b/examples/migrations/generated-code/LICENSE
@@ -0,0 +1,27 @@
+BSD 3-Clause License
+
+Copyright (c) 2026, Nadeem Bitar
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors
+   may be used to endorse or promote products derived from this software
+   without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/examples/migrations/generated-code/cabal.project b/examples/migrations/generated-code/cabal.project
new file mode 100644
--- /dev/null
+++ b/examples/migrations/generated-code/cabal.project
@@ -0,0 +1,1 @@
+packages: .
diff --git a/examples/migrations/generated-code/generated-fixture.cabal b/examples/migrations/generated-code/generated-fixture.cabal
new file mode 100644
--- /dev/null
+++ b/examples/migrations/generated-code/generated-fixture.cabal
@@ -0,0 +1,16 @@
+cabal-version: 3.4
+name: heni-generated-fixture
+version: 0.1.0.0
+build-type: Simple
+license: BSD-3-Clause
+
+test-suite fixture-test
+  type: exitcode-stdio-1.0
+  default-language: GHC2024
+  hs-source-dirs:
+    test
+    generated
+
+  main-is: Main.hs
+  other-modules: Generated
+  build-depends: base >=4.21.2 && <4.22
diff --git a/examples/migrations/generated-code/heni.json b/examples/migrations/generated-code/heni.json
new file mode 100644
--- /dev/null
+++ b/examples/migrations/generated-code/heni.json
@@ -0,0 +1,45 @@
+{
+  "schemaVersion": 1,
+  "commands": {
+    "build": {
+      "executable": "runghc",
+      "arguments": [
+        "tools/Build.hs"
+      ],
+      "timeoutSeconds": 60
+    },
+    "test": {
+      "executable": "cabal",
+      "arguments": [
+        "test",
+        "fixture-test",
+        "--offline",
+        "-j1",
+        "--test-show-details=direct"
+      ],
+      "timeoutSeconds": 30
+    }
+  },
+  "cases": [
+    {
+      "id": "generated-guard",
+      "description": "Mutate generator input before generation so the compiled guard is removed.",
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "model.txt",
+          "old": "mode=strict",
+          "new": "mode=open"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "generated capacity: FAIL"
+      }
+    }
+  ]
+}
diff --git a/examples/migrations/generated-code/model.txt b/examples/migrations/generated-code/model.txt
new file mode 100644
--- /dev/null
+++ b/examples/migrations/generated-code/model.txt
@@ -0,0 +1,2 @@
+mode=strict
+label=baseline
diff --git a/examples/migrations/generated-code/test/Main.hs b/examples/migrations/generated-code/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/examples/migrations/generated-code/test/Main.hs
@@ -0,0 +1,7 @@
+module Main (main) where
+
+import Generated
+import System.Exit (exitFailure)
+
+main :: IO ()
+main = if not (admits 10 10) then putStrLn "generated capacity: PASS" else putStrLn "generated capacity: FAIL" >> exitFailure
diff --git a/examples/migrations/generated-code/tools/Build.hs b/examples/migrations/generated-code/tools/Build.hs
new file mode 100644
--- /dev/null
+++ b/examples/migrations/generated-code/tools/Build.hs
@@ -0,0 +1,18 @@
+module Main (main) where
+
+import Data.List (isInfixOf)
+import System.Directory (createDirectoryIfMissing)
+import System.Exit (exitWith)
+import System.IO (hFlush, stdout)
+import System.Process (rawSystem)
+
+main :: IO ()
+main = do
+  model <- readFile "model.txt"
+  let guard = if "mode=strict" `isInfixOf` model then "used < limit" else "True"
+      generated = "module Generated (admits) where\nadmits :: Int -> Int -> Bool\nadmits used limit = " ++ guard ++ "\n"
+  createDirectoryIfMissing True "generated"
+  writeFile "generated/Generated.hs" generated
+  putStrLn ("compiled generator input: " ++ show model ++ "; artifact: admits used limit = " ++ guard)
+  hFlush stdout
+  rawSystem "cabal" ["build", "fixture-test", "--offline", "-j1"] >>= exitWith
diff --git a/examples/migrations/patch-catalogue/LICENSE b/examples/migrations/patch-catalogue/LICENSE
new file mode 100644
--- /dev/null
+++ b/examples/migrations/patch-catalogue/LICENSE
@@ -0,0 +1,27 @@
+BSD 3-Clause License
+
+Copyright (c) 2026, Nadeem Bitar
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors
+   may be used to endorse or promote products derived from this software
+   without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/examples/migrations/patch-catalogue/cabal.project b/examples/migrations/patch-catalogue/cabal.project
new file mode 100644
--- /dev/null
+++ b/examples/migrations/patch-catalogue/cabal.project
@@ -0,0 +1,1 @@
+packages: .
diff --git a/examples/migrations/patch-catalogue/heni-minimal-fixture.cabal b/examples/migrations/patch-catalogue/heni-minimal-fixture.cabal
new file mode 100644
--- /dev/null
+++ b/examples/migrations/patch-catalogue/heni-minimal-fixture.cabal
@@ -0,0 +1,17 @@
+cabal-version: 3.4
+name: heni-minimal-fixture
+version: 0.1.0.0
+build-type: Simple
+license: BSD-3-Clause
+synopsis: Standalone guard fixture for Heni
+
+test-suite fixture-test
+  type: exitcode-stdio-1.0
+  default-language: GHC2024
+  hs-source-dirs:
+    src
+    test
+
+  main-is: Main.hs
+  other-modules: Capacity
+  build-depends: base >=4.21 && <5
diff --git a/examples/migrations/patch-catalogue/heni.json b/examples/migrations/patch-catalogue/heni.json
new file mode 100644
--- /dev/null
+++ b/examples/migrations/patch-catalogue/heni.json
@@ -0,0 +1,49 @@
+{
+  "schemaVersion": 1,
+  "commands": {
+    "build": {
+      "executable": "cabal",
+      "arguments": [
+        "build",
+        "fixture-test",
+        "--offline",
+        "-j1"
+      ],
+      "timeoutSeconds": 60
+    },
+    "test": {
+      "executable": "cabal",
+      "arguments": [
+        "test",
+        "fixture-test",
+        "--offline",
+        "--test-show-details=direct",
+        "-j1"
+      ],
+      "timeoutSeconds": 60
+    }
+  },
+  "cases": [
+    {
+      "id": "guard-bypass",
+      "description": "Removing the capacity guard must fail the full-capacity assertion.",
+      "tags": [
+        "regression"
+      ],
+      "edits": [
+        {
+          "kind": "patch",
+          "content": "diff --git a/src/Capacity.hs b/src/Capacity.hs\n--- a/src/Capacity.hs\n+++ b/src/Capacity.hs\n@@ -1,7 +1,7 @@\n module Capacity (admits, marker) where\n \n admits :: Int -> Int -> Bool\n-admits used limit = used < limit\n+admits used limit = True\n \n marker :: String\n marker = \"baseline\"\n"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "rejects full capacity: FAIL"
+      }
+    }
+  ]
+}
diff --git a/examples/migrations/patch-catalogue/patches/guard-bypass.patch b/examples/migrations/patch-catalogue/patches/guard-bypass.patch
new file mode 100644
--- /dev/null
+++ b/examples/migrations/patch-catalogue/patches/guard-bypass.patch
@@ -0,0 +1,12 @@
+diff --git a/src/Capacity.hs b/src/Capacity.hs
+--- a/src/Capacity.hs
++++ b/src/Capacity.hs
+@@ -1,7 +1,7 @@
+ module Capacity (admits, marker) where
+ 
+ admits :: Int -> Int -> Bool
+-admits used limit = used < limit
++admits used limit = True
+ 
+ marker :: String
+ marker = "baseline"
diff --git a/examples/migrations/patch-catalogue/patches/survivor.patch b/examples/migrations/patch-catalogue/patches/survivor.patch
new file mode 100644
--- /dev/null
+++ b/examples/migrations/patch-catalogue/patches/survivor.patch
@@ -0,0 +1,12 @@
+diff --git a/src/Capacity.hs b/src/Capacity.hs
+--- a/src/Capacity.hs
++++ b/src/Capacity.hs
+@@ -1,7 +1,7 @@
+ module Capacity (admits, marker) where
+ 
+ admits :: Int -> Int -> Bool
+ admits used limit = used < limit
+ 
+ marker :: String
+-marker = "baseline"
++marker = "variant"
diff --git a/examples/migrations/patch-catalogue/src/Capacity.hs b/examples/migrations/patch-catalogue/src/Capacity.hs
new file mode 100644
--- /dev/null
+++ b/examples/migrations/patch-catalogue/src/Capacity.hs
@@ -0,0 +1,7 @@
+module Capacity (admits, marker) where
+
+admits :: Int -> Int -> Bool
+admits used limit = used < limit
+
+marker :: String
+marker = "baseline"
diff --git a/examples/migrations/patch-catalogue/test/Main.hs b/examples/migrations/patch-catalogue/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/examples/migrations/patch-catalogue/test/Main.hs
@@ -0,0 +1,15 @@
+module Main (main) where
+
+import Capacity
+import Control.Monad (unless)
+import System.Exit (exitFailure)
+
+main :: IO ()
+main = do
+  unless (not (admits 1 1)) $ do
+    putStrLn "rejects full capacity: FAIL"
+    exitFailure
+  unless (admits 0 1) $ do
+    putStrLn "admits spare capacity: FAIL"
+    exitFailure
+  putStrLn ("capacity assertions: PASS (" ++ marker ++ ")")
diff --git a/examples/migrations/replacement/LICENSE b/examples/migrations/replacement/LICENSE
new file mode 100644
--- /dev/null
+++ b/examples/migrations/replacement/LICENSE
@@ -0,0 +1,27 @@
+BSD 3-Clause License
+
+Copyright (c) 2026, Nadeem Bitar
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors
+   may be used to endorse or promote products derived from this software
+   without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/examples/migrations/replacement/cabal.project b/examples/migrations/replacement/cabal.project
new file mode 100644
--- /dev/null
+++ b/examples/migrations/replacement/cabal.project
@@ -0,0 +1,1 @@
+packages: .
diff --git a/examples/migrations/replacement/heni-minimal-fixture.cabal b/examples/migrations/replacement/heni-minimal-fixture.cabal
new file mode 100644
--- /dev/null
+++ b/examples/migrations/replacement/heni-minimal-fixture.cabal
@@ -0,0 +1,17 @@
+cabal-version: 3.4
+name: heni-minimal-fixture
+version: 0.1.0.0
+build-type: Simple
+license: BSD-3-Clause
+synopsis: Standalone guard fixture for Heni
+
+test-suite fixture-test
+  type: exitcode-stdio-1.0
+  default-language: GHC2024
+  hs-source-dirs:
+    src
+    test
+
+  main-is: Main.hs
+  other-modules: Capacity
+  build-depends: base >=4.21 && <5
diff --git a/examples/migrations/replacement/heni.json b/examples/migrations/replacement/heni.json
new file mode 100644
--- /dev/null
+++ b/examples/migrations/replacement/heni.json
@@ -0,0 +1,51 @@
+{
+  "schemaVersion": 1,
+  "commands": {
+    "build": {
+      "executable": "cabal",
+      "arguments": [
+        "build",
+        "fixture-test",
+        "--offline",
+        "-j1"
+      ],
+      "timeoutSeconds": 60
+    },
+    "test": {
+      "executable": "cabal",
+      "arguments": [
+        "test",
+        "fixture-test",
+        "--offline",
+        "--test-show-details=direct",
+        "-j1"
+      ],
+      "timeoutSeconds": 60
+    }
+  },
+  "cases": [
+    {
+      "id": "guard-bypass",
+      "description": "Removing the capacity guard must fail the full-capacity assertion.",
+      "tags": [
+        "regression"
+      ],
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "src/Capacity.hs",
+          "old": "used < limit",
+          "new": "True"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "rejects full capacity: FAIL"
+      }
+    }
+  ]
+}
diff --git a/examples/migrations/replacement/src/Capacity.hs b/examples/migrations/replacement/src/Capacity.hs
new file mode 100644
--- /dev/null
+++ b/examples/migrations/replacement/src/Capacity.hs
@@ -0,0 +1,7 @@
+module Capacity (admits, marker) where
+
+admits :: Int -> Int -> Bool
+admits used limit = used < limit
+
+marker :: String
+marker = "baseline"
diff --git a/examples/migrations/replacement/test/Main.hs b/examples/migrations/replacement/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/examples/migrations/replacement/test/Main.hs
@@ -0,0 +1,15 @@
+module Main (main) where
+
+import Capacity
+import Control.Monad (unless)
+import System.Exit (exitFailure)
+
+main :: IO ()
+main = do
+  unless (not (admits 1 1)) $ do
+    putStrLn "rejects full capacity: FAIL"
+    exitFailure
+  unless (admits 0 1) $ do
+    putStrLn "admits spare capacity: FAIL"
+    exitFailure
+  putStrLn ("capacity assertions: PASS (" ++ marker ++ ")")
diff --git a/examples/minimal/cabal.project b/examples/minimal/cabal.project
new file mode 100644
--- /dev/null
+++ b/examples/minimal/cabal.project
@@ -0,0 +1,1 @@
+packages: .
diff --git a/examples/minimal/heni-minimal-fixture.cabal b/examples/minimal/heni-minimal-fixture.cabal
new file mode 100644
--- /dev/null
+++ b/examples/minimal/heni-minimal-fixture.cabal
@@ -0,0 +1,17 @@
+cabal-version: 3.4
+name: heni-minimal-fixture
+version: 0.1.0.0
+build-type: Simple
+license: BSD-3-Clause
+synopsis: Standalone guard fixture for Heni
+
+test-suite fixture-test
+  type: exitcode-stdio-1.0
+  default-language: GHC2024
+  hs-source-dirs:
+    src
+    test
+
+  main-is: Main.hs
+  other-modules: Capacity
+  build-depends: base >=4.21 && <5
diff --git a/examples/minimal/heni.json b/examples/minimal/heni.json
new file mode 100644
--- /dev/null
+++ b/examples/minimal/heni.json
@@ -0,0 +1,97 @@
+{
+  "schemaVersion": 1,
+  "commands": {
+    "build": {
+      "executable": "cabal",
+      "arguments": [
+        "build",
+        "fixture-test",
+        "--offline",
+        "-j1"
+      ],
+      "timeoutSeconds": 60
+    },
+    "test": {
+      "executable": "cabal",
+      "arguments": [
+        "test",
+        "fixture-test",
+        "--offline",
+        "--test-show-details=direct",
+        "-j1"
+      ],
+      "timeoutSeconds": 60
+    }
+  },
+  "cases": [
+    {
+      "id": "guard-bypass",
+      "description": "The capacity assertion must detect removal of the guard.",
+      "tags": [
+        "regression"
+      ],
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "src/Capacity.hs",
+          "old": "used < limit",
+          "new": "True"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "rejects full capacity: FAIL"
+      }
+    },
+    {
+      "id": "survivor",
+      "description": "A harmless marker edit demonstrates a surviving mutation.",
+      "tags": [
+        "example"
+      ],
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "src/Capacity.hs",
+          "old": "baseline",
+          "new": "mutant"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "rejects full capacity: FAIL"
+      }
+    },
+    {
+      "id": "compile-error",
+      "description": "An unrelated compile failure cannot prove semantic detection.",
+      "tags": [
+        "example"
+      ],
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "src/Capacity.hs",
+          "old": "used < limit",
+          "new": "missingCapacityGuard"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "rejects full capacity: FAIL"
+      }
+    }
+  ]
+}
diff --git a/examples/minimal/src/Capacity.hs b/examples/minimal/src/Capacity.hs
new file mode 100644
--- /dev/null
+++ b/examples/minimal/src/Capacity.hs
@@ -0,0 +1,7 @@
+module Capacity (admits, marker) where
+
+admits :: Int -> Int -> Bool
+admits used limit = used < limit
+
+marker :: String
+marker = "baseline"
diff --git a/examples/minimal/test/Main.hs b/examples/minimal/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/examples/minimal/test/Main.hs
@@ -0,0 +1,15 @@
+module Main (main) where
+
+import Capacity
+import Control.Monad (unless)
+import System.Exit (exitFailure)
+
+main :: IO ()
+main = do
+  unless (not (admits 1 1)) $ do
+    putStrLn "rejects full capacity: FAIL"
+    exitFailure
+  unless (admits 0 1) $ do
+    putStrLn "admits spare capacity: FAIL"
+    exitFailure
+  putStrLn ("capacity assertions: PASS (" ++ marker ++ ")")
diff --git a/examples/standalone/Main.hs b/examples/standalone/Main.hs
new file mode 100644
--- /dev/null
+++ b/examples/standalone/Main.hs
@@ -0,0 +1,22 @@
+module Main (main) where
+
+import Control.Monad (unless)
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Test.Heni
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+main :: IO ()
+main = do
+  let build = checked (mkCommandId "build")
+      test = checked (mkCommandId "test")
+      command executable args = Command executable args Set.empty Nothing [] (checked (mkStageBudget 30000000)) (OutputPolicy 4096)
+      commands = Map.fromList [(build, command "ghc" ["-v0", "Checker.hs", "-o", "checker"]), (test, command "./checker" [])]
+      define cid new = CaseDefinition (checked (mkCaseId cid)) "Remove the intended capacity guard" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" new 1] build (Just test) Nothing (Diagnostic Test 1 "capacity rule violated") GenericCommands
+      campaign = checked (mkCampaign commands [(define cid value, noHooks) | (cid, value) <- [("caught", "mutant"), ("survivor", "harmless"), ("wrong", "unrelated")]])
+  result <- runCampaign (defaultRunOptions "target" "library-results") campaign
+  let RunResult {results, status} = result
+  unless ([verdict | CaseResult {verdict} <- results] == [Caught, Survived, WrongFailure] && status == RunFailed) (error (show result))
+  putStrLn "Standalone library: caught, survived, wrong-failure"
diff --git a/examples/standalone/consumer.cabal b/examples/standalone/consumer.cabal
new file mode 100644
--- /dev/null
+++ b/examples/standalone/consumer.cabal
@@ -0,0 +1,18 @@
+cabal-version: 3.4
+name: heni-standalone-consumer
+version: 0.1.0.0
+build-type: Simple
+license: BSD-3-Clause
+
+executable consumer
+  main-is: Main.hs
+  default-language: GHC2024
+  default-extensions:
+    DuplicateRecordFields
+    OverloadedStrings
+
+  ghc-options: -threaded
+  build-depends:
+    base >=4.21.2 && <4.22,
+    containers >=0.7 && <0.9,
+    heni ==0.1.0.0,
diff --git a/examples/standalone/target/Checker.hs b/examples/standalone/target/Checker.hs
new file mode 100644
--- /dev/null
+++ b/examples/standalone/target/Checker.hs
@@ -0,0 +1,11 @@
+module Main (main) where
+
+import System.Exit (exitFailure)
+
+main :: IO ()
+main = do
+  input <- readFile "input"
+  case input of
+    "mutant\n" -> putStrLn "capacity rule violated" >> exitFailure
+    "unrelated\n" -> putStrLn "unrelated checker failure" >> exitFailure
+    _ -> pure ()
diff --git a/examples/standalone/target/heni.json b/examples/standalone/target/heni.json
new file mode 100644
--- /dev/null
+++ b/examples/standalone/target/heni.json
@@ -0,0 +1,79 @@
+{
+  "schemaVersion": 1,
+  "commands": {
+    "build": {
+      "executable": "ghc",
+      "arguments": [
+        "-v0",
+        "Checker.hs",
+        "-o",
+        "checker"
+      ]
+    },
+    "test": {
+      "executable": "./checker"
+    }
+  },
+  "cases": [
+    {
+      "id": "caught",
+      "description": "Remove the intended capacity guard",
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "input",
+          "old": "anchor",
+          "new": "mutant"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "capacity rule violated"
+      }
+    },
+    {
+      "id": "survivor",
+      "description": "Remove the intended capacity guard",
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "input",
+          "old": "anchor",
+          "new": "harmless"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "capacity rule violated"
+      }
+    },
+    {
+      "id": "wrong",
+      "description": "Remove the intended capacity guard",
+      "edits": [
+        {
+          "kind": "replace",
+          "path": "input",
+          "old": "anchor",
+          "new": "unrelated"
+        }
+      ],
+      "build": "build",
+      "test": "test",
+      "expect": {
+        "kind": "diagnostic",
+        "stage": "test",
+        "exitCode": 1,
+        "outputContains": "capacity rule violated"
+      }
+    }
+  ]
+}
diff --git a/examples/standalone/target/input b/examples/standalone/target/input
new file mode 100644
--- /dev/null
+++ b/examples/standalone/target/input
@@ -0,0 +1,1 @@
+anchor
diff --git a/heni.cabal b/heni.cabal
new file mode 100644
--- /dev/null
+++ b/heni.cabal
@@ -0,0 +1,327 @@
+cabal-version: 3.4
+name: heni
+version: 0.1.0.0
+synopsis: Deliberate mutation testing with attributable evidence
+description:
+  Run checked mutations in disposable workspaces and require precise
+  failure evidence from existing tests and commands.
+
+homepage: https://github.com/shinzui/heni
+bug-reports: https://github.com/shinzui/heni/issues
+license: BSD-3-Clause
+license-file: LICENSE
+author: Nadeem Bitar
+maintainer: nadeem@gmail.com
+copyright: 2026 Nadeem Bitar
+category: Testing
+build-type: Simple
+tested-with: ghc ==9.12.4
+extra-doc-files:
+  CHANGELOG.md
+  README.md
+  docs/*.md
+  docs/adr/*.md
+  docs/design/*.md
+  docs/research/*.md
+  docs/validation/migration-measurements.json
+  docs/validation/migration-results.md
+  docs/validation/migration-source-identities.json
+
+extra-source-files:
+  schemas/*.json
+  scripts/check-schemas.py
+  scripts/check-sdist.py
+  scripts/check-sdist.sh
+  scripts/measure-migration.py
+  scripts/test-migrations.sh
+
+data-files:
+  examples/adapters/*.json
+  examples/adapters/README.md
+  examples/adapters/input
+  examples/migrations/README.md
+  examples/migrations/diagnostic-checker/LICENSE
+  examples/migrations/diagnostic-checker/heni.json
+  examples/migrations/diagnostic-checker/policy.txt
+  examples/migrations/diagnostic-checker/tools/Build.hs
+  examples/migrations/diagnostic-checker/tools/Check.hs
+  examples/migrations/generated-code/LICENSE
+  examples/migrations/generated-code/cabal.project
+  examples/migrations/generated-code/generated-fixture.cabal
+  examples/migrations/generated-code/heni.json
+  examples/migrations/generated-code/model.txt
+  examples/migrations/generated-code/test/Main.hs
+  examples/migrations/generated-code/tools/Build.hs
+  examples/migrations/patch-catalogue/LICENSE
+  examples/migrations/patch-catalogue/cabal.project
+  examples/migrations/patch-catalogue/heni-minimal-fixture.cabal
+  examples/migrations/patch-catalogue/heni.json
+  examples/migrations/patch-catalogue/patches/guard-bypass.patch
+  examples/migrations/patch-catalogue/patches/survivor.patch
+  examples/migrations/patch-catalogue/src/Capacity.hs
+  examples/migrations/patch-catalogue/test/Main.hs
+  examples/migrations/replacement/LICENSE
+  examples/migrations/replacement/cabal.project
+  examples/migrations/replacement/heni-minimal-fixture.cabal
+  examples/migrations/replacement/heni.json
+  examples/migrations/replacement/src/Capacity.hs
+  examples/migrations/replacement/test/Main.hs
+  examples/minimal/*.cabal
+  examples/minimal/cabal.project
+  examples/minimal/heni.json
+  examples/minimal/src/*.hs
+  examples/minimal/test/*.hs
+  examples/standalone/*.cabal
+  examples/standalone/*.hs
+  examples/standalone/target/*.hs
+  examples/standalone/target/*.json
+  examples/standalone/target/input
+
+source-repository head
+  type: git
+  location: https://github.com/shinzui/heni.git
+
+flag migration-tests
+  description: Run cross-package migration trials with an explicitly supplied CLI executable.
+  default: False
+  manual: True
+
+common common
+  default-language: GHC2024
+  default-extensions:
+    DeriveAnyClass
+    DuplicateRecordFields
+    OverloadedLabels
+    OverloadedStrings
+
+  ghc-options:
+    -Wall
+    -Wcompat
+    -Wincomplete-uni-patterns
+    -Wincomplete-record-updates
+
+  build-depends:
+    base >=4.21.2 && <4.22,
+    containers >=0.7 && <0.9,
+    text >=2.1.4 && <2.2,
+
+library
+  import: common
+  hs-source-dirs: src
+  build-depends:
+    aeson >=2.3.2 && <2.4,
+    bytestring >=0.12.2 && <0.13,
+    crypton >=2.2.5 && <2.3,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    process >=1.6.26.1 && <1.7,
+    stm >=2.5.3.1 && <2.6,
+    unix >=2.8.8 && <2.9,
+
+  exposed-modules:
+    Test.Heni
+    Test.Heni.Adapter
+    Test.Heni.BuildReuse
+    Test.Heni.Case
+    Test.Heni.Command
+    Test.Heni.Crash
+    Test.Heni.Edit
+    Test.Heni.Expectation
+    Test.Heni.Manifest
+    Test.Heni.Process
+    Test.Heni.Replay
+    Test.Heni.Report
+    Test.Heni.Resource
+    Test.Heni.Result
+    Test.Heni.Runner
+    Test.Heni.Scheduler
+    Test.Heni.Workspace
+
+  other-modules:
+    Test.Heni.Internal.Baseline
+    Test.Heni.Internal.Codec
+    Test.Heni.Internal.Files
+    Test.Heni.Internal.Model
+    Test.Heni.Internal.Prelude
+    Test.Heni.Internal.Process.Posix
+
+executable heni-fixture
+  import: common
+  hs-source-dirs: test/fixture
+  main-is: Main.hs
+  build-depends:
+    bytestring >=0.12.2 && <0.13,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    process >=1.6.26.1 && <1.7,
+    unix >=2.8.8 && <2.9,
+
+  ghc-options: -threaded
+
+executable heni-evidence-fixture
+  import: common
+  hs-source-dirs: test/evidence-fixture
+  main-is: Main.hs
+  build-depends:
+    bytestring >=0.12.2 && <0.13,
+    heni ==0.1.0.0,
+    unix >=2.8.8 && <2.9,
+
+  ghc-options: -threaded
+
+executable heni-library-example
+  import: common
+  hs-source-dirs: examples/library
+  main-is: Main.hs
+  other-modules: Definitions
+  build-depends: heni
+  ghc-options: -threaded
+
+test-suite heni-runner
+  import: common
+  type: exitcode-stdio-1.0
+  hs-source-dirs:
+    test/runner
+    examples/library
+
+  main-is: Main.hs
+  other-modules:
+    Definitions
+    Paths_heni
+
+  autogen-modules: Paths_heni
+  build-depends:
+    aeson >=2.3.2 && <2.4,
+    bytestring >=0.12.2 && <0.13,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    heni ==0.1.0.0,
+    unix >=2.8.8 && <2.9,
+
+  build-tool-depends: heni:heni-fixture
+  ghc-options: -threaded
+
+test-suite heni-process
+  import: common
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test/process
+  main-is: Main.hs
+  build-depends:
+    bytestring >=0.12.2 && <0.13,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    heni ==0.1.0.0,
+    unix >=2.8.8 && <2.9,
+
+  build-tool-depends: heni:heni-fixture
+  ghc-options: -threaded
+
+test-suite heni-adapter
+  import: common
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test/adapter
+  main-is: Main.hs
+  build-depends:
+    aeson >=2.3.2 && <2.4,
+    bytestring >=0.12.2 && <0.13,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    heni ==0.1.0.0,
+    unix >=2.8.8 && <2.9,
+
+  build-tool-depends: heni:heni-evidence-fixture
+  ghc-options: -threaded
+
+test-suite heni-unit
+  import: common
+  type: exitcode-stdio-1.0
+  hs-source-dirs:
+    test/unit
+    test/api
+
+  main-is: Main.hs
+  other-modules:
+    Consumer
+    Labels
+
+  build-depends: heni
+  ghc-options: -threaded
+
+test-suite heni-workspace
+  import: common
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test/workspace
+  main-is: Main.hs
+  build-depends:
+    bytestring >=0.12.2 && <0.13,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    heni ==0.1.0.0,
+    process >=1.6.26.1 && <1.7,
+    unix >=2.8.8 && <2.9,
+
+  ghc-options: -threaded
+
+test-suite heni-scheduler
+  import: common
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test/scheduler
+  main-is: Main.hs
+  build-depends:
+    bytestring >=0.12.2 && <0.13,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    heni ==0.1.0.0,
+    unix >=2.8.8 && <2.9,
+
+  build-tool-depends: heni:heni-fixture
+  ghc-options: -threaded
+
+executable heni-campaign-bench
+  import: common
+  hs-source-dirs:
+    bench/campaign
+    examples/library
+
+  main-is: Main.hs
+  other-modules:
+    Definitions
+    Paths_heni
+
+  autogen-modules: Paths_heni
+  build-depends:
+    aeson >=2.3.2 && <2.4,
+    bytestring >=0.12.2 && <0.13,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    heni ==0.1.0.0,
+    unix >=2.8.8 && <2.9,
+
+  ghc-options: -threaded
+
+test-suite heni-migration
+  import: common
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test/migration
+  main-is: Main.hs
+  other-modules:
+    Legacy
+    Paths_heni
+
+  autogen-modules: Paths_heni
+  build-depends:
+    aeson >=2.3.2 && <2.4,
+    bytestring >=0.12.2 && <0.13,
+    directory >=1.3.10.1 && <1.4,
+    filepath >=1.5.5 && <1.6,
+    heni ==0.1.0.0,
+    process >=1.6.26.1 && <1.7,
+    unix >=2.8.8 && <2.9,
+
+  build-tool-depends:
+    heni:heni-evidence-fixture,
+    heni:heni-fixture,
+
+  if !flag(migration-tests)
+    buildable: False
+  ghc-options: -threaded
diff --git a/schemas/manifest-v1.schema.json b/schemas/manifest-v1.schema.json
new file mode 100644
--- /dev/null
+++ b/schemas/manifest-v1.schema.json
@@ -0,0 +1,680 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://raw.githubusercontent.com/shinzui/heni/master/schemas/manifest-v1.schema.json",
+  "title": "Heni manifest v1",
+  "type": "object",
+  "properties": {
+    "schemaVersion": {
+      "const": 1
+    },
+    "sourceRoot": {
+      "type": "string",
+      "minLength": 1,
+      "pattern": "^(?!/)[^\\u0000]+$",
+      "default": "."
+    },
+    "sourcePolicy": {
+      "type": "object",
+      "properties": {
+        "includes": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "minLength": 1,
+            "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"
+          },
+          "default": []
+        },
+        "excludes": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "minLength": 1,
+            "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"
+          },
+          "default": []
+        }
+      },
+      "required": [],
+      "additionalProperties": false
+    },
+    "commands": {
+      "type": "object",
+      "propertyNames": {
+        "type": "string",
+        "pattern": "^[A-Za-z0-9_.-]+$"
+      },
+      "additionalProperties": {
+        "$ref": "#/$defs/command"
+      }
+    },
+    "cases": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/case"
+      },
+      "minItems": 1
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      },
+      "default": []
+    },
+    "execution": {
+      "type": "object",
+      "properties": {
+        "workers": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 256,
+          "default": 1
+        },
+        "baselineReuse": {
+          "anyOf": [
+            {
+              "type": "null"
+            },
+            {
+              "type": "object",
+              "properties": {
+                "toolchain": {
+                  "type": "string",
+                  "pattern": "\\S"
+                },
+                "adapter": {
+                  "anyOf": [
+                    {
+                      "type": "string",
+                      "pattern": "\\S"
+                    },
+                    {
+                      "type": "null"
+                    }
+                  ]
+                }
+              },
+              "required": [
+                "toolchain"
+              ],
+              "additionalProperties": false
+            }
+          ]
+        }
+      },
+      "additionalProperties": false
+    }
+  },
+  "required": [
+    "schemaVersion",
+    "commands",
+    "cases"
+  ],
+  "additionalProperties": false,
+  "$defs": {
+    "command": {
+      "type": "object",
+      "properties": {
+        "executable": {
+          "type": "string",
+          "minLength": 1
+        },
+        "arguments": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "sensitiveArguments": {
+          "type": "array",
+          "items": {
+            "type": "integer",
+            "minimum": 0
+          },
+          "uniqueItems": true,
+          "default": []
+        },
+        "workingDirectory": {
+          "anyOf": [
+            {
+              "type": "string",
+              "minLength": 1,
+              "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "environment": {
+          "type": "object",
+          "propertyNames": {
+            "pattern": "^[^=\\u0000]+$"
+          },
+          "additionalProperties": {
+            "type": "string"
+          },
+          "default": {}
+        },
+        "timeoutSeconds": {
+          "type": "number",
+          "exclusiveMinimum": 0,
+          "maximum": 86400,
+          "default": 30
+        },
+        "outputLimitBytes": {
+          "type": "integer",
+          "minimum": 1,
+          "default": 1048576
+        }
+      },
+      "required": [
+        "executable"
+      ],
+      "additionalProperties": false
+    },
+    "edit": {
+      "oneOf": [
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "replace"
+            },
+            "path": {
+              "type": "string",
+              "minLength": 1,
+              "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"
+            },
+            "old": {
+              "type": "string",
+              "minLength": 1
+            },
+            "new": {
+              "type": "string"
+            },
+            "occurrences": {
+              "type": "integer",
+              "minimum": 1,
+              "default": 1
+            }
+          },
+          "required": [
+            "kind",
+            "path",
+            "old",
+            "new"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "create"
+            },
+            "path": {
+              "type": "string",
+              "minLength": 1,
+              "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"
+            },
+            "content": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "path",
+            "content"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "delete"
+            },
+            "path": {
+              "type": "string",
+              "minLength": 1,
+              "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"
+            },
+            "expected": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "path",
+            "expected"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "patch"
+            },
+            "content": {
+              "type": "string",
+              "minLength": 1
+            }
+          },
+          "required": [
+            "kind",
+            "content"
+          ],
+          "additionalProperties": false
+        }
+      ]
+    },
+    "expectation": {
+      "oneOf": [
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "diagnostic"
+            },
+            "stage": {
+              "enum": [
+                "build",
+                "test"
+              ]
+            },
+            "exitCode": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "outputContains": {
+              "type": "string",
+              "minLength": 1
+            }
+          },
+          "required": [
+            "kind",
+            "stage",
+            "exitCode",
+            "outputContains"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "build-rejection"
+            },
+            "exitCode": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "outputContains": {
+              "type": "string",
+              "minLength": 1
+            }
+          },
+          "required": [
+            "kind",
+            "exitCode",
+            "outputContains"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "control"
+            }
+          },
+          "required": [
+            "kind"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "named-tests"
+            },
+            "failures": {
+              "type": "array",
+              "items": {
+                "type": "string",
+                "minLength": 1
+              },
+              "uniqueItems": true,
+              "minItems": 1
+            },
+            "replayRequired": {
+              "type": "boolean",
+              "default": false
+            }
+          },
+          "required": [
+            "kind",
+            "failures"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "crash"
+            },
+            "signal": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "probe": {
+              "type": "string",
+              "minLength": 1
+            }
+          },
+          "required": [
+            "kind",
+            "signal",
+            "probe"
+          ],
+          "additionalProperties": false
+        }
+      ]
+    },
+    "case": {
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "description": {
+          "type": "string",
+          "pattern": "\\S"
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true,
+          "default": []
+        },
+        "edits": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/edit"
+          },
+          "default": []
+        },
+        "build": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "test": {
+          "anyOf": [
+            {
+              "type": "string",
+              "pattern": "^[A-Za-z0-9_.-]+$"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "selector": {
+          "anyOf": [
+            {
+              "type": "string",
+              "pattern": "\\S"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "expect": {
+          "$ref": "#/$defs/expectation"
+        },
+        "adapter": {
+          "enum": [
+            null,
+            "heni-v1"
+          ]
+        },
+        "resources": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "pattern": "^[A-Z][A-Z0-9_]*$"
+          },
+          "uniqueItems": true,
+          "default": []
+        }
+      },
+      "required": [
+        "id",
+        "description",
+        "build",
+        "expect"
+      ],
+      "additionalProperties": false,
+      "allOf": [
+        {
+          "if": {
+            "properties": {
+              "expect": {
+                "properties": {
+                  "kind": {
+                    "const": "control"
+                  }
+                }
+              }
+            }
+          },
+          "then": {
+            "properties": {
+              "edits": {
+                "maxItems": 0
+              },
+              "test": {
+                "type": "string",
+                "pattern": "^[A-Za-z0-9_.-]+$"
+              }
+            },
+            "required": [
+              "test"
+            ]
+          },
+          "else": {
+            "required": [
+              "edits"
+            ],
+            "properties": {
+              "edits": {
+                "minItems": 1
+              }
+            }
+          }
+        },
+        {
+          "if": {
+            "properties": {
+              "expect": {
+                "properties": {
+                  "kind": {
+                    "const": "diagnostic"
+                  },
+                  "stage": {
+                    "const": "test"
+                  }
+                },
+                "required": [
+                  "stage"
+                ]
+              }
+            }
+          },
+          "then": {
+            "required": [
+              "test"
+            ],
+            "properties": {
+              "test": {
+                "type": "string",
+                "pattern": "^[A-Za-z0-9_.-]+$"
+              }
+            }
+          }
+        },
+        {
+          "if": {
+            "properties": {
+              "expect": {
+                "properties": {
+                  "kind": {
+                    "enum": [
+                      "named-tests",
+                      "crash"
+                    ]
+                  }
+                }
+              }
+            }
+          },
+          "then": {
+            "required": [
+              "test"
+            ],
+            "properties": {
+              "test": {
+                "type": "string"
+              }
+            }
+          }
+        },
+        {
+          "if": {
+            "properties": {
+              "expect": {
+                "properties": {
+                  "kind": {
+                    "const": "named-tests"
+                  }
+                }
+              }
+            }
+          },
+          "then": {
+            "required": [
+              "adapter"
+            ],
+            "properties": {
+              "adapter": {
+                "const": "heni-v1"
+              }
+            }
+          }
+        },
+        {
+          "if": {
+            "properties": {
+              "expect": {
+                "properties": {
+                  "kind": {
+                    "const": "crash"
+                  }
+                }
+              }
+            }
+          },
+          "then": {
+            "properties": {
+              "adapter": {
+                "type": "null"
+              }
+            }
+          }
+        }
+      ]
+    },
+    "resource": {
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string",
+          "pattern": "^[A-Z][A-Z0-9_]*$"
+        },
+        "scope": {
+          "enum": [
+            "campaign",
+            "worker",
+            "case"
+          ],
+          "default": "case"
+        },
+        "capacity": {
+          "type": "integer",
+          "minimum": 1,
+          "default": 1
+        },
+        "identity": {
+          "type": "string",
+          "minLength": 1
+        },
+        "version": {
+          "type": "string",
+          "minLength": 1
+        },
+        "epoch": {
+          "anyOf": [
+            {
+              "type": "string",
+              "minLength": 1
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "environment": {
+          "type": "object",
+          "propertyNames": {
+            "pattern": "^HENI_RESOURCE_[A-Z][A-Z0-9_]*_[^=\\u0000]*$"
+          },
+          "additionalProperties": {
+            "type": "string"
+          },
+          "default": {}
+        },
+        "setup": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "reset": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "teardown": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        }
+      },
+      "required": [
+        "name",
+        "identity",
+        "version",
+        "setup",
+        "reset",
+        "teardown"
+      ],
+      "additionalProperties": false
+    }
+  }
+}
diff --git a/schemas/report-v1.schema.json b/schemas/report-v1.schema.json
new file mode 100644
--- /dev/null
+++ b/schemas/report-v1.schema.json
@@ -0,0 +1,735 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://raw.githubusercontent.com/shinzui/heni/master/schemas/report-v1.schema.json",
+  "title": "Heni report v1",
+  "type": "object",
+  "properties": {
+    "schemaVersion": {
+      "const": 1
+    },
+    "toolVersion": {
+      "type": "string"
+    },
+    "metadata": {
+      "$ref": "#/$defs/metadata"
+    },
+    "result": {
+      "$ref": "#/$defs/result"
+    }
+  },
+  "required": [
+    "schemaVersion",
+    "toolVersion",
+    "metadata",
+    "result"
+  ],
+  "additionalProperties": false,
+  "$defs": {
+    "stageName": {
+      "type": "string",
+      "enum": [
+        "setup",
+        "build",
+        "test",
+        "replay",
+        "teardown"
+      ]
+    },
+    "processOutcome": {
+      "oneOf": [
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "exit"
+            },
+            "code": {
+              "type": "integer",
+              "minimum": 0
+            }
+          },
+          "required": [
+            "kind",
+            "code"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "signal"
+            },
+            "signal": {
+              "type": "integer",
+              "minimum": 1
+            }
+          },
+          "required": [
+            "kind",
+            "signal"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "timeout"
+            }
+          },
+          "required": [
+            "kind"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "cancelled"
+            }
+          },
+          "required": [
+            "kind"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "launch-failed"
+            },
+            "message": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "message"
+          ],
+          "additionalProperties": false
+        }
+      ]
+    },
+    "selection": {
+      "type": "object",
+      "properties": {
+        "discovered": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "selected": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "executed": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "executedIds": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true
+        },
+        "failedIds": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true
+        },
+        "skippedIds": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true
+        },
+        "setupErrors": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        }
+      },
+      "required": [
+        "discovered",
+        "selected",
+        "executed",
+        "executedIds",
+        "failedIds",
+        "skippedIds",
+        "setupErrors"
+      ],
+      "additionalProperties": false
+    },
+    "replay": {
+      "type": "object",
+      "properties": {
+        "token": {
+          "type": "string"
+        },
+        "generator": {
+          "type": "string"
+        },
+        "failures": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true
+        }
+      },
+      "required": [
+        "token",
+        "generator",
+        "failures"
+      ],
+      "additionalProperties": false
+    },
+    "stage": {
+      "type": "object",
+      "properties": {
+        "stage": {
+          "$ref": "#/$defs/stageName"
+        },
+        "outcome": {
+          "$ref": "#/$defs/processOutcome"
+        },
+        "witnesses": {
+          "type": "string"
+        },
+        "evidenceUnavailable": {
+          "type": "boolean"
+        },
+        "selection": {
+          "$ref": "#/$defs/selection"
+        },
+        "replay": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/replay"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "durationSeconds": {
+          "type": "number",
+          "minimum": 0
+        },
+        "logPaths": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "minLength": 1,
+            "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*[\\\\:\\u0000]).+$"
+          }
+        }
+      },
+      "required": [
+        "stage",
+        "outcome",
+        "witnesses",
+        "evidenceUnavailable",
+        "selection",
+        "replay",
+        "durationSeconds",
+        "logPaths"
+      ],
+      "additionalProperties": false
+    },
+    "baseline": {
+      "oneOf": [
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "passed"
+            }
+          },
+          "required": [
+            "kind"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "unavailable"
+            }
+          },
+          "required": [
+            "kind"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "failed"
+            },
+            "message": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "message"
+          ],
+          "additionalProperties": false
+        }
+      ]
+    },
+    "mutation": {
+      "oneOf": [
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "changed"
+            }
+          },
+          "required": [
+            "kind"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "unchanged"
+            }
+          },
+          "required": [
+            "kind"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "rejected"
+            },
+            "message": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "message"
+          ],
+          "additionalProperties": false
+        }
+      ]
+    },
+    "caseEvidence": {
+      "type": "object",
+      "properties": {
+        "baseline": {
+          "$ref": "#/$defs/baseline"
+        },
+        "mutation": {
+          "$ref": "#/$defs/mutation"
+        },
+        "stages": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/stage"
+          }
+        },
+        "infrastructureErrors": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "cleanupErrors": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "stopped": {
+          "anyOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        }
+      },
+      "required": [
+        "baseline",
+        "mutation",
+        "stages",
+        "infrastructureErrors",
+        "cleanupErrors",
+        "stopped"
+      ],
+      "additionalProperties": false
+    },
+    "caseResult": {
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "verdict": {
+          "type": "string",
+          "enum": [
+            "caught",
+            "survived",
+            "invalid-mutation",
+            "uncovered",
+            "unexpected-build-failure",
+            "wrong-failure",
+            "timed-out",
+            "infrastructure-error",
+            "not-run"
+          ]
+        },
+        "detection": {
+          "anyOf": [
+            {
+              "type": "string",
+              "enum": [
+                "semantic-named",
+                "generic-diagnostic",
+                "expected-build-rejection",
+                "explicit-crash",
+                "control-passed"
+              ]
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "semantic": {
+          "type": "boolean"
+        },
+        "control": {
+          "type": "boolean"
+        },
+        "reason": {
+          "type": "string"
+        },
+        "evidence": {
+          "$ref": "#/$defs/caseEvidence"
+        }
+      },
+      "required": [
+        "id",
+        "verdict",
+        "detection",
+        "semantic",
+        "control",
+        "reason",
+        "evidence"
+      ],
+      "additionalProperties": false
+    },
+    "failure": {
+      "oneOf": [
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "baseline"
+            },
+            "affected": {
+              "type": "array",
+              "items": {
+                "type": "string",
+                "pattern": "^[A-Za-z0-9_.-]+$"
+              }
+            },
+            "message": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "affected",
+            "message"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "control"
+            },
+            "id": {
+              "type": "string",
+              "pattern": "^[A-Za-z0-9_.-]+$"
+            },
+            "message": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "id",
+            "message"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "infrastructure"
+            },
+            "message": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "message"
+          ],
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "interrupted"
+            },
+            "message": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "kind",
+            "message"
+          ],
+          "additionalProperties": false
+        }
+      ]
+    },
+    "score": {
+      "type": "object",
+      "properties": {
+        "caught": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "total": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "complete": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "caught",
+        "total",
+        "complete"
+      ],
+      "additionalProperties": false
+    },
+    "result": {
+      "type": "object",
+      "properties": {
+        "selectedIds": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "pattern": "^[A-Za-z0-9_.-]+$"
+          },
+          "minItems": 1,
+          "uniqueItems": true
+        },
+        "cases": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/caseResult"
+          },
+          "minItems": 1
+        },
+        "failures": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/failure"
+          }
+        },
+        "status": {
+          "type": "string",
+          "enum": [
+            "passed",
+            "failed",
+            "untrustworthy",
+            "interrupted"
+          ]
+        },
+        "semanticScore": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/score"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        }
+      },
+      "required": [
+        "selectedIds",
+        "cases",
+        "failures",
+        "status",
+        "semanticScore"
+      ],
+      "additionalProperties": false
+    },
+    "metadata": {
+      "type": "object",
+      "properties": {
+        "platform": {
+          "type": "object",
+          "properties": {
+            "os": {
+              "type": "string"
+            },
+            "arch": {
+              "type": "string"
+            },
+            "compiler": {
+              "type": "string"
+            },
+            "compilerVersion": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "os",
+            "arch",
+            "compiler",
+            "compilerVersion"
+          ],
+          "additionalProperties": false
+        },
+        "sourcePolicy": {
+          "type": "string"
+        },
+        "commands": {
+          "type": "object"
+        },
+        "definitions": {
+          "type": "array"
+        },
+        "caseDetails": {
+          "type": "object"
+        },
+        "resources": {
+          "type": "array"
+        },
+        "snapshot": {
+          "type": "object",
+          "properties": {
+            "identity": {
+              "type": "string",
+              "pattern": "^[0-9a-f]{64}$"
+            },
+            "policy": {
+              "type": "object",
+              "properties": {
+                "includes": {
+                  "type": "array",
+                  "items": {
+                    "type": "string",
+                    "minLength": 1,
+                    "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*[\\\\:\\u0000]).+$"
+                  }
+                },
+                "excludes": {
+                  "type": "array",
+                  "items": {
+                    "type": "string",
+                    "minLength": 1,
+                    "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*[\\\\:\\u0000]).+$"
+                  }
+                },
+                "defaultExcludedNames": {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                "defaultExcludedPrefixes": {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                }
+              },
+              "required": [
+                "includes",
+                "excludes",
+                "defaultExcludedNames",
+                "defaultExcludedPrefixes"
+              ],
+              "additionalProperties": false
+            }
+          },
+          "required": [
+            "identity",
+            "policy"
+          ],
+          "additionalProperties": false
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "execution": {
+          "type": "object"
+        }
+      },
+      "required": [
+        "platform",
+        "sourcePolicy",
+        "commands",
+        "definitions",
+        "caseDetails",
+        "resources"
+      ],
+      "additionalProperties": false
+    }
+  }
+}
diff --git a/schemas/test-evidence-v1.schema.json b/schemas/test-evidence-v1.schema.json
new file mode 100644
--- /dev/null
+++ b/schemas/test-evidence-v1.schema.json
@@ -0,0 +1,691 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://raw.githubusercontent.com/shinzui/heni/master/schemas/test-evidence-v1.schema.json",
+  "title": "Heni test-evidence v1 JSON Lines record",
+  "description": "Each line is one record; the core additionally validates attempt identity, sequence, lifecycle, membership and completion counts.",
+  "oneOf": [
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "begin"
+        },
+        "producerVersion": {
+          "type": "string",
+          "minLength": 1
+        },
+        "discoveredIds": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string",
+                "minLength": 1
+              },
+              "uniqueItems": true
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "selectedIds": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "minLength": 1
+          },
+          "uniqueItems": true
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "producerVersion",
+        "discoveredIds",
+        "selectedIds"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "started"
+        },
+        "testId": {
+          "type": "string",
+          "minLength": 1
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "testId"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "passed"
+        },
+        "testId": {
+          "type": "string",
+          "minLength": 1
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "testId"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "failed"
+        },
+        "testId": {
+          "type": "string",
+          "minLength": 1
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "testId"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "skipped"
+        },
+        "testId": {
+          "type": "string",
+          "minLength": 1
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "testId"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "setup-failed"
+        },
+        "testId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "message": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "testId",
+        "message"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "setup-error"
+        },
+        "message": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "message"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "replay"
+        },
+        "replay": {
+          "type": "object",
+          "properties": {
+            "token": {
+              "type": "string",
+              "minLength": 1
+            },
+            "generator": {
+              "type": "string",
+              "minLength": 1
+            },
+            "failures": {
+              "type": "array",
+              "items": {
+                "type": "string",
+                "minLength": 1
+              },
+              "uniqueItems": true,
+              "minItems": 1
+            }
+          },
+          "required": [
+            "token",
+            "generator",
+            "failures"
+          ],
+          "additionalProperties": false
+        },
+        "counterexample": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "replay",
+        "counterexample"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "kind": {
+          "const": "complete"
+        },
+        "counts": {
+          "type": "object",
+          "properties": {
+            "discovered": {
+              "anyOf": [
+                {
+                  "type": "integer",
+                  "minimum": 0
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "selected": {
+              "type": "integer",
+              "minimum": 0
+            },
+            "executed": {
+              "type": "integer",
+              "minimum": 0
+            },
+            "passed": {
+              "type": "integer",
+              "minimum": 0
+            },
+            "failed": {
+              "type": "integer",
+              "minimum": 0
+            },
+            "skipped": {
+              "type": "integer",
+              "minimum": 0
+            },
+            "setupErrors": {
+              "type": "integer",
+              "minimum": 0
+            }
+          },
+          "required": [
+            "discovered",
+            "selected",
+            "executed",
+            "passed",
+            "failed",
+            "skipped",
+            "setupErrors"
+          ],
+          "additionalProperties": false
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "counts"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "properties": {
+        "protocolVersion": {
+          "const": 1
+        },
+        "runId": {
+          "type": "string",
+          "minLength": 1
+        },
+        "caseId": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9_.-]+$"
+        },
+        "stage": {
+          "enum": [
+            "setup",
+            "build",
+            "test",
+            "replay",
+            "teardown"
+          ]
+        },
+        "role": {
+          "enum": [
+            "baseline",
+            "candidate"
+          ]
+        },
+        "attempt": {
+          "type": "string",
+          "minLength": 1
+        },
+        "sequence": {
+          "const": -1
+        },
+        "kind": {
+          "const": "invalidated"
+        },
+        "message": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "protocolVersion",
+        "runId",
+        "caseId",
+        "stage",
+        "role",
+        "attempt",
+        "sequence",
+        "kind",
+        "message"
+      ],
+      "additionalProperties": false
+    }
+  ]
+}
diff --git a/scripts/check-schemas.py b/scripts/check-schemas.py
new file mode 100644
--- /dev/null
+++ b/scripts/check-schemas.py
@@ -0,0 +1,59 @@
+"""Validate published JSON Schemas and real emitted fixtures (development only)."""
+import argparse
+import json
+import sys
+import os
+from pathlib import Path
+from jsonschema import Draft202012Validator
+
+root = Path(__file__).resolve().parent.parent
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument("--corpus", type=Path, default=root / "heni-cli/test/corpus")
+parser.add_argument("reports", nargs="*")
+args = parser.parse_args()
+assert args.corpus.is_dir(), "supply --corpus from the extracted heni-cli archive"
+assert list(args.corpus.glob("*.json")), "manifest corpus is empty"
+
+def unique_object(pairs):
+    result = {}
+    for key, value in pairs:
+        if key in result:
+            raise ValueError(f"duplicate JSON object key: {key}")
+        result[key] = value
+    return result
+
+manifest_schema = json.loads((root / "schemas/manifest-v1.schema.json").read_text())
+Draft202012Validator.check_schema(manifest_schema)
+manifest_validator = Draft202012Validator(manifest_schema)
+for path in args.corpus.glob("*.json"):
+    try:
+        value = json.loads(path.read_text(), object_pairs_hook=unique_object)
+        valid = manifest_validator.is_valid(value)
+    except ValueError:
+        valid = False
+    assert valid == path.name.startswith("valid-"), path.name
+manifest_validator.validate(json.loads((root / "examples/minimal/heni.json").read_text()))
+manifest_validator.validate(json.loads((root / "examples/standalone/target/heni.json").read_text()))
+for path in (root / "examples/adapters").glob("*.json"):
+    manifest_validator.validate(json.loads(path.read_text()))
+for path in (root / "examples/migrations").glob("*/heni.json"):
+    manifest_validator.validate(json.loads(path.read_text()))
+schema = json.loads((root / "schemas/report-v1.schema.json").read_text())
+Draft202012Validator.check_schema(schema)
+validator = Draft202012Validator(schema)
+for argument in args.reports:
+    value = json.loads(Path(argument).read_text())
+    validator.validate(value)
+    invalid = dict(value, schemaVersion=999)
+    assert not validator.is_valid(invalid), "unknown report version accepted"
+    invalid = dict(value, unexpected=True)
+    assert not validator.is_valid(invalid), "unknown report field accepted"
+print("Manifest corpus, report schema and emitted fixtures passed")
+
+evidence_schema = json.loads((root / "schemas/test-evidence-v1.schema.json").read_text())
+Draft202012Validator.check_schema(evidence_schema)
+evidence_validator = Draft202012Validator(evidence_schema)
+if os.environ.get("HENI_SCHEMA_EVIDENCE"):
+    for line in Path(os.environ["HENI_SCHEMA_EVIDENCE"]).read_text().splitlines():
+        evidence_validator.validate(json.loads(line, object_pairs_hook=unique_object))
+    print("Emitted test evidence schema passed")
diff --git a/scripts/check-sdist.py b/scripts/check-sdist.py
new file mode 100644
--- /dev/null
+++ b/scripts/check-sdist.py
@@ -0,0 +1,236 @@
+"""Local release validation. Never uploads; retains failed extractions and logs."""
+import argparse
+import atexit
+import hashlib
+from html.parser import HTMLParser
+import json
+import os
+import re
+from pathlib import Path
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import time
+from urllib.parse import unquote, urlsplit
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument("--archives", type=Path, help="validate existing archives instead of creating them")
+parser.add_argument("--bounds", choices=["lower", "newest"], default="newest")
+parser.add_argument("--output", type=Path, default=Path("dist-release"))
+args = parser.parse_args()
+cabal_version = subprocess.check_output(["cabal", "--numeric-version"], text=True).strip()
+version_parts = tuple(int(part) for part in cabal_version.split("."))
+if (version_parts + (0, 0, 0, 0))[:4] < (3, 16, 1, 0):
+    parser.error("archive documentation requires cabal-install >=3.16.1.0; older Cabal does not preserve the selective Haddock show flags")
+checkout = Path(__file__).resolve().parent.parent
+output = args.output.resolve()
+output.mkdir(parents=True, exist_ok=True)
+fresh = Path(tempfile.mkdtemp(prefix="heni-sdist-"))
+print(f"Fresh extraction: {fresh}", flush=True)
+checks = []
+
+
+def retain_evidence():
+    evidence = output / "evidence"
+    evidence.mkdir(exist_ok=True)
+    for pattern in ["*.log", "*-plan.json", "core-closure.json", "core-report.json", "resource-report.json", "test-evidence.jsonl", "migration-results.json"]:
+        for path in fresh.glob(pattern):
+            shutil.copyfile(path, evidence / path.name)
+    campaigns = evidence / "campaigns"
+    retained = list(fresh.glob("heni-migration-*")) + list(fresh.glob("adapter-example-*"))
+    retained += [fresh / "consumer" / name for name in ["library-results", "cli-caught", "cli-survivor", "cli-wrong"]]
+    for directory in retained:
+        if directory.is_dir():
+            campaigns.mkdir(exist_ok=True)
+            shutil.copytree(directory, campaigns / directory.name, dirs_exist_ok=True)
+    (evidence / "state.json").write_text(json.dumps({"extraction": str(fresh), "bounds": args.bounds, "checks": checks}, indent=2) + "\n")
+
+
+atexit.register(retain_evidence)
+
+
+def run(label, argv, cwd, env=None, expected=0):
+    print(label, flush=True)
+    start = time.monotonic()
+    with (fresh / (label + ".log")).open("w") as log:
+        result = subprocess.run(argv, cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=1800)
+    checks.append({"check": label, "exit": result.returncode, "expected": expected, "seconds": time.monotonic() - start})
+    assert result.returncode == expected, f"{label}: see {fresh / (label + '.log')}"
+
+
+def project(path, packages):
+    versions = {"base": "4.21.2.0", "text": "2.1.4", "containers": "0.7", "bytestring": "0.12.2.0", "directory": "1.3.10.1", "filepath": "1.5.5.0", "unix": "2.8.8.0", "process": "1.6.26.1", "crypton": "2.2.5", "aeson": "2.3.2.0", "stm": "2.5.3.1", "tasty": "1.5.5", "tasty-hunit": "0.10.2", "hspec": "2.11.18", "hspec-core": "2.11.18", "hspec-api": "2.11.18", "QuickCheck": "2.19.0.0"}
+    if args.bounds == "newest":
+        versions.update(containers="0.8.1", directory="1.3.11.0", process="1.6.30.0")
+    constraints = ", ".join(name + " ==" + version for name, version in versions.items())
+    policy = ""
+    if os.environ.get("HENI_CI_WERROR") == "1":
+        policy = "".join("package " + name + "\n  ghc-options: -Werror\n" for name in ["heni", "heni-cli", "heni-tasty", "heni-hspec"])
+    path.write_text("packages: " + " ".join(packages) + "\nwrite-ghc-environment-files: never\nconstraints: " + constraints + "\n" + policy)
+
+
+archives = args.archives.resolve() if args.archives else output
+if not args.archives:
+    run("sdist", ["cabal", "sdist", "all", "--output-directory", str(archives)], checkout)
+names = ["heni", "heni-cli", "heni-tasty", "heni-hspec"]
+hashes = {}
+for name in names:
+    archive = archives / (name + "-0.1.0.0.tar.gz")
+    hashes[archive.name] = hashlib.sha256(archive.read_bytes()).hexdigest()
+    with tarfile.open(archive) as bundle:
+        for member in bundle.getmembers():
+            path = Path(member.name)
+            assert not path.is_absolute() and ".." not in path.parts
+            assert member.isfile() or member.isdir(), f"nonregular archive member: {member.name}"
+        bundle.extractall(fresh)  # members were restricted to contained regular paths above
+packages = {name: fresh / (name + "-0.1.0.0") for name in names}
+for name, package in packages.items():
+    for required in ["LICENSE", "CHANGELOG.md", name + ".cabal"]:
+        assert (package / required).is_file(), (name, required)
+    for path in package.rglob("*"):
+        assert not path.is_symlink(), path
+        if path.is_file():
+            # Match an absolute reference, not a relative path whose suffix
+            # happens to share the checkout basename (e.g. test/runner).
+            reference = rb"(?<![A-Za-z0-9_.-])" + re.escape(str(checkout).encode()) + rb"(?:/|[\s\"'])"
+            assert re.search(reference, path.read_bytes()) is None, f"checkout reference: {path}"
+    run("check-" + name, ["cabal", "check"], package)
+core = packages["heni"]
+for required in ["schemas/manifest-v1.schema.json", "examples/minimal/src/Capacity.hs", "examples/standalone/target/heni.json", "test/process/Main.hs", "test/migration/Main.hs"]:
+    assert (core / required).is_file(), required
+
+# Construct a tool path explicitly, without enumerating the compiler store or
+# inheriting Git, Nix, Mori, OKF, Python, or a consumer mutation runner.
+tools = fresh / "core-tools"
+tools.mkdir()
+build_tools = """
+cabal ghc ghc-pkg runghc runhaskell hsc2hs cc gcc clang as ld ld.gold ld.bfd ld.lld
+ar ranlib strip libtool objcopy objdump readelf cpp c++ g++ strings make pkg-config
+xcrun xcodebuild dsymutil otool nm lipo codesign sw_vers xattr
+sh curl install tar gzip uname rm rmdir mkdir cp mv cat sed awk perl tr touch chmod
+sort uniq cut head tail env readlink realpath dirname basename pwd find ln getconf
+ls printf sleep wc test diff cmp dd file tee od iconv ldd true false du df ps
+hostname which id whoami mktemp timeout setarch getent tput stty expr echo grep date
+""".split()
+for command in build_tools:
+    found = shutil.which(command)
+    if found:
+        (tools / command).symlink_to(found)
+minimal = os.environ.copy()
+minimal["PATH"] = str(tools)
+minimal.pop("HENI_REQUIRE_GIT", None)
+minimal["HENI_SCHEMA_REPORT"] = str(fresh / "core-report.json")
+minimal["HENI_SCHEMA_RESOURCE"] = str(fresh / "resource-report.json")
+minimal["HENI_SCHEMA_EVIDENCE"] = str(fresh / "test-evidence.jsonl")
+for command in ["git", "nix", "mori", "okf", "python3"]:
+    assert shutil.which(command, path=minimal["PATH"]) is None
+
+project(core / "cabal.project", ["."])
+run("core-independent", ["cabal", "test", "all", "-j1", "--test-show-details=direct"], core, minimal)
+plan = json.loads((core / "dist-newstyle/cache/plan.json").read_text())
+nodes = {item["id"]: item for item in plan["install-plan"]}
+reachable = set()
+
+
+def visit(key):
+    if key in reachable:
+        return
+    reachable.add(key)
+    node = nodes[key]
+    for dependency in node.get("depends", []) + node.get("exe-depends", []):
+        visit(dependency)
+
+
+for node in nodes.values():
+    if node["pkg-name"] == "heni":
+        visit(node["id"])
+closure = {nodes[key]["pkg-name"] for key in reachable}
+assert not closure.intersection({"heni-cli", "heni-tasty", "heni-hspec", "tasty", "hspec", "hspec-core"}), closure
+# Released Aeson itself depends on QuickCheck; this is not a reporter dependency.
+(fresh / "core-closure.json").write_text(json.dumps(sorted(closure), indent=2) + "\n")
+shutil.copyfile(core / "dist-newstyle/cache/plan.json", fresh / "core-plan.json")
+patch_env = os.environ.copy()
+patch_env["HENI_REQUIRE_GIT"] = "1"
+run("optional-git-patches", ["cabal", "test", "heni-workspace", "heni-runner", "-j1", "--test-show-details=direct"], core, patch_env)
+
+cli = packages["heni-cli"]
+project(cli / "cabal.project", [".", "../heni-0.1.0.0"])
+cli_env = os.environ.copy()
+cli_env["HENI_SCHEMA_RESOURCE"] = str(fresh / "resource-report.json")
+run("cli-with-core-archive", ["cabal", "test", "heni-cli:heni-cli-test", "-j1", "--test-show-details=direct"], cli, cli_env)
+project(fresh / "cabal.project", [p.name for p in packages.values()])
+run("optional-reporters", ["cabal", "test", "heni-tasty:heni-tasty-test", "heni-hspec:heni-hspec-test", "-j1", "--test-show-details=direct"], fresh)
+run("haddock", ["cabal", "haddock", "all", "--haddock-for-hackage", "--haddock-executables", "--haddock-option=--show=Test.Heni.Internal.Model", "--haddock-option=--show=Test.Heni.Internal.Files", "-j1"], fresh)
+text = (fresh / "haddock.log").read_text()
+for line in text.splitlines():
+    if "% (" in line and "in 'Test.Heni" in line:
+        assert "100%" in line, line
+for module in ["Test-Heni.html", "Test-Heni-Resource.html", "Test-Heni-Tasty.html", "Test-Heni-Hspec.html"]:
+    assert list((fresh / "dist-newstyle").rglob(module)), module
+
+
+class Links(HTMLParser):
+    def __init__(self):
+        super().__init__()
+        self.links = []
+
+    def handle_starttag(self, tag, attrs):
+        if tag == "a":
+            self.links.extend(value for key, value in attrs if key == "href")
+
+
+for page in (fresh / "dist-newstyle").rglob("Test-Heni*.html"):
+    links = Links()
+    links.feed(page.read_text())
+    for href in links.links:
+        parsed = urlsplit(href)
+        path = unquote(parsed.path)
+        if not parsed.scheme and (path.startswith("Test-Heni") or path.startswith("src/")):
+            assert (page.parent / path).is_file(), (page, href)
+schema_env = os.environ.copy()
+schema_env["HENI_SCHEMA_EVIDENCE"] = str(fresh / "test-evidence.jsonl")
+run("schema-agreement", [os.environ.get("HENI_SCHEMA_PYTHON", "python3"), str(core / "scripts/check-schemas.py"), "--corpus", str(cli / "test/corpus"), str(fresh / "core-report.json"), str(fresh / "resource-report.json")], fresh, schema_env)
+
+binary = subprocess.check_output(["cabal", "list-bin", "heni-cli:exe:heni"], cwd=fresh, text=True).strip()
+run("version-without-git", [binary, "--version"], fresh, minimal)
+run("help", [binary, "--help"], fresh)
+framework_env = os.environ.copy()
+fixture_dirs = []
+for package in ["heni-tasty", "heni-hspec"]:
+    fixture = subprocess.check_output(["cabal", "list-bin", package + ":exe:" + package + "-fixture"], cwd=fresh, text=True).strip()
+    fixture_dirs.append(str(Path(fixture).parent))
+framework_env["PATH"] = os.pathsep.join(fixture_dirs + [framework_env["PATH"]])
+for example in ["tasty", "hspec", "property"]:
+    run("adapter-example-" + example, [binary, "run", str(core / "examples/adapters" / (example + ".json")), "--output", str(fresh / ("adapter-example-" + example)), "--json"], fresh, framework_env)
+
+consumer = fresh / "consumer"
+shutil.copytree(core / "examples/standalone", consumer)
+project(consumer / "cabal.project", [".", "../heni-0.1.0.0"])
+run("standalone-library", ["cabal", "run", "consumer", "-j1"], consumer, minimal)
+for case, exit_code, verdict in [("caught", 0, "caught"), ("survivor", 1, "survived"), ("wrong", 1, "wrong-failure")]:
+    destination = consumer / ("cli-" + case)
+    run("standalone-cli-" + case, [binary, "run", "target/heni.json", "--case", case, "--output", str(destination), "--json"], consumer, minimal, exit_code)
+    report = json.loads((destination / "report.json").read_text())
+    assert report["result"]["cases"][0]["verdict"] == verdict
+run("standalone-config-exit", [binary, "run", "target/heni.json", "--case", "absent"], consumer, minimal, 2)
+migration_env = os.environ.copy()
+migration_env["HENI_CLI_EXE"] = binary
+migration_env["HENI_MIGRATION_RESULTS"] = str(fresh / "migration-results.json")
+run("migration-archives", ["cabal", "test", "heni:heni-migration", "-f", "migration-tests", "-j1", "--test-show-details=direct"], fresh, migration_env)
+shutil.copyfile(fresh / "dist-newstyle/cache/plan.json", fresh / "combined-plan.json")
+for filename in ["core-plan.json", "combined-plan.json"]:
+    for item in json.loads((fresh / filename).read_text())["install-plan"]:
+        source = item.get("pkg-src", {})
+        if source.get("type") == "local":
+            assert Path(source["path"]).resolve().is_relative_to(fresh.resolve()), source
+        else:
+            assert not source or source.get("type") == "repo-tar", source
+for archive in (fresh / "dist-newstyle").rglob("*-docs.tar.gz"):
+    shutil.copyfile(archive, output / archive.name)
+    hashes[archive.name] = hashlib.sha256(archive.read_bytes()).hexdigest()
+for name in names:
+    assert (output / (name + "-0.1.0.0-docs.tar.gz")).is_file(), name
+summary = {"bounds": args.bounds, "extraction": str(fresh), "archives": hashes, "compiler": subprocess.check_output(["ghc", "--numeric-version"], text=True).strip(), "cabal": subprocess.check_output(["cabal", "--numeric-version"], text=True).strip(), "checks": checks}
+(output / "archive-validation.json").write_text(json.dumps(summary, indent=2) + "\n")
+print(f"Archive validation passed; evidence: {output / 'archive-validation.json'}", flush=True)
diff --git a/scripts/check-sdist.sh b/scripts/check-sdist.sh
new file mode 100644
--- /dev/null
+++ b/scripts/check-sdist.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -eu
+exec python3 "$(dirname "$0")/check-sdist.py" "$@"
diff --git a/scripts/measure-migration.py b/scripts/measure-migration.py
new file mode 100644
--- /dev/null
+++ b/scripts/measure-migration.py
@@ -0,0 +1,124 @@
+"""Development-only, automated interface observations; never human effort timing."""
+import argparse
+import copy
+import json
+from pathlib import Path
+import shutil
+import statistics
+import subprocess
+import tempfile
+import time
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--results", type=Path, required=True)
+parser.add_argument("--cli", type=Path, required=True)
+parser.add_argument("--output", type=Path, required=True)
+args = parser.parse_args()
+root = Path(__file__).resolve().parent.parent
+cli = args.cli.resolve()
+(root / ".heni-runs").mkdir(exist_ok=True)
+trial = json.loads(args.results.read_text())
+assert len(trial["families"]) == 4, "a complete successful trial is required"
+legacy = (root / "test/migration/Legacy.hs").read_text().splitlines()
+
+def region(name):
+    first = next(i for i, line in enumerate(legacy) if line.strip() == "-- BEGIN " + name)
+    last = next(i for i, line in enumerate(legacy) if line.strip() == "-- END " + name)
+    return {i for i in range(first + 1, last) if legacy[i].strip() and not legacy[i].lstrip().startswith("--")}
+
+def timing(values):
+    return {"repeats": len(values), "minSeconds": min(values), "maxSeconds": max(values), "medianSeconds": statistics.median(values)}
+
+def stages(report):
+    return [(name, observation["stage"]) for details in report["metadata"]["caseDetails"].values()
+            for name, observation in details.items() if isinstance(observation, dict) and isinstance(observation.get("stage"), dict)]
+
+families = []
+authoring = []
+diagnostic_negative = None
+for family in trial["families"]:
+    name = family["family"]
+    before = region(name + "-orchestration") - region(name + "-definition")
+    domain = region(name + "-definition")
+    fixture = root / "examples/migrations" / name
+    definition = json.loads((fixture / "heni.json").read_text())
+    helper_loc = sum(sum(bool(line.strip()) and not line.lstrip().startswith("--") for line in p.read_text().splitlines()) for p in (fixture / "tools").glob("*.hs"))
+    row = {"family": name, "comparisonLocalOrchestrationLOC": len(before) + 1,
+           "comparisonDefinitionLOC": len(domain), "manifestLifecycleScaffoldingLOC": 0,
+           "retainedDomainBuildHelperLOC": helper_loc, "legacyIntendedCaught": family["legacyIntendedCaught"],
+           "legacyUnrelatedCompileAccepted": family["legacyUnrelatedCompileAccepted"],
+           "typedFreshSeconds": family["typedFreshSeconds"], "cliParallelReuseSeconds": family["cliParallelReuseSeconds"],
+           "wholeCampaignRepeatsPerCondition": 1, "outcomes": family["outcomes"]}
+    for label, report in [("typedFresh", family["typedReport"]), ("cliParallelReuse", family["cliReport"])]:
+        observations = stages(report)
+        baseline = [s["durationSeconds"] for role, s in observations if role == "baseline-build"]
+        row[label] = {"baselineBuildInvocations": len(baseline), "baselineBuildTiming": timing(baseline),
+                      "sumBuildStageSeconds": sum(s["durationSeconds"] for _, s in observations if s["stage"] == "build"),
+                      "sumAllStageSeconds": sum(s["durationSeconds"] for _, s in observations)}
+    row["typedElapsedMinusStagesSeconds"] = row["typedFreshSeconds"] - row["typedFresh"]["sumAllStageSeconds"]
+    families.append(row)
+    samples = []
+    with tempfile.TemporaryDirectory(prefix="migration-authoring-", dir=root / ".heni-runs") as scratch:
+        scratch = Path(scratch)
+        shutil.copytree(fixture, scratch / "source")
+        for _ in range(3):
+            started = time.perf_counter()
+            value = copy.deepcopy(definition)
+            new = copy.deepcopy(value["cases"][0])
+            new.update(id="authoring-variant", description="A new harmless label change demonstrates a surviving case.")
+            if name == "patch-catalogue":
+                new["edits"] = [{"kind": "patch", "content": (fixture / "patches/survivor.patch").read_text()}]
+            else:
+                new["edits"] = [{"kind": "replace", "path": {"generated-code": "model.txt", "diagnostic-checker": "policy.txt"}.get(name, "src/Capacity.hs"), "old": "baseline", "new": "authoring-variant"}]
+            value["cases"].append(new)
+            value["sourceRoot"] = "source"
+            manifest = scratch / "heni.json"
+            manifest.write_text(json.dumps(value, indent=2) + "\n")
+            checked = subprocess.run([str(cli), "check", str(manifest), "--json"], capture_output=True, text=True, check=True)
+            assert json.loads(checked.stdout)["testsExecuted"] is False
+            samples.append(time.perf_counter() - started)
+        if name == "diagnostic-checker":
+            wrong = copy.deepcopy(definition)
+            wrong["sourceRoot"] = "source"
+            wrong["cases"][0].update(id="wrong-diagnostic", edits=[{"kind": "replace", "path": "policy.txt", "old": "timeout=30", "new": "timeout=-1"}])
+            manifest.write_text(json.dumps(wrong, indent=2) + "\n")
+            checked = subprocess.run([str(cli), "run", str(manifest), "--output", str(scratch / "wrong-run"), "--json"], capture_output=True, text=True)
+            assert checked.returncode == 1
+            case = json.loads(checked.stdout)["result"]["cases"][0]
+            observed = (scratch / "wrong-run/wrong-diagnostic-candidate-test.stdout.log").read_text().strip()
+            assert case["verdict"] == "wrong-failure" and observed == "policy.timeout: duration must be positive"
+            diagnostic_negative = {"verdict": case["verdict"], "expected": "policy.capacity: maximum must be positive", "observed": observed, "exit": 1}
+    authoring.append({"family": name, "templateFieldsChanged": ["id", "description", "edits"], "validationCommands": 1,
+                      "testsExecutedDuringCheck": False, "automatedSerializationAndCheck": timing(samples)})
+
+report_root = Path(trial["evidenceDirectory"]) / "rich-cli"
+diagnosis = []
+for _ in range(5):
+    started = time.perf_counter()
+    stored = json.loads((report_root / "report.json").read_text())
+    case = next(c for c in stored["result"]["cases"] if c["id"] == "wrong-named")
+    expected = next(c for c in stored["metadata"]["definitions"] if c["id"] == "wrong-named")["expect"]["failures"]
+    stage = next(s for s in case["evidence"]["stages"] if s["stage"] == "test")
+    protocol = next(p for p in stage["logPaths"] if p.endswith("evidence.jsonl"))
+    events = [json.loads(line) for line in (report_root / protocol).read_text().splitlines()]
+    actual = [e["testId"] for e in events if e["kind"] == "failed"]
+    assert case["verdict"] == "wrong-failure" and expected == ["suite/intended"] and actual == ["suite/other"]
+    diagnosis.append(time.perf_counter() - started)
+
+with tempfile.TemporaryDirectory(prefix="migration-reproduce-", dir=root / ".heni-runs") as scratch:
+    started = time.perf_counter()
+    result = subprocess.run([str(cli), "run", str(root / "examples/migrations/diagnostic-checker/heni.json"), "--output", str(Path(scratch) / "run"), "--json"], capture_output=True, text=True, check=True)
+    reproduction_seconds = time.perf_counter() - started
+    reproduced = json.loads(result.stdout)["result"]["cases"]
+    assert [c["verdict"] for c in reproduced] == ["caught"]
+
+measurement = {"operator": "automated Python observation of agent-authored fixtures; no human effort inference",
+               "method": "nonblank/noncomment physical lines inside named Legacy.hs regions; local wrapper line included; definitions subtracted",
+               "comparisonSharedOrchestrationLOC": len(region("shared-orchestration")), "platform": trial["platform"],
+               "families": families, "authoring": authoring, "diagnosticRuleMismatch": diagnostic_negative,
+               "diagnosis": {"artifactReads": 2, "expectedFailures": expected, "actualFailures": actual, "automatedReadTime": timing(diagnosis)},
+               "reproduction": {"commands": 1, "case": "diagnostic-checker/invalid-capacity", "exit": 0, "verdict": "caught", "seconds": reproduction_seconds},
+               "limits": ["Whole-campaign variance is not estimated from one run per condition.", "Fresh library and parallel/reuse CLI conditions differ.", "Elapsed-minus-stages includes copies, edits and report IO; it is not isolated CPU overhead.", "Authoring/diagnosis timings exclude human reasoning."]}
+args.output.parent.mkdir(parents=True, exist_ok=True)
+args.output.write_text(json.dumps(measurement, indent=2) + "\n")
+print("Recorded automated scaffolding, authoring/check, diagnosis, reproduction and runtime observations")
diff --git a/scripts/test-migrations.sh b/scripts/test-migrations.sh
new file mode 100644
--- /dev/null
+++ b/scripts/test-migrations.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -eu
+# Explicit cross-package validation; the core package does not depend on CLI.
+cabal build heni-cli:exe:heni -j1
+HENI_CLI_EXE=$(cabal list-bin heni-cli:exe:heni)
+HENI_MIGRATION_RESULTS=${HENI_MIGRATION_RESULTS:-"$(pwd)/.heni-runs/migration-results.json"}
+export HENI_CLI_EXE HENI_MIGRATION_RESULTS
+cabal test heni:heni-migration -f migration-tests -j1 --test-show-details=direct "$@"
diff --git a/src/Test/Heni.hs b/src/Test/Heni.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni.hs
@@ -0,0 +1,23 @@
+-- | Deliberate mutation definitions and trustworthy evidence classification.
+--
+-- Construct and validate a 'CaseDefinition', collect evidence, then call
+-- 'classifyCase'. The execution runner is added by the campaign implementation;
+-- this module never prints output or terminates the embedding application.
+module Test.Heni
+  ( module Test.Heni.Case,
+    module Test.Heni.Command,
+    module Test.Heni.Expectation,
+    module Test.Heni.Result,
+    module Test.Heni.Runner,
+    module Test.Heni.Report,
+    module Test.Heni.Manifest,
+  )
+where
+
+import Test.Heni.Case
+import Test.Heni.Command
+import Test.Heni.Expectation
+import Test.Heni.Manifest
+import Test.Heni.Report
+import Test.Heni.Result
+import Test.Heni.Runner
diff --git a/src/Test/Heni/Adapter.hs b/src/Test/Heni/Adapter.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Adapter.hs
@@ -0,0 +1,360 @@
+-- | Attempt-bound framework-neutral JSON Lines evidence. Reporters describe
+-- observations; only the core classifier chooses a verdict.
+module Test.Heni.Adapter
+  ( EvidenceAttempt (..),
+    EvidenceError (..),
+    TestObservation (..),
+    SuiteEvidence (..),
+    newRunIdentity,
+    newEvidenceAttempt,
+    evidencePath,
+    evidenceRelativePath,
+    attemptEnvironment,
+    currentEvidenceAttempt,
+    emitSuite,
+    emitCurrentSuite,
+    readEvidence,
+    stableTestId,
+    recordPropertyReplay,
+    readPropertyReplay,
+    attemptIdentityJSON,
+    guardEvidenceAttempt,
+  )
+where
+
+import Control.Exception
+import Control.Monad (foldM, forM_, unless, when)
+import Data.Aeson
+import Data.Aeson.KeyMap qualified as KM
+import Data.Aeson.Types (Pair, Parser, parseEither)
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as Text
+import System.Directory qualified as Dir
+import System.Environment (lookupEnv)
+import System.Exit (ExitCode (..))
+import System.FilePath (takeDirectory, takeFileName, (</>))
+import System.IO
+import System.Posix.Temp (mkdtemp)
+import Test.Heni.Case
+import Test.Heni.Command
+import Test.Heni.Expectation
+import Test.Heni.Internal.Codec qualified as Codec
+import Test.Heni.Internal.Files (WorkspaceError, checkedPath)
+import Prelude hiding (sequence)
+
+-- | Fresh identity allocated by the runner, never inferred from terminal text.
+data EvidenceAttempt = EvidenceAttempt
+  { outputRoot :: !FilePath,
+    directoryName :: !FilePath,
+    runId :: !Text,
+    caseId :: !CaseId,
+    stage :: !Stage,
+    role :: !Text
+  }
+  deriving stock (Eq, Show)
+
+-- | Protocol failures remain distinct from assertion failures.
+newtype EvidenceError = EvidenceError Text
+  deriving stock (Eq, Show)
+  deriving anyclass (Exception)
+
+-- | Complete per-test observations with stable full IDs.
+data TestObservation = TestPassed !Text | TestFailed !Text | TestSkipped !Text | TestSetupFailed !Text !Text
+  deriving stock (Eq, Show)
+
+-- | Unknown unfiltered discovery is honest; selected/executed identities are exact.
+data SuiteEvidence = SuiteEvidence
+  { producerVersion :: !Text,
+    discoveredIds :: !(Maybe [Text]),
+    selectedIds :: ![Text],
+    observations :: ![TestObservation],
+    setupErrors :: ![Text],
+    replayEvidence :: !(Maybe ReplayEvidence),
+    counterexample :: ![Text]
+  }
+  deriving stock (Eq, Show)
+
+-- | Campaign identity backed by an exclusively created private marker directory.
+newRunIdentity :: FilePath -> IO Text
+newRunIdentity output = do
+  root <- Dir.makeAbsolute output
+  Text.pack . takeFileName <$> mkdtemp (root </> ".identity-")
+
+-- | Allocate a fresh destination for every baseline, candidate and replay attempt.
+newEvidenceAttempt :: FilePath -> Text -> CaseId -> Stage -> Text -> IO EvidenceAttempt
+newEvidenceAttempt output runId caseId stage role = do
+  outputRoot <- Dir.makeAbsolute output
+  directoryName <- takeFileName <$> mkdtemp (outputRoot </> ".attempt-")
+  pure EvidenceAttempt {outputRoot, directoryName, runId, caseId, stage, role}
+
+-- | Absolute destination supplied only to the child process.
+evidencePath :: EvidenceAttempt -> FilePath
+evidencePath a@EvidenceAttempt {outputRoot} = outputRoot </> evidenceRelativePath a
+
+-- | Portable relative artifact reference.
+evidenceRelativePath :: EvidenceAttempt -> FilePath
+evidenceRelativePath EvidenceAttempt {directoryName} = directoryName </> "evidence.jsonl"
+
+-- | Reserved environment entries, replaced freshly for each attempt.
+attemptEnvironment :: EvidenceAttempt -> [(String, String)]
+attemptEnvironment a@EvidenceAttempt {runId, caseId, stage, role, directoryName} =
+  [ ("HENI_EVIDENCE_FILE", evidencePath a),
+    ("HENI_RUN_ID", Text.unpack runId),
+    ("HENI_CASE_ID", Text.unpack (caseIdText caseId)),
+    ("HENI_STAGE", Text.unpack (stageText stage)),
+    ("HENI_ROLE", Text.unpack role),
+    ("HENI_ATTEMPT", directoryName)
+  ]
+
+-- | Normal direct framework execution has no destination. Partial Heni identity
+-- configuration fails instead of emitting ambiguously attributed evidence.
+currentEvidenceAttempt :: IO (Maybe EvidenceAttempt)
+currentEvidenceAttempt =
+  lookupEnv "HENI_EVIDENCE_FILE" >>= \case
+    Nothing -> pure Nothing
+    Just file -> do
+      let required key = lookupEnv key >>= maybe (throwIO (EvidenceError ("missing " <> Text.pack key))) pure
+      runId <- Text.pack <$> required "HENI_RUN_ID"
+      caseId <- required "HENI_CASE_ID" >>= either (throwIO . EvidenceError . Text.pack . show) pure . mkCaseId . Text.pack
+      stage <- required "HENI_STAGE" >>= either (throwIO . EvidenceError . Text.pack) pure . parseEither (Codec.enumParser Codec.stageTable) . String . Text.pack
+      role <- Text.pack <$> required "HENI_ROLE"
+      nonce <- required "HENI_ATTEMPT"
+      unless (takeFileName file == "evidence.jsonl" && takeFileName (takeDirectory file) == nonce) (throwIO (EvidenceError "evidence destination/attempt mismatch"))
+      pure (Just (EvidenceAttempt (takeDirectory (takeDirectory file)) nonce runId caseId stage role))
+
+-- | Escape path separators and tildes so nested or parameterized names retain
+-- unambiguous identity. Duplicate resulting IDs are rejected by the protocol.
+stableTestId :: [Text] -> Text
+stableTestId = Text.intercalate "/" . map (Text.replace "/" "~1" . Text.replace "~" "~0")
+
+validateAttempt :: EvidenceAttempt -> IO ()
+validateAttempt EvidenceAttempt {runId, role, directoryName} = do
+  when (Text.null runId || role `notElem` ["baseline", "candidate"]) (throwIO (EvidenceError "invalid run identity or attempt role"))
+  unless (takeFileName directoryName == directoryName) (throwIO (EvidenceError "invalid attempt directory name"))
+  either (throwIO . EvidenceError . Text.pack . show) (const (pure ())) (mkRelativeTarget directoryName)
+
+identityPairs :: EvidenceAttempt -> [Pair]
+identityPairs EvidenceAttempt {runId, caseId, stage, role, directoryName} =
+  ["protocolVersion" .= (1 :: Int), "runId" .= runId, "caseId" .= caseIdText caseId, "stage" .= String (stageText stage), "role" .= role, "attempt" .= directoryName]
+
+stageText :: Stage -> Text
+stageText stage = case Codec.enumValue Codec.stageTable stage of String t -> t; _ -> error "stage table"
+
+-- | Shared identity object for other attempt-bound evidence channels.
+attemptIdentityJSON :: EvidenceAttempt -> Value
+attemptIdentityJSON = object . identityPairs
+
+-- | Emit complete observed facts after framework completion. No completion is
+-- written when execution is partial. Files must be fresh, and are capped at 8MiB.
+emitSuite :: EvidenceAttempt -> SuiteEvidence -> IO ()
+emitSuite attempt suite@SuiteEvidence {producerVersion, discoveredIds, selectedIds, observations, setupErrors, replayEvidence, counterexample} = do
+  validateAttempt attempt
+  either (throwIO . EvidenceError) pure (validateSuite suite)
+  exists <- Dir.doesPathExist (evidencePath attempt)
+  when exists (throwIO (EvidenceError "evidence destination already exists"))
+  let begin = ("begin", ["producerVersion" .= producerVersion, "discoveredIds" .= discoveredIds, "selectedIds" .= selectedIds])
+      testRecords = concatMap observationRecords observations
+      setup = [("setup-error", ["message" .= why]) | why <- setupErrors]
+      replayRecords = maybe [] (\r -> [("replay", ["replay" .= Codec.replayJSON r, "counterexample" .= counterexample])]) replayEvidence
+      complete = ("complete", ["counts" .= countsJSON suite])
+      records = [object (identityPairs attempt ++ ["sequence" .= n, "kind" .= String kind] ++ fields) | (n, (kind, fields)) <- zip [0 :: Int ..] (begin : testRecords ++ setup ++ replayRecords ++ [complete])]
+      bytes = LBS.concat [encode record <> "\n" | record <- records]
+  when (LBS.length bytes > 8388608) (throwIO (EvidenceError "test evidence exceeds 8MiB"))
+  withBinaryFile (evidencePath attempt) WriteMode $ \file -> LBS.hPut file bytes >> hFlush file
+
+-- | Reporter convenience: ordinary execution remains usable without Heni.
+emitCurrentSuite :: SuiteEvidence -> IO ()
+emitCurrentSuite suite = currentEvidenceAttempt >>= mapM_ (`emitSuite` suite)
+
+observationId :: TestObservation -> Text
+observationId = \case TestPassed t -> t; TestFailed t -> t; TestSkipped t -> t; TestSetupFailed t _ -> t
+
+observationRecords :: TestObservation -> [(Text, [Pair])]
+observationRecords observation = case observation of
+  TestPassed t -> [("started", ["testId" .= t]), ("passed", ["testId" .= t])]
+  TestFailed t -> [("started", ["testId" .= t]), ("failed", ["testId" .= t])]
+  TestSkipped t -> [("skipped", ["testId" .= t])]
+  TestSetupFailed t why -> [("setup-failed", ["testId" .= t, "message" .= why])]
+
+validateSuite :: SuiteEvidence -> Either Text ()
+validateSuite SuiteEvidence {producerVersion, discoveredIds, selectedIds, observations, replayEvidence} = do
+  when (Text.null producerVersion) (Left "missing reporter version identity")
+  unless (unique selectedIds && all (not . Text.null) selectedIds) (Left "empty or duplicate selected test identity")
+  forM_ discoveredIds $ \ids -> unless (unique ids && all (not . Text.null) ids && Set.fromList selectedIds `Set.isSubsetOf` Set.fromList ids) (Left "selected identities do not agree with discovery")
+  let observed = map observationId observations
+      failed = Set.fromList [t | TestFailed t <- observations]
+  unless (unique observed && Set.fromList selectedIds == Set.fromList observed) (Left "incomplete or conflicting selected test observations")
+  forM_ replayEvidence $ \ReplayEvidence {token, generator, failures} ->
+    unless (not (Text.null token || Text.null generator) && Text.length token <= 65536 && Text.length generator <= 65536 && failures `Set.isSubsetOf` failed && not (Set.null failures)) (Left "replay does not identify the observed failures")
+  where
+    unique xs = Set.size (Set.fromList xs) == length xs
+
+countsJSON :: SuiteEvidence -> Value
+countsJSON SuiteEvidence {discoveredIds, selectedIds, observations, setupErrors} =
+  object
+    [ "discovered" .= fmap length discoveredIds,
+      "selected" .= length selectedIds,
+      "executed" .= length [() | o <- observations, case o of TestPassed {} -> True; TestFailed {} -> True; _ -> False],
+      "passed" .= length [() | TestPassed {} <- observations],
+      "failed" .= length [() | TestFailed {} <- observations],
+      "skipped" .= length [() | TestSkipped {} <- observations],
+      "setupErrors" .= (length setupErrors + length [() | TestSetupFailed {} <- observations])
+    ]
+
+data Record
+  = Begin !Text !(Maybe [Text]) ![Text]
+  | Started !Text
+  | Finished !TestObservation
+  | SetupError !Text
+  | ReplayRecord !ReplayEvidence ![Text]
+  | Complete !Value
+  deriving stock (Show)
+
+parseRecord :: EvidenceAttempt -> Int -> Value -> Parser Record
+parseRecord expected index value =
+  withObject
+    "test-evidence record"
+    ( \o -> do
+        kind <- o .: "kind" :: Parser Text
+        let fields = case kind of "begin" -> ["producerVersion", "discoveredIds", "selectedIds"]; "setup-error" -> ["message"]; "invalidated" -> ["message"]; "replay" -> ["replay", "counterexample"]; "complete" -> ["counts"]; "setup-failed" -> ["testId", "message"]; _ -> ["testId"]
+        Codec.strictObject
+          "test-evidence record"
+          (["protocolVersion", "runId", "caseId", "stage", "role", "attempt", "sequence", "kind"] ++ fields)
+          ( \record -> do
+              version <- record .: "protocolVersion"
+              unless (version == (1 :: Int)) (fail "unsupported evidence protocol version")
+              forM_ (identityPairs expected) $ \(key, wanted) -> unless (KM.lookup key record == Just wanted) (fail "stale or wrong run/case/stage/attempt identity")
+              when (kind == "invalidated") $ do
+                why <- record .: "message"
+                fail ("reporter invalidated attempt: " ++ why)
+              sequence <- record .: "sequence"
+              unless (sequence == index) (fail "evidence event sequence gap or duplicate")
+              case kind of
+                "begin" -> Begin <$> record .: "producerVersion" <*> record .: "discoveredIds" <*> record .: "selectedIds"
+                "started" -> Started <$> record .: "testId"
+                "passed" -> Finished . TestPassed <$> record .: "testId"
+                "failed" -> Finished . TestFailed <$> record .: "testId"
+                "skipped" -> Finished . TestSkipped <$> record .: "testId"
+                "setup-failed" -> Finished <$> (TestSetupFailed <$> record .: "testId" <*> record .: "message")
+                "setup-error" -> SetupError <$> record .: "message"
+                "replay" -> ReplayRecord <$> (record .: "replay" >>= Codec.parseReplay) <*> record .: "counterexample"
+                "complete" -> Complete <$> record .: "counts"
+                _ -> fail "unknown evidence event kind"
+          )
+          value
+    )
+    value
+
+-- | Read only the fresh attempt's bounded file and verify every identity,
+-- sequence, execution transition, completion count and failure membership.
+readEvidence :: EvidenceAttempt -> IO (Either Text (SelectionEvidence, Maybe ReplayEvidence, Text))
+readEvidence attempt = evidenceIO $ do
+  validateAttempt attempt
+  bytes <- readAttemptFile attempt "evidence.jsonl"
+  unless (not (BS.null bytes) && BS.last bytes == 10) (throwIO (EvidenceError "incomplete evidence: missing final newline/completion"))
+  let lines' = init (BS.split 10 bytes)
+  values <- either (throwIO . EvidenceError . Text.pack) pure (mapM Codec.decodeJSON lines')
+  records <- either (throwIO . EvidenceError . Text.pack) pure (mapM (\(index, value) -> parseEither (parseRecord attempt index) value) (zip [0 ..] values))
+  suite <- either (throwIO . EvidenceError) pure (normalize records)
+  let SuiteEvidence {producerVersion, discoveredIds, selectedIds, observations, setupErrors, replayEvidence} = suite
+      executedIds = Set.fromList [t | o <- observations, t <- case o of TestPassed t -> [t]; TestFailed t -> [t]; _ -> []]
+      failedIds = Set.fromList [t | TestFailed t <- observations]
+      skippedIds = Set.fromList [t | TestSkipped t <- observations]
+      errors = setupErrors ++ [t <> ": " <> why | TestSetupFailed t why <- observations]
+  pure (SelectionEvidence (fmap length discoveredIds) (Just (length selectedIds)) (Just (Set.size executedIds)) executedIds failedIds skippedIds errors, replayEvidence, producerVersion)
+
+normalize :: [Record] -> Either Text SuiteEvidence
+normalize records = case records of
+  Begin producer discovered selected : rest -> case reverse rest of
+    Complete counts : reversed -> do
+      (_, finished, errors, replay, counterexample) <- foldM (step (Set.fromList selected)) (Set.empty, Map.empty, [], Nothing, []) (reverse reversed)
+      let suite = SuiteEvidence producer discovered selected (Map.elems finished) errors replay counterexample
+      validateSuite suite
+      unless (counts == countsJSON suite) (Left "completion counts contradict observed identities")
+      pure suite
+    _ -> Left "missing final completion record"
+  _ -> Left "missing initial evidence identity/selection record"
+  where
+    step selected (started, finished, errors, replay, counterexample) record = case record of
+      Started t -> do
+        unless (t `Set.member` selected && t `Set.notMember` started && Map.notMember t finished) (Left "unexpected or repeated test start")
+        pure (Set.insert t started, finished, errors, replay, counterexample)
+      Finished observation -> do
+        let t = observationId observation
+            needsStart = case observation of TestPassed {} -> True; TestFailed {} -> True; _ -> False
+        unless (t `Set.member` selected && Map.notMember t finished && (t `Set.member` started) == needsStart) (Left "conflicting test completion/start identity")
+        pure (started, Map.insert t observation finished, errors, replay, counterexample)
+      SetupError why -> pure (started, finished, errors ++ [why], replay, counterexample)
+      ReplayRecord value examples -> case replay of
+        Just _ -> Left "duplicate replay evidence"
+        Nothing -> pure (started, finished, errors, Just value, examples)
+      _ -> Left "duplicate or misplaced begin/completion record"
+
+readAttemptFile :: EvidenceAttempt -> FilePath -> IO BS.ByteString
+readAttemptFile EvidenceAttempt {outputRoot, directoryName} name = do
+  target <- either (throwIO . EvidenceError . Text.pack . show) pure (mkRelativeTarget (directoryName </> name))
+  path <- checkedPath outputRoot target
+  bytes <- withBinaryFile path ReadMode (\file -> BS.hGet file 8388609)
+  when (BS.length bytes > 8388608) (throwIO (EvidenceError "test evidence exceeds 8MiB"))
+  pure bytes
+
+-- | Optional framework property helpers record the exact seed/size token and
+-- generator contract in a separate fresh sidecar, later included in protocol.
+recordPropertyReplay :: ReplayEvidence -> [Text] -> IO ()
+recordPropertyReplay replay counterexample =
+  currentEvidenceAttempt
+    >>= mapM_
+      ( \attempt@EvidenceAttempt {outputRoot, directoryName} -> do
+          let path = outputRoot </> directoryName </> "property.json"
+          exists <- Dir.doesPathExist path
+          when exists (throwIO (EvidenceError "one QuickCheck replay property per attempt is supported"))
+          LBS.writeFile path (encode (object (identityPairs attempt ++ ["replay" .= Codec.replayJSON replay, "counterexample" .= counterexample])))
+      )
+
+-- | Validate a helper sidecar before a reporter incorporates it into completion.
+readPropertyReplay :: EvidenceAttempt -> IO (Maybe (ReplayEvidence, [Text]))
+readPropertyReplay attempt@EvidenceAttempt {outputRoot, directoryName} = do
+  exists <- Dir.doesPathExist (outputRoot </> directoryName </> "property.json")
+  if not exists
+    then pure Nothing
+    else do
+      bytes <- readAttemptFile attempt "property.json"
+      value <- either (throwIO . EvidenceError . Text.pack) pure (Codec.decodeJSON bytes)
+      either
+        (throwIO . EvidenceError . Text.pack)
+        (pure . Just)
+        ( parseEither
+            ( Codec.strictObject "property replay" ["protocolVersion", "runId", "caseId", "stage", "role", "attempt", "replay", "counterexample"] $ \o -> do
+                forM_ (identityPairs attempt) $ \(key, wanted) -> unless (KM.lookup key o == Just wanted) (fail "stale property replay identity")
+                (,) <$> (o .: "replay" >>= Codec.parseReplay) <*> o .: "counterexample"
+            )
+            value
+        )
+
+evidenceIO :: IO a -> IO (Either Text a)
+evidenceIO action =
+  catches
+    (Right <$> action)
+    [ Handler (\(EvidenceError why) -> pure (Left why)),
+      Handler (\(e :: IOException) -> pure (Left (Text.pack (show e)))),
+      Handler (\(e :: WorkspaceError) -> pure (Left (Text.pack (show e))))
+    ]
+
+-- | An exception after a framework wrote completion must invalidate that proof,
+-- while retaining the prior artifact. Ordinary framework success/failure exits
+-- are preserved. The runner still owns timeout and cancellation.
+guardEvidenceAttempt :: EvidenceAttempt -> IO a -> IO a
+guardEvidenceAttempt attempt action =
+  action `catch` \(e :: SomeException) -> do
+    case fromException e :: Maybe ExitCode of
+      Just ExitSuccess -> pure ()
+      Just (ExitFailure 1) -> pure ()
+      _ -> do
+        let line = encode (object (identityPairs attempt ++ ["sequence" .= (-1 :: Int), "kind" .= String "invalidated", "message" .= displayException e])) <> "\n"
+        _ <- try (LBS.appendFile (evidencePath attempt) line) :: IO (Either IOException ())
+        pure ()
+    throwIO e
diff --git a/src/Test/Heni/BuildReuse.hs b/src/Test/Heni/BuildReuse.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/BuildReuse.hs
@@ -0,0 +1,100 @@
+-- | Explicit build-tool contracts for retaining private worker artifacts.
+-- Generic commands have no automatic invalidation guarantee and default fresh.
+module Test.Heni.BuildReuse
+  ( BuildReuse (..),
+    BuildWorker,
+    BuildReuseError (..),
+    validateBuildReuse,
+    withBuildWorker,
+    withBuildCandidate,
+    confirmBuild,
+    retireBuildWorker,
+  )
+where
+
+import Control.Exception
+import Control.Monad (forM_, unless, when)
+import Data.IORef
+import Data.Text (Text)
+import Data.Text qualified as Text
+import System.Directory qualified as Dir
+import System.FilePath (splitDirectories, (</>))
+import Test.Heni.Case
+import Test.Heni.Internal.Files
+import Test.Heni.Workspace
+
+-- | A consumer promises a content-key-aware build tool. Before tests, the
+-- verifier must prove that the completed build observed HENI_BUILD_INPUT_ID.
+-- The preparation callback can enforce conservative timestamp behavior. Neither
+-- callback is an attestation against malicious commands. This hook is Haskell
+-- only because arbitrary build tools cannot share one reliable portable check.
+data BuildReuse = BuildReuse
+  { contractIdentity :: !Text,
+    retainedDirectories :: ![RelativeTarget],
+    prepareInputs :: !(Workspace -> IO ()),
+    verifyBuildObservation :: !(Workspace -> Text -> IO (Either Text ()))
+  }
+
+-- | Invalidation/reset failures invalidate the current case, never a kill.
+newtype BuildReuseError = BuildReuseError Text
+  deriving stock (Eq, Show)
+  deriving anyclass (Exception)
+
+-- | One private workspace and an in-memory command/environment contract.
+data BuildWorker key = BuildWorker !SourceSnapshot !BuildReuse !Workspace !(IORef (Maybe (key, Inventory))) !(IORef Bool)
+
+-- | Retain only known top-level build directories, never selected source paths.
+validateBuildReuse :: BuildReuse -> Either ConfigError ()
+validateBuildReuse BuildReuse {contractIdentity, retainedDirectories} = do
+  unless (not (Text.null (Text.strip contractIdentity)) && not (null retainedDirectories)) (Left (ConfigError "buildReuse" "nonempty contract and build directories required"))
+  unless (all (\p -> targetPath p `elem` ["dist", "dist-newstyle", ".heni-build"]) retainedDirectories) (Left (ConfigError "buildReuse.directories" "only dist, dist-newstyle or .heni-build can be retained"))
+
+-- | The workspace is never shared with another worker and is discarded at exit.
+withBuildWorker :: SourceSnapshot -> BuildReuse -> (BuildWorker key -> IO a) -> IO a
+withBuildWorker snapshot provider action = withWorkspace snapshot $ \workspace -> do
+  previous <- newIORef Nothing
+  dirty <- newIORef True
+  action (BuildWorker snapshot provider workspace previous dirty)
+
+-- | Retire retained artifacts after any suspicious lifecycle result.
+retireBuildWorker :: BuildWorker key -> IO ()
+retireBuildWorker (BuildWorker _ _ _ _ dirty) = writeIORef dirty True
+
+-- | Independently verify the previous source tree, restore exact staged bytes,
+-- preserve private build directories, and verify source again after use. A
+-- retired worker starts by deleting all retained artifacts from its owned tree.
+withBuildCandidate :: (Eq key) => BuildWorker key -> key -> Workspace -> (Workspace -> Text -> IO a) -> IO a
+withBuildCandidate worker@(BuildWorker snapshot BuildReuse {retainedDirectories, prepareInputs} workspace previous dirty) key staged action =
+  operation `onException` retireBuildWorker worker
+  where
+    names = map targetPath retainedDirectories
+    excluded path = case splitDirectories path of first : _ -> first `elem` names; [] -> False
+    operation = do
+      old <- readIORef previous
+      retired <- readIORef dirty
+      when (retired || maybe True ((/= key) . fst) old) (resetWorkspace snapshot workspace >> writeIORef previous Nothing)
+      saved <- readIORef previous
+      forM_ saved $ \(_, expected) ->
+        inventory excluded (workspaceRoot workspace) >>= \actual ->
+          unless (actual == expected) (throwIO (BuildReuseError "unexpected source/generated state in retained worker"))
+      expected <- inventory (const False) (workspaceRoot staged)
+      entries <- Dir.listDirectory (workspaceRoot workspace)
+      forM_ (filter (`notElem` names) entries) (Dir.removePathForcibly . (workspaceRoot workspace </>))
+      materialize (workspaceRoot workspace) expected
+      actual <- inventory excluded (workspaceRoot workspace)
+      unless (actual == expected) (throwIO (BuildReuseError "restored build inputs do not match staged mutation"))
+      let inputId = inventoryDigest "heni-build-input-v1" expected
+      prepareInputs workspace
+      verified <- inventory excluded (workspaceRoot workspace)
+      unless (verified == expected) (throwIO (BuildReuseError "build preparation changed selected input bytes"))
+      value <- action workspace inputId
+      final <- inventory excluded (workspaceRoot workspace)
+      unless (final == expected) (throwIO (BuildReuseError "build/test changed source or created undeclared generated inputs"))
+      writeIORef previous (Just (key, expected))
+      writeIORef dirty False
+      pure value
+
+-- | Check the build-tool observation before running tests against its artifacts.
+confirmBuild :: BuildWorker key -> Text -> IO ()
+confirmBuild (BuildWorker _ BuildReuse {verifyBuildObservation} workspace _ _) inputId =
+  verifyBuildObservation workspace inputId >>= either (throwIO . BuildReuseError) pure
diff --git a/src/Test/Heni/Case.hs b/src/Test/Heni/Case.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Case.hs
@@ -0,0 +1,55 @@
+-- | Case definitions and validated portable identities.
+module Test.Heni.Case
+  ( CaseId,
+    mkCaseId,
+    caseIdText,
+    RelativeTarget,
+    mkRelativeTarget,
+    targetPath,
+    SelectedCases,
+    mkSelectedCases,
+    selectedCaseIds,
+    ConfigError (..),
+    CaseDefinition (..),
+    EvidenceMode (..),
+    Edit (..),
+    CaseHooks (..),
+    WorkspaceOps (..),
+    noHooks,
+    validateCase,
+  )
+where
+
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Test.Heni.Internal.Model
+
+-- | Reject invalid expectations and edits before invoking any command.
+validateCase :: CaseDefinition -> Either ConfigError ()
+validateCase CaseDefinition {description, edits, test, expectation, selector, evidenceMode}
+  | Text.null (Text.strip description) = bad "description" "expected an explanation of the fault"
+  | maybe False (Text.null . Text.strip) selector = bad "selector" "empty selector"
+  | expectation /= Control && null edits = bad "edits" "mutation requires edits"
+  | expectation == Control && not (null edits) = bad "edits" "controls must explicitly leave source unchanged"
+  | requiresTest expectation && test == Nothing = bad "test" "expectation requires a test command"
+  | CrashProbe {} <- expectation, evidenceMode == StructuredTestsV1 = bad "adapter" "crash probes use their dedicated attempt-bound channel"
+  | otherwise = mapM_ validateEdit edits >> validateExpectation expectation
+  where
+    bad f m = Left (ConfigError f m)
+    requiresTest BuildRejection {} = False
+    requiresTest (Diagnostic Build _ _) = False
+    requiresTest _ = True
+    validateEdit (Replace _ old new n)
+      | Text.null old || old == new || n <= 0 = bad "edits.replace" "expected nonempty distinct text and a positive exact count"
+    validateEdit (Patch p) | Text.null (Text.strip p) = bad "edits.patch" "empty patch"
+    validateEdit (CustomEdit k) | Text.null k = bad "edits.custom" "empty hook key"
+    validateEdit _ = Right ()
+    validateExpectation (NamedTests ids _)
+      | Set.null ids || any Text.null ids = bad "expect.namedTests" "expected nonempty named failure set"
+    validateExpectation (Diagnostic s code witness)
+      | s `notElem` [Build, Test] || code <= 0 || Text.null witness = bad "expect.diagnostic" "expected build/test stage, nonzero exit and witness"
+    validateExpectation (BuildRejection code witness)
+      | code <= 0 || Text.null witness = bad "expect.buildRejection" "expected nonzero exit and witness"
+    validateExpectation (CrashProbe sig probe)
+      | sig <= 0 || Text.null probe = bad "expect.crash" "expected positive signal and a case-specific probe"
+    validateExpectation _ = Right ()
diff --git a/src/Test/Heni/Command.hs b/src/Test/Heni/Command.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Command.hs
@@ -0,0 +1,36 @@
+-- | Portable commands and validated budgets. Commands run without shell expansion.
+module Test.Heni.Command
+  ( CommandId,
+    mkCommandId,
+    commandIdText,
+    StageBudget,
+    mkStageBudget,
+    budgetMicroseconds,
+    Stage (..),
+    ProcessOutcome (..),
+    OutputPolicy (..),
+    Command (..),
+    validateCommand,
+  )
+where
+
+import Data.List (isPrefixOf)
+import Data.Set qualified as Set
+import Test.Heni.Internal.Model
+
+-- | Validate launch-independent constraints. Environment names must be unique.
+validateCommand :: Command -> Either ConfigError ()
+validateCommand Command {executable, arguments, sensitiveArguments, environment, output = OutputPolicy {limitBytes}}
+  | null executable || '\0' `elem` executable = bad "executable" "expected an executable without NUL"
+  | "./" `isPrefixOf` executable && invalidWorkspaceExecutable = bad "executable" "workspace executable must be a contained relative path"
+  | any (elem '\0') arguments = bad "arguments" "NUL in argument"
+  | any (\i -> i < 0 || i >= length arguments) (Set.toList sensitiveArguments) = bad "sensitiveArguments" "argument index is out of range"
+  | limitBytes <= 0 = bad "output.limitBytes" "expected a positive output limit"
+  | any (\(k, v) -> null k || any (`elem` k) ['=', '\0'] || '\0' `elem` v) environment = bad "environment" "invalid environment entry"
+  | length (map fst environment) /= length (unique (map fst environment)) = bad "environment" "duplicate environment key"
+  | otherwise = Right ()
+  where
+    invalidWorkspaceExecutable = case mkRelativeTarget (drop 2 executable) of Left _ -> True; Right _ -> False
+    bad f m = Left (ConfigError f m)
+    unique [] = []
+    unique (x : xs) = x : unique (filter (/= x) xs)
diff --git a/src/Test/Heni/Crash.hs b/src/Test/Heni/Crash.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Crash.hs
@@ -0,0 +1,71 @@
+-- | Fresh attempt-bound reachability probes for explicitly expected native signals.
+module Test.Heni.Crash (recordCrashProbe, writeCrashProbe, readCrashProbe, probeRelativePath) where
+
+import Control.Exception
+import Control.Monad (forM_, unless, when)
+import Data.Aeson
+import Data.Aeson.KeyMap qualified as KM
+import Data.Aeson.Types (parseEither)
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Text (Text)
+import Data.Text qualified as Text
+import System.Directory qualified as Dir
+import System.FilePath ((</>))
+import System.IO
+import Test.Heni.Adapter
+import Test.Heni.Case (mkRelativeTarget)
+import Test.Heni.Internal.Codec (decodeJSON, strictObject)
+import Test.Heni.Internal.Files (WorkspaceError, checkedPath)
+
+-- | Relative path kept with the report's other artifacts.
+probeRelativePath :: EvidenceAttempt -> FilePath
+probeRelativePath EvidenceAttempt {directoryName} = directoryName </> "probe.json"
+
+-- | Called immediately before the intended native operation. Outside Heni it is
+-- a no-op; under Heni it refuses to replace existing probe evidence.
+recordCrashProbe :: Text -> IO ()
+recordCrashProbe probe = currentEvidenceAttempt >>= mapM_ (`writeCrashProbe` probe)
+
+-- | Explicit form for non-environment integrations and conformance fixtures.
+writeCrashProbe :: EvidenceAttempt -> Text -> IO ()
+writeCrashProbe attempt@EvidenceAttempt {outputRoot} probe = do
+  when (Text.null probe || Text.length probe > 65536) (throwIO (EvidenceError "probe must be nonempty and bounded"))
+  let path = outputRoot </> probeRelativePath attempt
+  exists <- Dir.doesPathExist path
+  when exists (throwIO (EvidenceError "probe destination already exists"))
+  case attemptIdentityJSON attempt of
+    Object identity -> withBinaryFile path WriteMode $ \file -> LBS.hPut file (encode (Object (KM.insert "probe" (String probe) identity))) >> hFlush file
+    _ -> error "attempt identity object"
+
+-- | Missing, stale or mismatched probes never prove reachability. The caller
+-- retains the rejection reason alongside the actual exit/signal evidence.
+readCrashProbe :: EvidenceAttempt -> Text -> IO (Either Text ())
+readCrashProbe attempt@EvidenceAttempt {outputRoot} expected =
+  catches
+    (Right <$> action)
+    [ Handler (\(EvidenceError why) -> pure (Left why)),
+      Handler (\(e :: IOException) -> pure (Left (Text.pack (show e)))),
+      Handler (\(e :: WorkspaceError) -> pure (Left (Text.pack (show e))))
+    ]
+  where
+    action = do
+      target <- either (throwIO . EvidenceError . Text.pack . show) pure (mkRelativeTarget (probeRelativePath attempt))
+      path <- checkedPath outputRoot target
+      bytes <- withBinaryFile path ReadMode (\file -> BS.hGet file 262145)
+      when (BS.length bytes > 262144) (throwIO (EvidenceError "probe exceeds size limit"))
+      value <- either (throwIO . EvidenceError . Text.pack) pure (decodeJSON bytes)
+      identity <- case attemptIdentityJSON attempt of
+        Object fields -> pure fields
+        _ -> throwIO (EvidenceError "internal attempt identity is not an object")
+      either
+        (throwIO . EvidenceError . Text.pack)
+        pure
+        ( parseEither
+            ( strictObject "crash probe" ("probe" : KM.keys identity) $ \o -> do
+                forM_ (KM.toList identity) $ \(key, wanted) -> unless (KM.lookup key o == Just wanted) (fail "stale or wrong crash probe attempt identity")
+                probe <- o .: "probe"
+                unless (probe == expected) (fail "wrong crash reachability probe")
+            )
+            value
+        )
diff --git a/src/Test/Heni/Edit.hs b/src/Test/Heni/Edit.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Edit.hs
@@ -0,0 +1,156 @@
+-- | Transactional checked edits: the callback sees a candidate only after all
+-- preconditions and the independent final inventory have passed.
+module Test.Heni.Edit
+  ( EditError (..),
+    FileChange (..),
+    EditEvidence (..),
+    PatchRunner,
+    withEditedWorkspace,
+    workspaceOperations,
+  )
+where
+
+import Control.Exception
+import Control.Monad (forM_, unless, when)
+import Data.ByteString qualified as BS
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Text
+import System.Directory qualified as Dir
+import System.FilePath (splitDirectories, takeDirectory)
+import Test.Heni.Case
+import Test.Heni.Expectation
+import Test.Heni.Internal.Files
+import Test.Heni.Workspace
+
+-- | Stale/invalid mutations differ from ordinary filesystem infrastructure errors.
+data EditError = InvalidEdit !Text | EditInfrastructure !Text
+  deriving stock (Eq, Show)
+  deriving anyclass (Exception)
+
+-- | Actual before/after bytes, including creation, deletion and executable modes.
+data FileChange = FileChange {path :: !RelativeTarget, before :: !(Maybe FileEntry), after :: !(Maybe FileEntry)}
+  deriving stock (Eq, Show)
+
+-- | Independently recomputed changes, not a hook's claimed touched-file list.
+newtype EditEvidence = EditEvidence {changes :: [FileChange]}
+  deriving stock (Eq, Show)
+
+-- | Inject the common supervisor for optional Git. It must bound and reap children.
+-- Arguments are passed literally; the runner supplies its workspace as cwd.
+type PatchRunner = Workspace -> [String] -> IO (Either Text ())
+
+-- | Checked trusted-IO capabilities. Writes require existing files; creation and
+-- deletion have explicit byte preconditions and reject runner-owned paths.
+workspaceOperations :: Workspace -> WorkspaceOps
+workspaceOperations workspace = WorkspaceOps readText writeText createText deleteText
+  where
+    checked target = do
+      when (reserved target) (throwIO (InvalidEdit "edit targets runner-owned build/evidence metadata"))
+      checkedWorkspacePath workspace target
+    readText target = do
+      p <- checked target
+      b <- BS.readFile p
+      either (const (throwIO (InvalidEdit "target is not valid UTF-8"))) pure (Text.decodeUtf8' b)
+    writeText target content = do
+      _ <- readText target
+      p <- checked target
+      BS.writeFile p (Text.encodeUtf8 content)
+    createText target content = do
+      p <- checked target
+      exists <- Dir.doesPathExist p
+      when exists (throwIO (InvalidEdit "creation target already exists"))
+      Dir.createDirectoryIfMissing True (takeDirectory p)
+      BS.writeFile p (Text.encodeUtf8 content)
+    deleteText target expected = do
+      actual <- readText target
+      unless (actual == expected) (throwIO (InvalidEdit "deletion precondition failed"))
+      p <- checked target
+      Dir.removeFile p
+
+reserved :: RelativeTarget -> Bool
+reserved target = any (\p -> p `elem` [".git", "dist", "dist-newstyle", ".heni", "mutation-results"] || ".heni-" `Text.isPrefixOf` Text.pack p) (splitDirectories (targetPath target))
+
+-- | Allocate a private candidate, apply ordered edits, inspect all final bytes,
+-- then invoke the consumer. Failure or cancellation discards the entire tree.
+withEditedWorkspace :: forall a. PatchRunner -> CaseHooks -> SourceSnapshot -> CaseDefinition -> (Workspace -> EditEvidence -> IO a) -> IO (Either EditError a)
+withEditedWorkspace runPatch hooks snapshot definition action =
+  catches
+    (Right <$> withWorkspace snapshot candidate)
+    [ Handler (pure . Left :: EditError -> IO (Either EditError a)),
+      Handler (\e -> pure (Left (InvalidEdit (Text.pack (show (e :: WorkspaceError)))))),
+      Handler (\e -> pure (Left (EditInfrastructure (Text.pack (show (e :: IOException))))))
+    ]
+  where
+    candidate workspace = do
+      let CaseDefinition {edits, expectation} = definition
+      forM_ edits (apply workspace)
+      actual <- inventory (const False) (workspaceRoot workspace)
+      let original = snapshotFiles snapshot
+          changed =
+            [ FileChange p (Map.lookup p original) (Map.lookup p actual)
+            | p <- Set.toAscList (Map.keysSet original <> Map.keysSet actual),
+              Map.lookup p original /= Map.lookup p actual
+            ]
+      when (any (\FileChange {path} -> reserved path) changed) (throwIO (InvalidEdit "edit changed runner-owned metadata"))
+      when (expectation /= Control && null changed) (throwIO (InvalidEdit "mutation left all bytes unchanged"))
+      when (expectation == Control && not (null changed)) (throwIO (InvalidEdit "control changed source bytes"))
+      action workspace (EditEvidence changed)
+    apply workspace edit = do
+      let ops@WorkspaceOps {readTarget, writeTarget, createTarget, deleteTarget} = workspaceOperations workspace
+      case edit of
+        Replace target old new count -> do
+          when (Text.null old || old == new || count <= 0) (throwIO (InvalidEdit "invalid replacement definition"))
+          actual <- readTarget target
+          unless (Text.count old actual == count) (throwIO (InvalidEdit "replacement anchor count did not match"))
+          writeTarget target (Text.replace old new actual)
+        Create target content -> createTarget target content
+        Delete target expected -> deleteTarget target expected
+        CustomEdit key -> case hooks of
+          CaseHooks {customEdits} -> case lookup key customEdits of
+            Nothing -> throwIO (InvalidEdit ("missing custom edit hook: " <> key))
+            Just hook -> hook ops
+        Patch content -> do
+          targets <- either (throwIO . InvalidEdit) pure (patchTargets content)
+          mapM_ (checkedWorkspacePath workspace) targets
+          withOwnedDirectory ".heni-patch-" $ \scratch -> do
+            let patchFile = scratch ++ "/candidate.patch"
+            BS.writeFile patchFile (Text.encodeUtf8 content)
+            forM_ [["apply", "--check", "--", patchFile], ["apply", "--", patchFile]] $ \args -> do
+              result <- runPatch workspace args
+              either (throwIO . InvalidEdit) pure result
+
+patchTargets :: Text -> Either Text [RelativeTarget]
+patchTargets content
+  | any unsupported lines' = Left "only textual Git patches without rename/copy/mode/binary operations are supported"
+  | null headers = Left "patch requires diff --git headers"
+  | otherwise = do
+      diffs <- concat <$> mapM parse headers
+      paths <- mapM parseFileHeader (filter (\line -> "--- " `Text.isPrefixOf` line || "+++ " `Text.isPrefixOf` line) lines')
+      let allPaths = diffs ++ [p | Just p <- paths]
+      if any reserved allPaths then Left "patch targets runner-owned metadata" else Right allPaths
+  where
+    lines' = Text.lines content
+    headers = filter (Text.isPrefixOf "diff --git ") lines'
+    unsupported line =
+      any
+        (`Text.isPrefixOf` line)
+        ["GIT binary patch", "Binary files ", "rename ", "copy ", "old mode ", "new mode "]
+        || ("new file mode " `Text.isPrefixOf` line && line `notElem` ["new file mode 100644", "new file mode 100755"])
+        || ("deleted file mode " `Text.isPrefixOf` line && line `notElem` ["deleted file mode 100644", "deleted file mode 100755"])
+        || ("index " `Text.isPrefixOf` line && "120000" `Text.isSuffixOf` line)
+    parse line = case Text.words line of
+      ["diff", "--git", a, b] -> do
+        x <- pathAfter "a/" a
+        y <- pathAfter "b/" b
+        if x /= y then Left "patch renaming is unsupported" else Right [x, y]
+      _ -> Left "quoted or spaced patch paths are not supported"
+    pathAfter prefix t = case Text.stripPrefix prefix t of
+      Nothing -> Left "patch requires a/ and b/ paths"
+      Just p -> either (Left . Text.pack . show) Right (mkRelativeTarget (Text.unpack p))
+    parseFileHeader line
+      | Text.drop 4 line == "/dev/null" = Right Nothing
+      | "--- " `Text.isPrefixOf` line = Just <$> pathAfter "a/" (Text.drop 4 line)
+      | otherwise = Just <$> pathAfter "b/" (Text.drop 4 line)
diff --git a/src/Test/Heni/Expectation.hs b/src/Test/Heni/Expectation.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Expectation.hs
@@ -0,0 +1,12 @@
+-- | Explicit expectation and selection evidence. Framework types never enter the core.
+module Test.Heni.Expectation
+  ( Expectation (..),
+    ReplayPolicy (..),
+    ReplayEvidence (..),
+    SelectionEvidence (..),
+    unknownSelection,
+    StageEvidence (..),
+  )
+where
+
+import Test.Heni.Internal.Model
diff --git a/src/Test/Heni/Internal/Baseline.hs b/src/Test/Heni/Internal/Baseline.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Internal/Baseline.hs
@@ -0,0 +1,34 @@
+-- | Campaign-local single-flight baseline sharing. Keys may contain secrets and
+-- are never serialized or hashed for portable reports.
+module Test.Heni.Internal.Baseline (BaselineCache, newBaselineCache, clearBaselineCache, shareBaseline) where
+
+import Control.Concurrent.MVar
+import Control.Exception
+
+newtype BaselineCache key value = BaselineCache (MVar [(key, MVar (Maybe value))])
+
+newBaselineCache :: IO (BaselineCache key value)
+newBaselineCache = BaselineCache <$> newMVar []
+
+clearBaselineCache :: BaselineCache key value -> IO ()
+clearBaselineCache (BaselineCache cache) = modifyMVar_ cache (const (pure []))
+
+-- | The producer returns Nothing for an unshareable observation. Waiters then
+-- execute independently. Cancellation publishes Nothing before rethrowing so
+-- another worker can never remain blocked on abandoned ownership.
+shareBaseline :: (Eq key) => BaselineCache key value -> key -> IO (Maybe value) -> IO (Bool, Maybe value)
+shareBaseline (BaselineCache cache) key compute = mask $ \restore -> do
+  (owner, slot) <- modifyMVar cache $ \entries -> case lookup key entries of
+    Just existing -> pure (entries, (False, existing))
+    Nothing -> do fresh <- newEmptyMVar; pure ((key, fresh) : entries, (True, fresh))
+  if owner
+    then do
+      result <- try (restore compute)
+      case result of
+        Left (e :: SomeException) -> putMVar slot Nothing >> throwIO e
+        Right value -> putMVar slot value >> pure (False, value)
+    else do
+      cached <- restore (readMVar slot)
+      case cached of
+        Just value -> pure (True, Just value)
+        Nothing -> (False,) <$> restore compute
diff --git a/src/Test/Heni/Internal/Codec.hs b/src/Test/Heni/Internal/Codec.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Internal/Codec.hs
@@ -0,0 +1,380 @@
+-- | Explicit version-one wire vocabulary. No Generic-derived compatibility.
+module Test.Heni.Internal.Codec where
+
+import Control.Monad (unless)
+import Data.Aeson
+import Data.Aeson.Decoding.ByteString (bsToTokens)
+import Data.Aeson.Decoding.Tokens
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KM
+import Data.Aeson.Types (Parser)
+import Data.ByteString qualified as BS
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Test.Heni.Case (validateCase)
+import Test.Heni.Command (validateCommand)
+import Test.Heni.Internal.Model
+import Test.Heni.Result (summarizeRun)
+
+-- | Validate the original token stream before Aeson's map conversion can erase
+-- duplicate keys. Escaped spellings are compared as decoded keys at every depth.
+decodeJSON :: BS.ByteString -> Either String Value
+decodeJSON bytes = do
+  leftover <- checkTokens 0 (bsToTokens bytes)
+  unless (BS.all (`elem` [9, 10, 13, 32]) leftover) (Left "trailing non-whitespace after JSON value")
+  eitherDecodeStrict bytes
+
+checkTokens :: Int -> Tokens k String -> Either String k
+checkTokens depth _ | depth > 128 = Left "JSON nesting exceeds 128 levels"
+checkTokens _ (TkLit _ k) = Right k
+checkTokens _ (TkText _ k) = Right k
+checkTokens _ (TkNumber _ k) = Right k
+checkTokens depth (TkArrayOpen array) = checkArray (depth + 1) array
+checkTokens depth (TkRecordOpen record) = checkRecord (depth + 1) Set.empty record
+checkTokens _ (TkErr message) = Left message
+
+checkArray :: Int -> TkArray k String -> Either String k
+checkArray depth (TkItem value) = checkTokens depth value >>= checkArray depth
+checkArray _ (TkArrayEnd k) = Right k
+checkArray _ (TkArrayErr message) = Left message
+
+checkRecord :: Int -> Set.Set Key -> TkRecord k String -> Either String k
+checkRecord depth seen (TkPair key value)
+  | key `Set.member` seen = Left ("duplicate JSON object key: " ++ Key.toString key)
+  | otherwise = checkTokens depth value >>= checkRecord depth (Set.insert key seen)
+checkRecord _ _ (TkRecordEnd k) = Right k
+checkRecord _ _ (TkRecordErr message) = Left message
+
+strictObject :: String -> [Key] -> (Object -> Parser a) -> Value -> Parser a
+strictObject label allowed f = withObject label $ \o -> do
+  unless (all (`elem` allowed) (KM.keys o)) (fail (label ++ ": unknown keys " ++ show (filter (`notElem` allowed) (KM.keys o))))
+  f o
+
+checked :: (Show e) => Either e a -> Parser a
+checked = either (fail . show) pure
+
+enumValue :: (Eq a) => [(Text, a)] -> a -> Value
+enumValue table a = case [name | (name, b) <- table, a == b] of
+  name : _ -> String name
+  [] -> error "internal wire enum table is incomplete"
+
+enumParser :: [(Text, a)] -> Value -> Parser a
+enumParser table = withText "enum" (\name -> maybe (fail ("unknown value: " ++ Text.unpack name)) pure (lookup name table))
+
+stageTable :: [(Text, Stage)]
+stageTable = [("setup", Setup), ("build", Build), ("test", Test), ("replay", Replay), ("teardown", Teardown)]
+
+verdicts :: [(Text, Verdict)]
+verdicts = [("caught", Caught), ("survived", Survived), ("invalid-mutation", InvalidMutation), ("uncovered", Uncovered), ("unexpected-build-failure", UnexpectedBuildFailure), ("wrong-failure", WrongFailure), ("timed-out", TimedOut), ("infrastructure-error", InfrastructureError), ("not-run", NotRun)]
+
+detections :: [(Text, DetectionKind)]
+detections = [("semantic-named", SemanticNamed), ("generic-diagnostic", GenericDiagnostic), ("expected-build-rejection", ExpectedBuildRejection), ("explicit-crash", ExplicitCrash), ("control-passed", ControlPassed)]
+
+statuses :: [(Text, RunStatus)]
+statuses = [("passed", RunPassed), ("failed", RunFailed), ("untrustworthy", RunUntrustworthy), ("interrupted", RunInterrupted)]
+
+nullable :: (a -> Value) -> Maybe a -> Value
+nullable = maybe Null
+
+parseNullable :: (Value -> Parser a) -> Value -> Parser (Maybe a)
+parseNullable _ Null = pure Nothing
+parseNullable f v = Just <$> f v
+
+commandJSON :: Command -> Value
+commandJSON Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output = OutputPolicy {limitBytes}} =
+  object
+    [ "executable" .= executable,
+      "arguments" .= arguments,
+      "sensitiveArguments" .= Set.toAscList sensitiveArguments,
+      "workingDirectory" .= fmap targetPath workingDirectory,
+      "environment" .= object [Key.fromString k .= v | (k, v) <- environment],
+      "timeoutMicroseconds" .= budgetMicroseconds budget,
+      "outputLimitBytes" .= limitBytes
+    ]
+
+parseCommand :: Value -> Parser Command
+parseCommand = strictObject "command" ["executable", "arguments", "sensitiveArguments", "workingDirectory", "environment", "timeoutMicroseconds", "outputLimitBytes"] $ \o -> do
+  executable <- o .: "executable"
+  arguments <- o .:? "arguments" .!= []
+  sensitiveArguments <- o .:? "sensitiveArguments" .!= [] >>= uniqueSet
+  relative <- o .:? "workingDirectory"
+  workingDirectory <- traverse (checked . mkRelativeTarget) relative
+  env <- o .:? "environment" .!= KM.empty
+  environment <- mapM (\(k, v) -> (,) (Key.toString k) <$> parseJSON v) (KM.toList env)
+  budget <- o .: "timeoutMicroseconds" >>= checked . mkStageBudget
+  output <- OutputPolicy <$> o .:? "outputLimitBytes" .!= 1048576
+  let command = Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output}
+  checked (validateCommand command)
+  pure command
+
+editJSON :: Edit -> Value
+editJSON (Replace p old new n) = object ["kind" .= String "replace", "path" .= targetPath p, "old" .= old, "new" .= new, "occurrences" .= n]
+editJSON (Create p content) = object ["kind" .= String "create", "path" .= targetPath p, "content" .= content]
+editJSON (Delete p content) = object ["kind" .= String "delete", "path" .= targetPath p, "expected" .= content]
+editJSON (Patch content) = object ["kind" .= String "patch", "content" .= content]
+editJSON (CustomEdit key) = object ["kind" .= String "custom", "key" .= key]
+
+parseEdit :: Value -> Parser Edit
+parseEdit value =
+  withObject
+    "edit"
+    ( \o ->
+        o .: "kind" >>= \(kind :: Text) -> case kind of
+          "replace" -> strictObject "replace" ["kind", "path", "old", "new", "occurrences"] (\x -> Replace <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "old" <*> x .: "new" <*> x .:? "occurrences" .!= 1) value
+          "create" -> strictObject "create" ["kind", "path", "content"] (\x -> Create <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "content") value
+          "delete" -> strictObject "delete" ["kind", "path", "expected"] (\x -> Delete <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "expected") value
+          "patch" -> strictObject "patch" ["kind", "content"] (\x -> Patch <$> x .: "content") value
+          "custom" -> strictObject "custom" ["kind", "key"] (\x -> CustomEdit <$> x .: "key") value
+          _ -> fail "unknown edit kind"
+    )
+    value
+
+expectationJSON :: Expectation -> Value
+expectationJSON (NamedTests ids policy) = object ["kind" .= String "named-tests", "failures" .= Set.toAscList ids, "replayRequired" .= (policy == RequireReplay)]
+expectationJSON (Diagnostic stage code witness) = object ["kind" .= String "diagnostic", "stage" .= enumValue stageTable stage, "exitCode" .= code, "outputContains" .= witness]
+expectationJSON (BuildRejection code witness) = object ["kind" .= String "build-rejection", "exitCode" .= code, "outputContains" .= witness]
+expectationJSON (CrashProbe signal probe) = object ["kind" .= String "crash", "signal" .= signal, "probe" .= probe]
+expectationJSON Control = object ["kind" .= String "control"]
+
+parseExpectation :: Value -> Parser Expectation
+parseExpectation value =
+  withObject
+    "expectation"
+    ( \o ->
+        o .: "kind" >>= \(kind :: Text) -> case kind of
+          "named-tests" -> strictObject "named-tests" ["kind", "failures", "replayRequired"] (\x -> NamedTests <$> (x .: "failures" >>= uniqueSet) <*> ((\b -> if b then RequireReplay else NoReplay) <$> (x .:? "replayRequired" .!= False))) value
+          "diagnostic" -> strictObject "diagnostic" ["kind", "stage", "exitCode", "outputContains"] (\x -> Diagnostic <$> (x .: "stage" >>= enumParser stageTable) <*> x .: "exitCode" <*> x .: "outputContains") value
+          "build-rejection" -> strictObject "build-rejection" ["kind", "exitCode", "outputContains"] (\x -> BuildRejection <$> x .: "exitCode" <*> x .: "outputContains") value
+          "crash" -> strictObject "crash" ["kind", "signal", "probe"] (\x -> CrashProbe <$> x .: "signal" <*> x .: "probe") value
+          "control" -> strictObject "control" ["kind"] (const (pure Control)) value
+          _ -> fail "unknown expectation kind"
+    )
+    value
+
+uniqueSet :: (Ord a) => [a] -> Parser (Set.Set a)
+uniqueSet xs = let s = Set.fromList xs in if Set.size s == length xs then pure s else fail "duplicate set member"
+
+caseJSON :: CaseDefinition -> Value
+caseJSON CaseDefinition {caseId, description, tags, edits, build, test, selector, expectation, evidenceMode} =
+  object
+    [ "id" .= caseIdText caseId,
+      "description" .= description,
+      "tags" .= Set.toAscList tags,
+      "edits" .= map editJSON edits,
+      "build" .= commandIdText build,
+      "test" .= fmap commandIdText test,
+      "selector" .= selector,
+      "expect" .= expectationJSON expectation,
+      "adapter" .= (case evidenceMode of GenericCommands -> Null; StructuredTestsV1 -> String "heni-v1")
+    ]
+
+parseCase :: Value -> Parser CaseDefinition
+parseCase = strictObject "case" ["id", "description", "tags", "edits", "build", "test", "selector", "expect", "adapter"] $ \o -> do
+  caseId <- o .: "id" >>= checked . mkCaseId
+  description <- o .: "description"
+  tags <- o .:? "tags" .!= [] >>= uniqueSet
+  edits <- o .:? "edits" .!= [] >>= mapM parseEdit
+  build <- o .: "build" >>= checked . mkCommandId
+  test <- o .:? "test" >>= traverse (checked . mkCommandId)
+  selector <- o .:? "selector"
+  expectation <- o .: "expect" >>= parseExpectation
+  evidenceMode <-
+    o .:? "adapter" >>= \case
+      Nothing -> pure GenericCommands
+      Just ("heni-v1" :: Text) -> pure StructuredTestsV1
+      Just _ -> fail "unsupported adapter; expected heni-v1"
+  let definition = CaseDefinition {caseId, description, tags, edits, build, test, selector, expectation, evidenceMode}
+  checked (validateCase definition)
+  pure definition
+
+outcomeJSON :: ProcessOutcome -> Value
+outcomeJSON (Exited code) = object ["kind" .= String "exit", "code" .= code]
+outcomeJSON (Signaled signal) = object ["kind" .= String "signal", "signal" .= signal]
+outcomeJSON Timeout = object ["kind" .= String "timeout"]
+outcomeJSON Cancelled = object ["kind" .= String "cancelled"]
+outcomeJSON (LaunchFailed message) = object ["kind" .= String "launch-failed", "message" .= message]
+
+parseOutcome :: Value -> Parser ProcessOutcome
+parseOutcome value =
+  withObject
+    "outcome"
+    ( \o ->
+        o .: "kind" >>= \(kind :: Text) -> case kind of
+          "exit" -> strictObject "exit" ["kind", "code"] (\x -> do n <- x .: "code"; if n < 0 then fail "negative normal exit" else pure (Exited n)) value
+          "signal" -> strictObject "signal" ["kind", "signal"] (\x -> do n <- x .: "signal"; if n <= 0 then fail "nonpositive signal" else pure (Signaled n)) value
+          "timeout" -> strictObject "timeout" ["kind"] (const (pure Timeout)) value
+          "cancelled" -> strictObject "cancelled" ["kind"] (const (pure Cancelled)) value
+          "launch-failed" -> strictObject "launch-failed" ["kind", "message"] (\x -> LaunchFailed <$> x .: "message") value
+          _ -> fail "unknown process outcome"
+    )
+    value
+
+selectionJSON :: SelectionEvidence -> Value
+selectionJSON SelectionEvidence {discovered, selected, executed, executedIds, failedIds, skippedIds, setupErrors} =
+  object
+    [ "discovered" .= discovered,
+      "selected" .= selected,
+      "executed" .= executed,
+      "executedIds" .= Set.toAscList executedIds,
+      "failedIds" .= Set.toAscList failedIds,
+      "skippedIds" .= Set.toAscList skippedIds,
+      "setupErrors" .= setupErrors
+    ]
+
+parseSelection :: Value -> Parser SelectionEvidence
+parseSelection = strictObject "selection" ["discovered", "selected", "executed", "executedIds", "failedIds", "skippedIds", "setupErrors"] $ \o ->
+  SelectionEvidence
+    <$> o .: "discovered"
+    <*> o .: "selected"
+    <*> o .: "executed"
+    <*> (o .: "executedIds" >>= uniqueSet)
+    <*> (o .: "failedIds" >>= uniqueSet)
+    <*> (o .: "skippedIds" >>= uniqueSet)
+    <*> o .: "setupErrors"
+
+replayJSON :: ReplayEvidence -> Value
+replayJSON ReplayEvidence {token, generator, failures} = object ["token" .= token, "generator" .= generator, "failures" .= Set.toAscList failures]
+
+parseReplay :: Value -> Parser ReplayEvidence
+parseReplay = strictObject "replay" ["token", "generator", "failures"] $ \o -> ReplayEvidence <$> o .: "token" <*> o .: "generator" <*> (o .: "failures" >>= uniqueSet)
+
+stageJSON :: StageEvidence -> Value
+stageJSON StageEvidence {stage, outcome, transcript, truncated, selection, replay, durationSeconds, logPaths} =
+  object
+    [ "stage" .= enumValue stageTable stage,
+      "outcome" .= outcomeJSON outcome,
+      "witnesses" .= transcript,
+      "evidenceUnavailable" .= truncated,
+      "selection" .= selectionJSON selection,
+      "replay" .= nullable replayJSON replay,
+      "durationSeconds" .= durationSeconds,
+      "logPaths" .= logPaths
+    ]
+
+parseStage :: Value -> Parser StageEvidence
+parseStage = strictObject "stage" ["stage", "outcome", "witnesses", "evidenceUnavailable", "selection", "replay", "durationSeconds", "logPaths"] $ \o -> do
+  stage <- o .: "stage" >>= enumParser stageTable
+  outcome <- o .: "outcome" >>= parseOutcome
+  transcript <- o .: "witnesses"
+  truncated <- o .: "evidenceUnavailable"
+  selection <- o .: "selection" >>= parseSelection
+  replay <- o .: "replay" >>= parseNullable parseReplay
+  durationSeconds <- o .: "durationSeconds"
+  unless (durationSeconds >= 0 && not (isInfinite durationSeconds || isNaN durationSeconds)) (fail "invalid duration")
+  logPaths <- o .: "logPaths"
+  mapM_ (checked . mkRelativeTarget) logPaths
+  pure StageEvidence {stage, outcome, transcript, truncated, selection, replay, durationSeconds, logPaths}
+
+baselineJSON :: BaselineEvidence -> Value
+baselineJSON BaselinePassed = object ["kind" .= String "passed"]
+baselineJSON BaselineUnavailable = object ["kind" .= String "unavailable"]
+baselineJSON (BaselineFailed message) = object ["kind" .= String "failed", "message" .= message]
+
+parseBaseline :: Value -> Parser BaselineEvidence
+parseBaseline = strictObject "baseline" ["kind", "message"] $ \o ->
+  o .: "kind" >>= \(kind :: Text) -> case kind of
+    "passed" -> pure BaselinePassed
+    "unavailable" -> pure BaselineUnavailable
+    "failed" -> BaselineFailed <$> o .: "message"
+    _ -> fail "unknown baseline status"
+
+mutationJSON :: MutationEvidence -> Value
+mutationJSON Changed = object ["kind" .= String "changed"]
+mutationJSON Unchanged = object ["kind" .= String "unchanged"]
+mutationJSON (EditRejected message) = object ["kind" .= String "rejected", "message" .= message]
+
+parseMutation :: Value -> Parser MutationEvidence
+parseMutation = strictObject "mutation" ["kind", "message"] $ \o ->
+  o .: "kind" >>= \(kind :: Text) -> case kind of
+    "changed" -> pure Changed
+    "unchanged" -> pure Unchanged
+    "rejected" -> EditRejected <$> o .: "message"
+    _ -> fail "unknown mutation status"
+
+evidenceJSON :: CaseEvidence -> Value
+evidenceJSON CaseEvidence {baseline, mutation, stages, infrastructureErrors, cleanupErrors, stopped} =
+  object
+    [ "baseline" .= baselineJSON baseline,
+      "mutation" .= mutationJSON mutation,
+      "stages" .= map stageJSON stages,
+      "infrastructureErrors" .= infrastructureErrors,
+      "cleanupErrors" .= cleanupErrors,
+      "stopped" .= stopped
+    ]
+
+parseEvidence :: Value -> Parser CaseEvidence
+parseEvidence = strictObject "case evidence" ["baseline", "mutation", "stages", "infrastructureErrors", "cleanupErrors", "stopped"] $ \o ->
+  CaseEvidence
+    <$> (o .: "baseline" >>= parseBaseline)
+    <*> (o .: "mutation" >>= parseMutation)
+    <*> (o .: "stages" >>= mapM parseStage)
+    <*> o .: "infrastructureErrors"
+    <*> o .: "cleanupErrors"
+    <*> o .: "stopped"
+
+caseResultJSON :: CaseResult -> Value
+caseResultJSON CaseResult {caseId, verdict, detection, semantic, control, reason, evidence} =
+  object
+    [ "id" .= caseIdText caseId,
+      "verdict" .= enumValue verdicts verdict,
+      "detection" .= nullable (enumValue detections) detection,
+      "semantic" .= semantic,
+      "control" .= control,
+      "reason" .= reason,
+      "evidence" .= evidenceJSON evidence
+    ]
+
+parseCaseResult :: Value -> Parser CaseResult
+parseCaseResult = strictObject "case result" ["id", "verdict", "detection", "semantic", "control", "reason", "evidence"] $ \o ->
+  CaseResult
+    <$> (o .: "id" >>= checked . mkCaseId)
+    <*> (o .: "verdict" >>= enumParser verdicts)
+    <*> (o .: "detection" >>= parseNullable (enumParser detections))
+    <*> o .: "semantic"
+    <*> o .: "control"
+    <*> o .: "reason"
+    <*> (o .: "evidence" >>= parseEvidence)
+
+failureJSON :: RunFailure -> Value
+failureJSON (BaselineFailure ids why) = object ["kind" .= String "baseline", "affected" .= map caseIdText ids, "message" .= why]
+failureJSON (ControlFailure i why) = object ["kind" .= String "control", "id" .= caseIdText i, "message" .= why]
+failureJSON (RunInfrastructure why) = object ["kind" .= String "infrastructure", "message" .= why]
+failureJSON (Interrupted why) = object ["kind" .= String "interrupted", "message" .= why]
+
+parseFailure :: Value -> Parser RunFailure
+parseFailure = strictObject "run failure" ["kind", "affected", "id", "message"] $ \o ->
+  o .: "kind" >>= \(kind :: Text) -> case kind of
+    "baseline" -> BaselineFailure <$> (o .: "affected" >>= mapM (checked . mkCaseId)) <*> o .: "message"
+    "control" -> ControlFailure <$> (o .: "id" >>= checked . mkCaseId) <*> o .: "message"
+    "infrastructure" -> RunInfrastructure <$> o .: "message"
+    "interrupted" -> Interrupted <$> o .: "message"
+    _ -> fail "unknown run failure"
+
+scoreJSON :: SemanticScore -> Value
+scoreJSON (SemanticScore caught total complete) = object ["caught" .= caught, "total" .= total, "complete" .= complete]
+
+parseScore :: Value -> Parser SemanticScore
+parseScore = strictObject "score" ["caught", "total", "complete"] $ \o -> SemanticScore <$> o .: "caught" <*> o .: "total" <*> o .: "complete"
+
+resultJSON :: RunResult -> Value
+resultJSON RunResult {selectedIds, results, failures, status, score} =
+  object
+    [ "selectedIds" .= map caseIdText selectedIds,
+      "cases" .= map caseResultJSON results,
+      "failures" .= map failureJSON failures,
+      "status" .= enumValue statuses status,
+      "semanticScore" .= nullable scoreJSON score
+    ]
+
+parseResult :: Value -> Parser RunResult
+parseResult = strictObject "run result" ["selectedIds", "cases", "failures", "status", "semanticScore"] $ \o -> do
+  selected <- o .: "selectedIds" >>= mapM (checked . mkCaseId) >>= checked . mkSelectedCases
+  cases <- o .: "cases" >>= mapM parseCaseResult
+  failures <- o .: "failures" >>= mapM parseFailure
+  status <- o .: "status" >>= enumParser statuses
+  score <- o .: "semanticScore" >>= parseNullable parseScore
+  result <- checked (summarizeRun selected cases failures)
+  let RunResult {status = actualStatus, score = actualScore} = result
+  unless (actualStatus == status && actualScore == score) (fail "stored summary contradicts selected ledger")
+  pure result
diff --git a/src/Test/Heni/Internal/Files.hs b/src/Test/Heni/Internal/Files.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Internal/Files.hs
@@ -0,0 +1,130 @@
+{-# OPTIONS_HADDOCK not-home #-}
+
+-- | Implementation detail, not an exposed module or supported import API.
+-- Owns temporary paths and checked byte inventories for the POSIX implementation.
+module Test.Heni.Internal.Files where
+
+import Control.Exception
+import Control.Monad (forM, forM_, unless, when)
+import Crypto.Hash (Digest, SHA256, hash)
+import Data.Bits ((.&.))
+import Data.ByteString qualified as BS
+import Data.ByteString.Char8 qualified as B8
+import Data.List (sort)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Text
+import System.Directory qualified as Dir
+import System.FilePath (takeDirectory, (</>))
+import System.IO.Error (isDoesNotExistError)
+import System.Posix.Files qualified as Posix
+import System.Posix.Temp (mkdtemp)
+import Test.Heni.Case (RelativeTarget, mkRelativeTarget, targetPath)
+
+-- | Structured filesystem failure; ordinary IO errors remain IOException.
+data WorkspaceError = UnsafeSource !Text | SourceChanged | WorkspaceMismatch !Text
+  deriving stock (Eq, Show)
+  deriving anyclass (Exception)
+
+-- | Immutable bytes and executable bits, excluding ownership and timestamps.
+data FileEntry = FileEntry {bytes :: !BS.ByteString, executableBits :: !Int}
+  deriving stock (Eq, Show)
+
+-- | Canonical relative targets mapped to immutable file contents.
+type Inventory = Map.Map RelativeTarget FileEntry
+
+-- | Preserve both exceptions when disposal fails after an action already failed.
+data WorkspaceFinalizationError = WorkspaceFinalizationError !(Maybe SomeException) !SomeException
+  deriving stock (Show)
+  deriving anyclass (Exception)
+
+-- | Mask disposal, retaining both the primary and secondary exception.
+withFinalizer :: forall a. IO a -> IO () -> IO a
+withFinalizer action release = mask $ \restore -> do
+  primary <- try (restore action) :: IO (Either SomeException a)
+  secondary <- try release :: IO (Either SomeException ())
+  case (primary, secondary) of
+    (Right value, Right ()) -> pure value
+    (Left e, Right ()) -> throwIO e
+    (Right _, Left e) -> throwIO (WorkspaceFinalizationError Nothing e)
+    (Left first, Left second) -> throwIO (WorkspaceFinalizationError (Just first) second)
+
+-- | Lowercase hexadecimal SHA-256 content digest.
+digest :: BS.ByteString -> Text
+digest = Text.pack . show . (hash :: BS.ByteString -> Digest SHA256)
+
+-- | Length-prefix bytes to prevent concatenation ambiguity.
+framed :: BS.ByteString -> BS.ByteString
+framed b = B8.pack (show (BS.length b) ++ ":") <> b
+
+-- | Hash policy, names, executable bits and bytes with explicit framing.
+inventoryDigest :: Text -> Inventory -> Text
+inventoryDigest policy files =
+  digest $
+    framed (Text.encodeUtf8 policy)
+      <> BS.concat [framed (Text.encodeUtf8 (Text.pack (targetPath p))) <> framed (B8.pack (show mode)) <> framed b | (p, FileEntry b mode) <- Map.toAscList files]
+
+-- | Allocate an opaque temporary directory and dispose it after the callback.
+withOwnedDirectory :: String -> (FilePath -> IO a) -> IO a
+withOwnedDirectory prefix action = mask $ \restore -> do
+  root <- allocate
+  withFinalizer (restore (action root)) (Dir.removePathForcibly root)
+  where
+    allocate = do
+      temp <- Dir.getTemporaryDirectory >>= Dir.canonicalizePath
+      mkdtemp (temp </> prefix)
+
+-- | No symlink component is accepted, including a dangling link. Call before IO.
+checkedPath :: FilePath -> RelativeTarget -> IO FilePath
+checkedPath root target = do
+  rootStatus <- Posix.getSymbolicLinkStatus root
+  unless (Posix.isDirectory rootStatus && not (Posix.isSymbolicLink rootStatus)) (throwIO (UnsafeSource "workspace root is not a directory"))
+  go root (Text.splitOn "/" (Text.pack (targetPath target)))
+  pure (root </> targetPath target)
+  where
+    go _ [] = pure ()
+    go current (part : rest) = do
+      let p = current </> Text.unpack part
+      status <- tryIO (Posix.getSymbolicLinkStatus p)
+      case status of
+        Left e | isDoesNotExistError e -> pure ()
+        Left e -> throwIO e
+        Right st -> do
+          when (Posix.isSymbolicLink st) (throwIO (UnsafeSource ("symlink target: " <> Text.pack p)))
+          unless
+            (if null rest then Posix.isRegularFile st || Posix.isDirectory st else Posix.isDirectory st)
+            (throwIO (UnsafeSource ("non-regular path: " <> Text.pack p)))
+          go p rest
+
+-- | Catch only ordinary IO failures, preserving asynchronous exceptions.
+tryIO :: IO a -> IO (Either IOException a)
+tryIO = try
+
+-- | Read the exact selected regular-file tree; names are canonical relative paths.
+inventory :: (FilePath -> Bool) -> FilePath -> IO Inventory
+inventory excluded root = Map.fromList <$> walk ""
+  where
+    walk relative = do
+      let full = if null relative then root else root </> relative
+      st <- Posix.getSymbolicLinkStatus full
+      when (Posix.isSymbolicLink st) (throwIO (UnsafeSource ("source symlink is unsupported: " <> Text.pack relative)))
+      if Posix.isDirectory st
+        then do
+          names <- sort <$> Dir.listDirectory full
+          concat <$> forM names (\n -> let p = if null relative then n else relative </> n in if excluded p then pure [] else walk p)
+        else
+          if Posix.isRegularFile st
+            then do
+              target <- either (throwIO . UnsafeSource . Text.pack . show) pure (mkRelativeTarget relative)
+              b <- BS.readFile full
+              pure [(target, FileEntry b (fromIntegral (Posix.fileMode st .&. 0o111)))]
+            else throwIO (UnsafeSource ("special source file is unsupported: " <> Text.pack relative))
+
+-- | Write independent regular files with their captured executable bits.
+materialize :: FilePath -> Inventory -> IO ()
+materialize root files = forM_ (Map.toAscList files) $ \(target, FileEntry b mode) -> do
+  p <- checkedPath root target
+  Dir.createDirectoryIfMissing True (takeDirectory p)
+  BS.writeFile p b
+  Posix.setFileMode p (0o600 + fromIntegral mode)
diff --git a/src/Test/Heni/Internal/Model.hs b/src/Test/Heni/Internal/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Internal/Model.hs
@@ -0,0 +1,278 @@
+{-# OPTIONS_HADDOCK not-home #-}
+
+-- | Implementation detail, not an exposed module or supported import API.
+-- Public facades hide constrained constructors and own their documentation.
+module Test.Heni.Internal.Model where
+
+import Data.Char (isAlphaNum, isAscii)
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Test.Heni.Internal.Prelude
+
+-- | A field-specific configuration problem, detected before execution.
+data ConfigError = ConfigError {field :: !Text, message :: !Text}
+  deriving stock (Eq, Show, Generic)
+
+-- | Stable portable case identifier.
+newtype CaseId = CaseId Text deriving stock (Eq, Ord, Show, Generic)
+
+-- | Stable portable command identifier.
+newtype CommandId = CommandId Text deriving stock (Eq, Ord, Show, Generic)
+
+-- | Slash-separated path relative to a workspace, never a parent or root.
+newtype RelativeTarget = RelativeTarget FilePath deriving stock (Eq, Ord, Show, Generic)
+
+-- | Positive wall-clock microseconds, bounded to avoid timer overflow.
+newtype StageBudget = StageBudget Int deriving stock (Eq, Ord, Show, Generic)
+
+-- | A nonempty set of selected IDs, preserving the requested order.
+newtype SelectedCases = SelectedCases [CaseId] deriving stock (Eq, Show, Generic)
+
+-- | Construct an identifier using ASCII letters, digits, dots, dashes and underscores.
+mkCaseId :: Text -> Either ConfigError CaseId
+mkCaseId t = CaseId <$> identifier "case.id" t
+
+-- | Construct a reusable command identifier.
+mkCommandId :: Text -> Either ConfigError CommandId
+mkCommandId t = CommandId <$> identifier "command.id" t
+
+-- | Shared ASCII identity check used by the public smart constructors.
+identifier :: Text -> Text -> Either ConfigError Text
+identifier f t
+  | Text.null t || not (Text.all (\c -> isAscii c && (isAlphaNum c || c `elem` ("-_." :: String))) t) =
+      Left (ConfigError f "expected a nonempty portable identifier")
+  | otherwise = Right t
+
+-- | Reject traversal, drive syntax, backslashes, empty segments and NULs.
+-- Filesystem checks must additionally reject symlinks when opening the target.
+mkRelativeTarget :: FilePath -> Either ConfigError RelativeTarget
+mkRelativeTarget p
+  | null p || any (`elem` p) ['\\', ':', '\0'] || any (`elem` ["", ".", ".."]) parts =
+      Left (ConfigError "path" "expected a normalized relative path")
+  | otherwise = Right (RelativeTarget p)
+  where
+    parts = Text.splitOn "/" (Text.pack p)
+
+-- | Budget in microseconds; at most one day for a single stage.
+mkStageBudget :: Int -> Either ConfigError StageBudget
+mkStageBudget n
+  | n > 0 && toInteger n <= 86400000000 = Right (StageBudget n)
+  | otherwise = Left (ConfigError "budget" "expected positive microseconds, at most one day")
+
+-- | Reject empty or duplicate selections rather than silently changing their meaning.
+mkSelectedCases :: [CaseId] -> Either ConfigError SelectedCases
+mkSelectedCases ids
+  | null ids = Left (ConfigError "selection" "no cases selected")
+  | Set.size (Set.fromList ids) /= length ids = Left (ConfigError "selection" "duplicate case IDs")
+  | otherwise = Right (SelectedCases ids)
+
+-- | Underlying portable ID.
+caseIdText :: CaseId -> Text
+caseIdText (CaseId t) = t
+
+-- | Underlying command ID.
+commandIdText :: CommandId -> Text
+commandIdText (CommandId t) = t
+
+-- | Path suitable for joining to a verified workspace root.
+targetPath :: RelativeTarget -> FilePath
+targetPath (RelativeTarget p) = p
+
+-- | Timer argument in microseconds.
+budgetMicroseconds :: StageBudget -> Int
+budgetMicroseconds (StageBudget n) = n
+
+-- | Requested case order.
+selectedCaseIds :: SelectedCases -> [CaseId]
+selectedCaseIds (SelectedCases ids) = ids
+
+-- | Independently supervised execution phases.
+data Stage = Setup | Build | Test | Replay | Teardown
+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)
+
+-- | Exit, signal and runner intervention are deliberately distinct.
+data ProcessOutcome = Exited !Int | Signaled !Int | Timeout | Cancelled | LaunchFailed !Text
+  deriving stock (Eq, Show, Generic)
+
+-- | Output retention policy. Exceeding the limit cannot prove a diagnostic.
+data OutputPolicy = OutputPolicy {limitBytes :: !Int}
+  deriving stock (Eq, Show, Generic)
+
+-- | An executable and arguments, with no implicit shell expansion.
+data Command = Command
+  { executable :: !FilePath,
+    arguments :: ![String],
+    sensitiveArguments :: !(Set Int),
+    workingDirectory :: !(Maybe RelativeTarget),
+    environment :: ![(String, String)],
+    budget :: !StageBudget,
+    output :: !OutputPolicy
+  }
+  deriving stock (Eq, Show, Generic)
+
+-- | Checked text, patch, or registered Haskell edit. Patches are UTF-8 content.
+data Edit
+  = Replace !RelativeTarget !Text !Text !Int
+  | Create !RelativeTarget !Text
+  | Delete !RelativeTarget !Text
+  | Patch !Text
+  | CustomEdit !Text
+  deriving stock (Eq, Show, Generic)
+
+-- | A replay token and its exact generator configuration.
+data ReplayEvidence = ReplayEvidence
+  {token :: !Text, generator :: !Text, failures :: !(Set Text)}
+  deriving stock (Eq, Show, Generic)
+
+-- | Whether matching counterexample replay is mandatory.
+data ReplayPolicy = NoReplay | RequireReplay
+  deriving stock (Eq, Show, Generic)
+
+-- | Expectations declare their evidence strength rather than guessing from output.
+data Expectation
+  = NamedTests !(Set Text) !ReplayPolicy
+  | Diagnostic !Stage !Int !Text
+  | BuildRejection !Int !Text
+  | CrashProbe !Int !Text
+  | Control
+  deriving stock (Eq, Show, Generic)
+
+-- | Declared framework-neutral evidence transport.
+data EvidenceMode = GenericCommands | StructuredTestsV1
+  deriving stock (Eq, Show, Generic)
+
+-- | Portable definition, including its declared evidence transport.
+data CaseDefinition = CaseDefinition
+  { caseId :: !CaseId,
+    description :: !Text,
+    tags :: !(Set Text),
+    edits :: ![Edit],
+    build :: !CommandId,
+    test :: !(Maybe CommandId),
+    selector :: !(Maybe Text),
+    expectation :: !Expectation,
+    evidenceMode :: !EvidenceMode
+  }
+  deriving stock (Eq, Show, Generic)
+
+-- | Operations supplied by the workspace owner. Hooks are trusted IO, not a sandbox.
+data WorkspaceOps = WorkspaceOps
+  { readTarget :: !(RelativeTarget -> IO Text),
+    writeTarget :: !(RelativeTarget -> Text -> IO ()),
+    createTarget :: !(RelativeTarget -> Text -> IO ()),
+    deleteTarget :: !(RelativeTarget -> Text -> IO ())
+  }
+
+-- | Haskell-only extension points. Returned evidence still passes all validity gates.
+data CaseHooks = CaseHooks
+  { customEdits :: ![(Text, WorkspaceOps -> IO ())],
+    customEvidence :: !(Maybe (StageEvidence -> IO (Either Text StageEvidence))),
+    replayBuilder :: !(Maybe (ReplayEvidence -> Command -> IO (Either Text Command)))
+  }
+
+-- | No custom behavior.
+noHooks :: CaseHooks
+noHooks = CaseHooks [] Nothing Nothing
+
+-- | Counts are optional: unknown is different from zero. IDs identify attribution.
+data SelectionEvidence = SelectionEvidence
+  { discovered :: !(Maybe Int),
+    selected :: !(Maybe Int),
+    executed :: !(Maybe Int),
+    executedIds :: !(Set Text),
+    failedIds :: !(Set Text),
+    skippedIds :: !(Set Text),
+    setupErrors :: ![Text]
+  }
+  deriving stock (Eq, Show, Generic)
+
+-- | Generic commands do not invent test counts.
+unknownSelection :: SelectionEvidence
+unknownSelection = SelectionEvidence Nothing Nothing Nothing Set.empty Set.empty Set.empty []
+
+-- | Observations from a single stage. A truncated output cannot be a witness.
+data StageEvidence = StageEvidence
+  { stage :: !Stage,
+    outcome :: !ProcessOutcome,
+    transcript :: !Text,
+    truncated :: !Bool,
+    selection :: !SelectionEvidence,
+    replay :: !(Maybe ReplayEvidence),
+    durationSeconds :: !Double,
+    logPaths :: ![FilePath]
+  }
+  deriving stock (Eq, Show, Generic)
+
+-- | Whether edit preconditions and the final byte-change requirement passed.
+data MutationEvidence = Changed | Unchanged | EditRejected !Text
+  deriving stock (Eq, Show, Generic)
+
+-- | A baseline failure stops the candidate; it is never a mutant kill.
+data BaselineEvidence = BaselinePassed | BaselineFailed !Text | BaselineUnavailable
+  deriving stock (Eq, Show, Generic)
+
+-- | All validity gates and primary/secondary observations used by the classifier.
+data CaseEvidence = CaseEvidence
+  { baseline :: !BaselineEvidence,
+    mutation :: !MutationEvidence,
+    stages :: ![StageEvidence],
+    infrastructureErrors :: ![Text],
+    cleanupErrors :: ![Text],
+    stopped :: !(Maybe Text)
+  }
+  deriving stock (Eq, Show, Generic)
+
+-- | All nine public case outcomes.
+data Verdict
+  = Caught
+  | Survived
+  | InvalidMutation
+  | Uncovered
+  | UnexpectedBuildFailure
+  | WrongFailure
+  | TimedOut
+  | InfrastructureError
+  | NotRun
+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)
+
+-- | Successful detections and passing controls are tallied separately.
+data DetectionKind = SemanticNamed | GenericDiagnostic | ExpectedBuildRejection | ExplicitCrash | ControlPassed
+  deriving stock (Eq, Ord, Show, Generic)
+
+-- | Classifier output, retaining every observation and secondary error.
+data CaseResult = CaseResult
+  { caseId :: !CaseId,
+    verdict :: !Verdict,
+    detection :: !(Maybe DetectionKind),
+    semantic :: !Bool,
+    control :: !Bool,
+    reason :: !Text,
+    evidence :: !CaseEvidence
+  }
+  deriving stock (Eq, Show, Generic)
+
+-- | Campaign failures keep affected IDs even when no candidate could start.
+data RunFailure
+  = BaselineFailure ![CaseId] !Text
+  | ControlFailure !CaseId !Text
+  | RunInfrastructure !Text
+  | Interrupted !Text
+  deriving stock (Eq, Show, Generic)
+
+-- | CLI-neutral run contract. Infrastructure takes precedence over a failed contract.
+data RunStatus = RunPassed | RunFailed | RunUntrustworthy | RunInterrupted
+  deriving stock (Eq, Show, Generic)
+
+-- | Only caught and survived semantic mutations contribute to the ratio.
+data SemanticScore = SemanticScore {caught :: !Int, total :: !Int, complete :: !Bool}
+  deriving stock (Eq, Show, Generic)
+
+-- | Exactly one result per selected ID, in selection order.
+data RunResult = RunResult
+  { selectedIds :: ![CaseId],
+    results :: ![CaseResult],
+    failures :: ![RunFailure],
+    status :: !RunStatus,
+    score :: !(Maybe SemanticScore)
+  }
+  deriving stock (Eq, Show, Generic)
diff --git a/src/Test/Heni/Internal/Prelude.hs b/src/Test/Heni/Internal/Prelude.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Internal/Prelude.hs
@@ -0,0 +1,6 @@
+-- | Small internal convenience imports. Never adds label instances.
+module Test.Heni.Internal.Prelude (Text, Set, Generic) where
+
+import Data.Set (Set)
+import Data.Text (Text)
+import GHC.Generics (Generic)
diff --git a/src/Test/Heni/Internal/Process/Posix.hs b/src/Test/Heni/Internal/Process/Posix.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Internal/Process/Posix.hs
@@ -0,0 +1,78 @@
+-- | Narrow POSIX group termination. Detached descendants require a sandbox.
+module Test.Heni.Internal.Process.Posix (stopGroup, exitOutcome) where
+
+import Control.Concurrent (threadDelay)
+import Control.Exception
+import Data.Text (Text)
+import Data.Text qualified as Text
+import GHC.Clock (getMonotonicTimeNSec)
+import System.Exit (ExitCode (..))
+import System.IO.Error (isDoesNotExistError)
+import System.Posix.Signals
+import System.Process (Pid, ProcessHandle, getProcessExitCode)
+import Test.Heni.Command (ProcessOutcome (..))
+
+exitOutcome :: ExitCode -> ProcessOutcome
+exitOutcome ExitSuccess = Exited 0
+exitOutcome (ExitFailure n) | n < 0 = Signaled (negate n)
+exitOutcome (ExitFailure n) = Exited n
+
+-- | Signal only while the owned group remains observable. Once absence has
+-- been observed, never send another signal to that transient numeric identity.
+-- Reap the direct child independently; all waits have explicit bounds.
+stopGroup :: Int -> Maybe Pid -> ProcessHandle -> IO [Text]
+stopGroup grace pid process = do
+  initial <- groupState
+  termErrors <- case initial of Right False -> pure []; _ -> send sigTERM
+  deadline <- (+ fromIntegral grace * 1000) <$> getMonotonicTimeNSec
+  remaining <- case initial of
+    Right False -> pure (Right False)
+    _ -> waitGroup deadline
+  killErrors <- case remaining of Right False -> pure []; _ -> send sigKILL
+  reapDeadline <- (+ fromIntegral grace * 1000) <$> getMonotonicTimeNSec
+  final <- case remaining of Right False -> pure (Right False); _ -> waitGroup reapDeadline
+  reaped <- reap reapDeadline
+  -- Darwin can temporarily return EPERM for a group whose members are exiting.
+  -- Only proven disappearance clears these provisional observation/send errors.
+  let shutdownErrors = case final of
+        Right False -> []
+        _ -> either pure (const []) initial ++ termErrors ++ either pure (const []) final ++ killErrors
+  pure (shutdownErrors ++ ["direct child could not be reaped within shutdown budget" | not reaped])
+  where
+    send signal = case pid of
+      Nothing -> pure ["missing owned process group identity"]
+      Just group -> do
+        result <- try (signalProcessGroup signal group)
+        case result of
+          Right () -> pure []
+          Left e | isDoesNotExistError e -> pure []
+          Left (e :: IOException) -> do
+            -- A disappearing group can make a redundant signal fail. Accept
+            -- only definite absence, never a permission/observation failure.
+            after <- groupState
+            pure $ case after of
+              Right False -> []
+              _ -> ["sending signal " <> Text.pack (show signal) <> " to owned group: " <> Text.pack (displayException e)]
+    groupState = case pid of
+      Nothing -> pure (Left "missing owned process group identity")
+      Just group -> do
+        result <- try (signalProcessGroup nullSignal group) :: IO (Either IOException ())
+        pure $ case result of
+          Right () -> Right True
+          Left e | isDoesNotExistError e -> Right False
+          Left e -> Left ("probing owned process group: " <> Text.pack (displayException e))
+    waitGroup deadline = do
+      _ <- getProcessExitCode process
+      state <- groupState
+      now <- getMonotonicTimeNSec
+      case state of
+        Right False -> pure state
+        _ | now >= deadline -> pure state
+        _ -> threadDelay 5000 >> waitGroup deadline
+    reap deadline = do
+      result <- getProcessExitCode process
+      now <- getMonotonicTimeNSec
+      case result of
+        Just _ -> pure True
+        Nothing | now >= deadline -> pure False
+        Nothing -> threadDelay 5000 >> reap deadline
diff --git a/src/Test/Heni/Manifest.hs b/src/Test/Heni/Manifest.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Manifest.hs
@@ -0,0 +1,237 @@
+-- | Strict version-one portable manifests and isolated applicability checks.
+module Test.Heni.Manifest
+  ( Manifest,
+    decodeManifest,
+    readManifest,
+    manifestSourceRoot,
+    manifestPolicy,
+    manifestCampaign,
+    manifestRunOptions,
+    caseDefinitionJSON,
+    Applicability (..),
+    checkManifest,
+  )
+where
+
+import Control.Exception
+import Control.Monad (forM, forM_, unless, when)
+import Data.Aeson
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KM
+import Data.Aeson.Types (JSONPathElement (..), Parser, parseEither)
+import Data.ByteString qualified as BS
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as Text
+import System.Directory qualified as Dir
+import System.FilePath (isAbsolute, takeDirectory, (</>))
+import Test.Heni.Case
+import Test.Heni.Command
+import Test.Heni.Edit
+import Test.Heni.Expectation
+import Test.Heni.Internal.Codec
+import Test.Heni.Internal.Files (withOwnedDirectory)
+import Test.Heni.Process
+import Test.Heni.Resource qualified as Resource
+import Test.Heni.Runner
+import Test.Heni.Workspace
+
+-- | Source roots are relative to the manifest file until readManifest resolves them.
+data Manifest = Manifest !FilePath !SnapshotPolicy !Campaign !Execution
+
+data Execution = Execution ![Resource.ResourceProvider] !(Map.Map CaseId [Text]) !Int !ReusePolicy
+
+-- | Declared source root, resolved by readManifest.
+manifestSourceRoot :: Manifest -> FilePath
+manifestSourceRoot (Manifest root _ _ _) = root
+
+-- | Explicit source inclusion and exclusion choices.
+manifestPolicy :: Manifest -> SnapshotPolicy
+manifestPolicy (Manifest _ policy _ _) = policy
+
+-- | The same validated campaign used by Haskell callers.
+manifestCampaign :: Manifest -> Campaign
+manifestCampaign (Manifest _ _ campaign _) = campaign
+
+-- | Lower portable execution/resource options into the shared library runner.
+manifestRunOptions :: FilePath -> Manifest -> RunOptions
+manifestRunOptions output (Manifest root policy _ (Execution providers claims jobs reuse)) =
+  (defaultRunOptions root output) {sourcePolicy = policy, workers = jobs, resources = providers, resourceClaims = claims, reusePolicy = reuse}
+
+-- | Explicit portable case representation, shared with report metadata.
+caseDefinitionJSON :: CaseDefinition -> Value
+caseDefinitionJSON = caseJSON
+
+-- | Decode at most 4MiB, reject duplicates/unknown keys and validate all references.
+decodeManifest :: BS.ByteString -> Either ConfigError Manifest
+decodeManifest bytes
+  | BS.length bytes > 4194304 = Left (ConfigError "manifest" "manifest exceeds 4MiB")
+  | otherwise = either (Left . ConfigError "manifest" . Text.pack) Right $ decodeJSON bytes >>= parseEither parseManifest
+
+parseManifest :: Value -> Parser Manifest
+parseManifest = strictObject "manifest" ["schemaVersion", "sourceRoot", "sourcePolicy", "commands", "cases", "resources", "execution"] $ \o -> do
+  version <- o .: "schemaVersion"
+  unless (version == (1 :: Int)) (fail "unsupported schemaVersion")
+  root <- defaultField o "sourceRoot" "."
+  when (null root || isAbsolute root || '\0' `elem` root) (fail "sourceRoot must be a nonempty relative path")
+  policy <- case KM.lookup "sourcePolicy" o of Nothing -> pure defaultSnapshotPolicy; Just value -> parsePolicy value
+  commandValues <- o .: "commands" :: Parser Object
+  commands <- forM (KM.toList commandValues) $ \(key, value) -> do
+    ident <- checked (mkCommandId (Key.toText key))
+    command <- parsePortableCommand value <?> Key key
+    pure (ident, command)
+  caseValues <- o .: "cases"
+  definitions <- forM (zip [0 ..] caseValues) $ \(index, value) -> parsePortableCase value <?> Index index
+  campaign <- checked (mkCampaign (Map.fromList commands) [(definition, noHooks) | definition <- definitions])
+  providers <- defaultField o "resources" [] >>= mapM (parseResource (Map.fromList commands))
+  checked (Resource.validateResources providers)
+  claims <- forM (zip definitions caseValues) $ \(CaseDefinition {caseId}, value) ->
+    withObject
+      "case resource claims"
+      ( \c -> do
+          names <- defaultField c "resources" []
+          unless (length names == Set.size (Set.fromList names) && all (`elem` map Resource.resourceName providers) names) (fail "unknown or duplicate resource claim")
+          pure (caseId, names)
+      )
+      value
+  (jobs, reuse) <- case KM.lookup "execution" o of Nothing -> pure (1, Fresh); Just value -> parseExecution value
+  pure (Manifest root policy campaign (Execution providers (Map.fromList claims) jobs reuse))
+
+parsePolicy :: Value -> Parser SnapshotPolicy
+parsePolicy = strictObject "sourcePolicy" ["includes", "excludes"] $ \o ->
+  SnapshotPolicy <$> (defaultField o "includes" [] >>= mapM (checked . mkRelativeTarget)) <*> (defaultField o "excludes" [] >>= mapM (checked . mkRelativeTarget))
+
+defaultField :: (FromJSON a) => Object -> Key -> a -> Parser a
+defaultField o key fallback = maybe (pure fallback) parseJSON (KM.lookup key o)
+
+parsePortableCommand :: Value -> Parser Command
+parsePortableCommand = strictObject "command" ["executable", "arguments", "sensitiveArguments", "workingDirectory", "environment", "timeoutSeconds", "outputLimitBytes"] $ \o -> do
+  executable <- o .: "executable"
+  arguments <- defaultField o "arguments" []
+  sensitive <- defaultField o "sensitiveArguments" [] >>= uniqueSet
+  working <- o .:? "workingDirectory" >>= traverse (checked . mkRelativeTarget)
+  environmentValues <- defaultField o "environment" KM.empty
+  environment <- forM (KM.toList environmentValues) $ \(key, value) -> (,) (Key.toString key) <$> parseJSON value
+  seconds <- defaultField o "timeoutSeconds" (30 :: Double)
+  unless (seconds > 0 && seconds <= 86400 && not (isNaN seconds || isInfinite seconds)) (fail "timeoutSeconds must be positive and finite, at most 86400")
+  budget <- checked (mkStageBudget (ceiling (seconds * 1000000)))
+  output <- OutputPolicy <$> defaultField o "outputLimitBytes" 1048576
+  let command = Command executable arguments sensitive working environment budget output
+  checked (validateCommand command)
+  pure command
+
+parsePortableCase :: Value -> Parser CaseDefinition
+parsePortableCase value = do
+  -- Defaults never turn an explicit null into a missing non-nullable field.
+  withObject
+    "case"
+    ( \o -> do
+        forM_ ["tags", "edits"] $ \key -> when (KM.lookup key o == Just Null) (fail (Key.toString key ++ " cannot be null"))
+        edits <- o .:? "edits" .!= []
+        forM_ edits $ withObject "edit" $ \edit -> when (KM.lookup "occurrences" edit == Just Null) (fail "occurrences cannot be null")
+    )
+    value
+  withObject
+    "case"
+    ( \o -> case KM.lookup "expect" o of
+        Just (Object expected) | KM.lookup "replayRequired" expected == Just Null -> fail "replayRequired cannot be null"
+        _ -> pure ()
+    )
+    value
+  stripped <- withObject "case" (pure . Object . KM.delete "resources") value
+  definition@CaseDefinition {edits, expectation, evidenceMode} <- parseCase stripped
+  forM_ edits $ \case CustomEdit {} -> fail "custom Haskell hooks cannot be serialized in manifests"; _ -> pure ()
+  case expectation of
+    NamedTests {} | evidenceMode /= StructuredTestsV1 -> fail "named-tests requires adapter: heni-v1"
+    NamedTests {} -> pure definition
+    CrashProbe {} -> pure definition
+    _ -> pure definition
+
+-- | Read with source paths anchored to the manifest location, not the caller's cwd.
+readManifest :: FilePath -> IO (Either ConfigError Manifest)
+readManifest path = do
+  result <- try $ do
+    absolute <- Dir.makeAbsolute path
+    bytes <- BS.readFile absolute
+    case decodeManifest bytes of
+      Left e -> pure (Left e)
+      Right (Manifest root policy campaign execution) -> do
+        source <- Dir.canonicalizePath (takeDirectory absolute </> root)
+        pure (Right (Manifest source policy campaign execution))
+  pure $ either (Left . ConfigError "manifest" . Text.pack . show) id (result :: Either IOException (Either ConfigError Manifest))
+
+-- | Static validation plus checked edit applicability; no test discovery or baseline.
+data Applicability = Applicability {caseId :: !CaseId, applicable :: !Bool, diagnostics :: ![Text]}
+  deriving stock (Eq, Show)
+
+-- | Check available commands and edits in fresh disposable candidates. Git is
+-- optional unless a selected definition contains a patch, and uses runCommand.
+checkManifest :: Manifest -> IO (Either ConfigError [Applicability])
+checkManifest (Manifest root policy campaign _) =
+  catches
+    (Right <$> action)
+    [ Handler (\(e :: IOException) -> pure (Left (ConfigError "check" (Text.pack (show e))))),
+      Handler (\(e :: WorkspaceError) -> pure (Left (ConfigError "source" (Text.pack (show e)))))
+    ]
+  where
+    action = do
+      availability <- mapM validateExecutable (Map.elems (campaignCommands campaign))
+      let commandErrors = [Text.pack (show e) | Left e <- availability]
+      withOwnedDirectory ".heni-check-" $ \logs -> withSnapshot root policy $ \snapshot ->
+        forM (campaignCases campaign) $ \definition@CaseDefinition {caseId} -> do
+          result <- withEditedWorkspace (gitRunner logs caseId) noHooks snapshot definition (\_ _ -> pure ())
+          let editErrors = either (pure . Text.pack . show) (const []) result
+              diagnostics = commandErrors ++ editErrors
+          pure (Applicability caseId (null diagnostics) diagnostics)
+    gitRunner logs caseId workspace args = do
+      let prefix = Text.unpack (caseIdText caseId) ++ if "--check" `elem` args then "-check" else "-apply"
+          budget = either (error . show) id (mkStageBudget 5000000)
+          command = Command "git" args Set.empty Nothing [] budget (OutputPolicy 65536)
+      ProcessReport {evidence = StageEvidence {outcome}, errors} <- runCommand (defaultProcessOptions logs prefix) workspace Setup command `catch` \ProcessAborted {cause} -> throwIO cause
+      unless (null errors) (throwIO (EditInfrastructure (Text.intercalate "; " errors)))
+      case outcome of
+        Exited 0 -> pure (Right ())
+        Exited _ -> pure (Left "Git rejected patch applicability")
+        _ -> throwIO (EditInfrastructure (Text.pack (show outcome)))
+
+parseExecution :: Value -> Parser (Int, ReusePolicy)
+parseExecution = strictObject "execution" ["workers", "baselineReuse"] $ \o -> do
+  jobs <- defaultField o "workers" 1
+  unless (jobs > 0 && jobs <= 256) (fail "workers must be between 1 and 256")
+  reuse <- case KM.lookup "baselineReuse" o of
+    Nothing -> pure Fresh
+    Just Null -> pure Fresh
+    Just value ->
+      strictObject
+        "baselineReuse"
+        ["toolchain", "adapter"]
+        ( \r -> do
+            toolchain <- r .: "toolchain"
+            adapter <- r .:? "adapter"
+            unless (not (Text.null (Text.strip toolchain)) && maybe True (not . Text.null . Text.strip) adapter) (fail "empty reuse identity")
+            pure (ReuseBaselines toolchain adapter)
+        )
+        value
+  pure (jobs, reuse)
+
+parseResource :: Map.Map CommandId Command -> Value -> Parser Resource.ResourceProvider
+parseResource commands = strictObject "resource" ["name", "scope", "capacity", "identity", "version", "epoch", "environment", "setup", "reset", "teardown"] $ \o -> do
+  name <- o .: "name"
+  scope <-
+    defaultField o "scope" ("case" :: Text) >>= \case
+      "campaign" -> pure Resource.CampaignScope
+      "worker" -> pure Resource.WorkerScope
+      "case" -> pure Resource.CaseScope
+      _ -> fail "unknown resource scope"
+  capacity <- defaultField o "capacity" 1
+  identity <- o .: "identity"
+  version <- o .: "version"
+  epoch <- o .:? "epoch"
+  env <- defaultField o "environment" KM.empty >>= mapM (\(key, value) -> (Key.toString key,) <$> parseJSON value) . KM.toList
+  let binding = Resource.ResourceBinding identity version epoch env
+  _ <- checked (Resource.bindingEnvironment [(name, binding)])
+  let referenced key = do
+        ident <- o .: key >>= checked . mkCommandId
+        maybe (fail ("unknown resource command: " ++ Key.toString key)) pure (Map.lookup ident commands)
+  Resource.commandResource name scope capacity binding <$> referenced "setup" <*> referenced "reset" <*> referenced "teardown"
diff --git a/src/Test/Heni/Process.hs b/src/Test/Heni/Process.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Process.hs
@@ -0,0 +1,224 @@
+-- | Bounded commands, owned POSIX groups and independent stdout/stderr evidence.
+module Test.Heni.Process
+  ( Stream (..),
+    LogArtifact (..),
+    ProcessOptions (..),
+    defaultProcessOptions,
+    ProcessReport (..),
+    ProcessAborted (..),
+    runCommand,
+    validateExecutable,
+  )
+where
+
+import Control.Concurrent
+import Control.Exception hiding (handle)
+import Control.Monad (unless, when)
+import Data.ByteString qualified as BS
+import Data.IORef
+import Data.List (isPrefixOf, nub)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Text
+import GHC.Clock (getMonotonicTimeNSec)
+import System.Directory qualified as Dir
+import System.Environment (getEnvironment)
+import System.FilePath (isPathSeparator, (</>))
+import System.IO
+import System.Process qualified as P
+import System.Timeout (timeout)
+import Test.Heni.Case (ConfigError (..), mkRelativeTarget, targetPath)
+import Test.Heni.Command
+import Test.Heni.Expectation
+import Test.Heni.Internal.Process.Posix
+import Test.Heni.Workspace
+
+-- | Streams remain separate; a witness cannot span stdout and stderr.
+data Stream = Stdout | Stderr deriving stock (Eq, Show)
+
+-- | Bounded retained bytes and total byte count; paths are portable basenames.
+data LogArtifact = LogArtifact
+  {stream :: !Stream, path :: !FilePath, bytesSeen :: !Integer, bytesRetained :: !Int, truncated :: !Bool}
+  deriving stock (Eq, Show)
+
+-- | Observation callbacks may fail but cannot alter process outcomes.
+data ProcessOptions = ProcessOptions
+  { logDirectory :: !FilePath,
+    logPrefix :: !String,
+    graceBudget :: !StageBudget,
+    witnesses :: ![Text],
+    environmentRemovals :: ![String],
+    observer :: !(Stream -> BS.ByteString -> IO ())
+  }
+
+-- | A 200ms grace period and no diagnostic matching; specify an owned log directory.
+defaultProcessOptions :: FilePath -> String -> ProcessOptions
+defaultProcessOptions directory prefix = ProcessOptions directory prefix grace [] [] (\_ _ -> pure ())
+  where
+    grace = either (error . show) id (mkStageBudget 200000)
+
+-- | Primary stage facts plus bounded log metadata and secondary shutdown errors.
+data ProcessReport = ProcessReport
+  {evidence :: !StageEvidence, logs :: ![LogArtifact], preservedWitnesses :: ![Text], errors :: ![Text]}
+  deriving stock (Eq, Show)
+
+-- | Cancellation retains final process evidence. Campaigns finalize their ledger,
+-- then rethrow cause to honor the original asynchronous exception.
+data ProcessAborted = ProcessAborted {report :: !ProcessReport, cause :: !SomeException}
+  deriving stock (Show)
+  deriving anyclass (Exception)
+
+-- | Resolve host tools before execution. Explicit ./workspace programs are
+-- validated structurally here and resolved after the build, inside its workspace.
+-- Launch races are still handled in IO.
+validateExecutable :: Command -> IO (Either ConfigError ())
+validateExecutable command@Command {executable} = case validateCommand command of
+  Left e -> pure (Left e)
+  Right () | "./" `isPrefixOf` executable -> pure (Right ())
+  Right () -> do
+    found <- Dir.findExecutable executable
+    pure $ maybe (Left (ConfigError "executable" "executable is unavailable")) (const (Right ())) found
+
+data Capture = Capture !Integer !BS.ByteString !BS.ByteString ![Text]
+
+-- | Run literal executable/argv in an exclusive workspace. Logs are bounded per
+-- stream. Exact requested witnesses are scanned through all bytes and retained
+-- separately, so log truncation alone cannot erase a proved witness.
+runCommand :: ProcessOptions -> Workspace -> Stage -> Command -> IO ProcessReport
+runCommand ProcessOptions {logDirectory, logPrefix, graceBudget, witnesses, environmentRemovals, observer} workspace stage command@Command {executable, arguments, workingDirectory, environment, budget, output = OutputPolicy {limitBytes}} = do
+  started <- getMonotonicTimeNSec
+  let failed why = pure (ProcessReport (StageEvidence stage (LaunchFailed why) "" False unknownSelection Nothing 0 []) [] [] [why])
+  case validateCommand command of
+    Left e -> failed (Text.pack (show e))
+    Right () | null logPrefix || any isPathSeparator logPrefix || logPrefix `elem` [".", ".."] -> failed "invalid log prefix"
+    Right () | sum (map (BS.length . Text.encodeUtf8) witnesses) > 65536 || any Text.null witnesses -> failed "diagnostic witnesses must be nonempty and total at most 64KiB"
+    Right () -> do
+      launch <- try $ do
+        cwd <- maybe (pure (workspaceRoot workspace)) (checkedWorkspacePath workspace) workingDirectory `catch` workspaceLaunchError
+        isDirectory <- Dir.doesDirectoryExist cwd
+        unless isDirectory (ioError (userError "working directory does not exist"))
+        when ("./" `isPrefixOf` executable) $ do
+          let relative = maybe "" ((++ "/") . targetPath) workingDirectory ++ drop 2 executable
+          target <- either (ioError . userError . show) pure (mkRelativeTarget relative)
+          _ <- checkedWorkspacePath workspace target `catch` workspaceLaunchError
+          pure ()
+        inherited <- getEnvironment
+        let overrides = Map.fromList environment
+            effective = Map.toList (overrides `Map.union` foldr Map.delete (Map.fromList inherited) environmentRemovals)
+        Dir.createDirectoryIfMissing True logDirectory
+        withBinaryFile (logDirectory </> logPrefix ++ ".stdout.log") WriteMode $ \outLog ->
+          withBinaryFile (logDirectory </> logPrefix ++ ".stderr.log") WriteMode $ \errLog ->
+            supervise started cwd effective outLog errLog
+      case launch of
+        Left (e :: IOException) -> failed (Text.pack (show e))
+        Right result -> pure result
+  where
+    workspaceLaunchError (e :: WorkspaceError) = ioError (userError (show e))
+    supervise started cwd effective outLog errLog = mask $ \restore -> do
+      -- Dependency-owned record update is isolated to the process adapter; no
+      -- label instances propagate through the public library.
+      let spec =
+            (P.proc executable arguments)
+              { P.cwd = Just cwd,
+                P.env = Just effective,
+                P.std_in = P.NoStream,
+                P.std_out = P.CreatePipe,
+                P.std_err = P.CreatePipe,
+                P.create_group = True,
+                P.close_fds = True
+              }
+      (_, maybeOut, maybeErr, process) <- P.createProcess spec
+      pid <- P.getPid process
+      case (maybeOut, maybeErr) of
+        (Just stdoutHandle, Just stderrHandle) -> do
+          outRef <- newIORef (Capture 0 BS.empty BS.empty [])
+          errRef <- newIORef (Capture 0 BS.empty BS.empty [])
+          outDone <- newEmptyMVar
+          errDone <- newEmptyMVar
+          outThread <- forkFinally (restore (drain Stdout stdoutHandle outLog outRef)) (putMVar outDone)
+          errThread <- forkFinally (restore (drain Stderr stderrHandle errLog errRef)) (putMVar errDone)
+          let poll = do
+                readerFailure <- firstFailure [outDone, errDone]
+                case readerFailure of
+                  Just e -> throwIO e
+                  Nothing -> do
+                    exit <- P.getProcessExitCode process
+                    now <- getMonotonicTimeNSec
+                    case exit of
+                      Just code -> pure (exitOutcome code)
+                      Nothing | now - started >= fromIntegral (budgetMicroseconds budget) * 1000 -> pure Timeout
+                      Nothing -> threadDelay 5000 >> poll
+          primary <- try (restore poll) :: IO (Either SomeException ProcessOutcome)
+          deferred <- newIORef Nothing
+          let defer :: forall b. IO b -> IO b
+              defer action =
+                action `catch` \(e :: SomeAsyncException) -> do
+                  writeIORef deferred (Just (toException e))
+                  defer action
+          -- Cleanup runs masked and has no blocking operation without a bound.
+          shutdown <- uninterruptibleMask_ (stopGroup (budgetMicroseconds graceBudget) pid process)
+          let finish tid handle done = do
+                joined <- timeout (budgetMicroseconds graceBudget) (readMVar done)
+                case joined of
+                  Nothing -> do
+                    _ <- timeout (budgetMicroseconds graceBudget) (killThread tid)
+                    _ <- timeout (budgetMicroseconds graceBudget) (readMVar done)
+                    closeQuietly handle
+                    pure ["output reader did not finish within shutdown budget"]
+                  Just (Left e) -> closeQuietly handle >> pure [Text.pack (displayException e)]
+                  Just (Right ()) -> closeQuietly handle >> pure []
+          outErrors <- defer (finish outThread stdoutHandle outDone)
+          errErrors <- defer (finish errThread stderrHandle errDone)
+          flushResult <- defer (try (hFlush outLog >> hFlush errLog)) :: IO (Either IOException ())
+          secondaryCancellation <- readIORef deferred
+          outCapture <- readIORef outRef
+          errCapture <- readIORef errRef
+          ended <- getMonotonicTimeNSec
+          let found = nub (captureWitnesses outCapture ++ captureWitnesses errCapture)
+              -- Only exact byte-matched witnesses enter classifier text. Lenient
+              -- display decoding of malformed UTF-8 must never invent a match.
+              outputText = Text.intercalate "\n" found
+              secondary = shutdown ++ outErrors ++ errErrors ++ either (\e -> [Text.pack (show e)]) (const []) flushResult
+              primaryErrors = either (\e -> case fromException e :: Maybe SomeAsyncException of Just _ -> []; Nothing -> [Text.pack (displayException e)]) (const []) primary
+              outcome = either (const Cancelled) id primary
+              unavailable = not (null (primaryErrors ++ secondary))
+              stageEvidence = StageEvidence stage outcome outputText unavailable unknownSelection Nothing (fromIntegral (ended - started) / 1e9) [logPrefix ++ ".stdout.log", logPrefix ++ ".stderr.log"]
+              result = ProcessReport stageEvidence [artifact Stdout outCapture, artifact Stderr errCapture] found (primaryErrors ++ secondary)
+          case primary of
+            Left e | Just (_ :: SomeAsyncException) <- fromException e -> throwIO (ProcessAborted result e)
+            _ -> case secondaryCancellation of
+              Just e -> throwIO (ProcessAborted result e)
+              Nothing -> pure result
+        _ -> do
+          _ <- stopGroup (budgetMicroseconds graceBudget) pid process
+          ioError (userError "process library did not supply requested output pipes")
+    captureWitnesses (Capture _ _ _ found) = found
+    captureTruncated (Capture count b _ _) = count > fromIntegral (BS.length b)
+    artifact stream capture@(Capture count b _ _) = LogArtifact stream (logPrefix ++ suffix stream) count (BS.length b) (captureTruncated capture)
+    suffix Stdout = ".stdout.log"
+    suffix Stderr = ".stderr.log"
+    drain stream input logHandle ref = do
+      chunk <- BS.hGetSome input 32768
+      unless (BS.null chunk) $ do
+        Capture count kept tailBytes found <- readIORef ref
+        let retain = BS.take (max 0 (limitBytes - BS.length kept)) chunk
+            haystack = tailBytes <> chunk
+            newFound = nub (found ++ [w | w <- witnesses, Text.encodeUtf8 w `BS.isInfixOf` haystack])
+            overlap = maximum (0 : map ((subtract 1) . BS.length . Text.encodeUtf8) witnesses)
+            nextTail = BS.drop (max 0 (BS.length haystack - overlap)) haystack
+        BS.hPut logHandle retain
+        writeIORef ref (Capture (count + fromIntegral (BS.length chunk)) (kept <> retain) nextTail newFound)
+        observer stream chunk
+        drain stream input logHandle ref
+
+firstFailure :: [MVar (Either SomeException ())] -> IO (Maybe SomeException)
+firstFailure [] = pure Nothing
+firstFailure (done : rest) = do
+  result <- tryReadMVar done
+  case result of Just (Left e) -> pure (Just e); _ -> firstFailure rest
+
+closeQuietly :: Handle -> IO ()
+closeQuietly handle = do
+  _ <- try (hClose handle) :: IO (Either IOException ())
+  pure ()
diff --git a/src/Test/Heni/Replay.hs b/src/Test/Heni/Replay.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Replay.hs
@@ -0,0 +1,19 @@
+-- | Opaque replay tokens belong to the producing framework, not a universal DSL.
+module Test.Heni.Replay (replayCommand) where
+
+import Data.Text qualified as Text
+import Test.Heni.Command
+import Test.Heni.Expectation
+
+-- | Reuse the candidate test command with the exact token and generator contract.
+-- A typed CaseHooks replayBuilder may replace this for another framework.
+replayCommand :: ReplayEvidence -> Command -> Command
+replayCommand ReplayEvidence {token, generator} (Command exe args sensitive working env budget output) =
+  Command
+    exe
+    args
+    sensitive
+    working
+    (("HENI_REPLAY_TOKEN", Text.unpack token) : ("HENI_REPLAY_GENERATOR", Text.unpack generator) : filter (\(key, _) -> key `notElem` ["HENI_REPLAY_TOKEN", "HENI_REPLAY_GENERATOR"]) env)
+    budget
+    output
diff --git a/src/Test/Heni/Report.hs b/src/Test/Heni/Report.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Report.hs
@@ -0,0 +1,187 @@
+-- | Versioned portable reports and read-only journal recovery.
+module Test.Heni.Report
+  ( StoredReport (..),
+    ReportError (..),
+    reportJSON,
+    readReport,
+    recoverReport,
+    writeReport,
+    appendJournal,
+    runResultJSON,
+  )
+where
+
+import Control.Exception
+import Control.Monad (forM_, unless)
+import Data.Aeson
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KM
+import Data.Aeson.Types (Parser, parseEither)
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Foldable (toList)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import System.Directory qualified as Dir
+import System.FilePath (takeDirectory, (</>))
+import System.IO
+import Test.Heni.Case (mkRelativeTarget, mkSelectedCases)
+import Test.Heni.Command (ProcessOutcome (..))
+import Test.Heni.Expectation (StageEvidence (..))
+import Test.Heni.Internal.Codec
+import Test.Heni.Internal.Files (WorkspaceError, checkedPath)
+import Test.Heni.Result
+import Prelude hiding (sequence)
+
+-- | Version-one run-result payload used by nonterminal renderers.
+runResultJSON :: RunResult -> Value
+runResultJSON = resultJSON
+
+-- | Effective, redacted configuration and the authoritative classified result.
+data StoredReport = StoredReport {metadata :: !Value, result :: !RunResult}
+  deriving stock (Eq, Show)
+
+-- | IO, schema, ledger, journal and evidence-containment failures.
+newtype ReportError = ReportError Text
+  deriving stock (Eq, Show)
+  deriving anyclass (Exception)
+
+-- | Stable v1 envelope, with codecs independent of Haskell constructor names.
+reportJSON :: StoredReport -> Value
+reportJSON StoredReport {metadata, result} =
+  object
+    ["schemaVersion" .= (1 :: Int), "toolVersion" .= String "0.1.0.0", "metadata" .= metadata, "result" .= resultJSON result]
+
+parseReport :: Value -> Parser StoredReport
+parseReport = strictObject "report" ["schemaVersion", "toolVersion", "metadata", "result"] $ \o -> do
+  version <- o .: "schemaVersion"
+  unless (version == (1 :: Int)) (fail "unsupported report schemaVersion")
+  (_ :: Text) <- o .: "toolVersion"
+  StoredReport <$> o .: "metadata" <*> (o .: "result" >>= parseResult)
+
+-- | Atomic replacement within an exclusively allocated run directory. This
+-- protects readers from half-written JSON, not from arbitrary storage failure.
+writeReport :: FilePath -> StoredReport -> IO ()
+writeReport directory stored = mask $ \restore -> do
+  (temporary, file) <- openBinaryTempFile directory ".report-"
+  let remove = do
+        _ <- try (hClose file) :: IO (Either IOException ())
+        exists <- Dir.doesFileExist temporary
+        if exists then Dir.removeFile temporary else pure ()
+  (restore (LBS.hPut file (encode (reportJSON stored))) >> hClose file >> Dir.renameFile temporary (directory </> "report.json")) `onException` remove
+
+-- | Append a complete ledger snapshot after each observation. Sequence numbers
+-- make missing, duplicate and reordered records detectable during recovery.
+appendJournal :: FilePath -> Int -> Text -> StoredReport -> IO ()
+appendJournal directory sequence event stored =
+  withBinaryFile (directory </> "journal.jsonl") AppendMode $ \file -> do
+    LBS.hPut file (encode (object ["sequence" .= sequence, "event" .= event, "report" .= reportJSON stored]))
+    BS.hPut file "\n"
+    hFlush file
+
+-- | Read and validate summary accounting and every referenced log's containment
+-- and existence. Nothing executes while reading evidence.
+readReport :: FilePath -> IO (Either ReportError StoredReport)
+readReport path = handleErrors $ do
+  bytes <- BS.readFile path
+  value <- either (throwIO . ReportError . Text.pack) pure (decodeJSON bytes)
+  stored <- either (throwIO . ReportError . Text.pack) pure (parseEither parseReport value)
+  validateArtifacts (takeDirectory path) stored
+  pure stored
+
+validateArtifacts :: FilePath -> StoredReport -> IO ()
+validateArtifacts root StoredReport {metadata, result = RunResult {results}} = do
+  -- Reuse points at the original producer's actual baseline, never a synthetic
+  -- fresh attempt. Secrets used in equality checks deliberately are not stored.
+  case metadata of
+    Object o -> case KM.lookup "caseDetails" o of
+      Just (Object cases) -> forM_ (KM.toList cases) $ \(current, details) -> case details of
+        Object fields -> case KM.lookup "baseline-reuse" fields of
+          Just (Object reuse) | KM.lookup "reused" reuse == Just (Bool True) -> do
+            owner <- case KM.lookup "ownerCase" reuse of Just (String name) -> pure (Key.fromText name); _ -> throwIO (ReportError "reused baseline has no owner")
+            unless (owner /= current) (throwIO (ReportError "reused baseline points to itself"))
+            observed <- case KM.lookup owner cases of
+              Just (Object ownerDetails) -> case KM.lookup "baseline-build" ownerDetails of
+                Just (Object observation) -> case KM.lookup "stage" observation of Just stage -> pure stage; _ -> throwIO (ReportError "baseline owner has no build evidence")
+                _ -> throwIO (ReportError "baseline owner has no original baseline")
+              _ -> throwIO (ReportError "unknown baseline owner")
+            StageEvidence {outcome} <- either (throwIO . ReportError . Text.pack) pure (parseEither parseStage observed)
+            unless (outcome == Exited 0) (throwIO (ReportError "reused baseline owner did not build successfully"))
+          _ -> pure ()
+        _ -> pure ()
+      _ -> pure ()
+    _ -> pure ()
+  let caseStages = concat [stages | CaseResult {evidence = CaseEvidence {stages}} <- results]
+      metadataStages = case metadata of
+        Object o -> case KM.lookup "caseDetails" o of
+          Just (Object cases) -> [value | Object details <- KM.elems cases, Object observation <- KM.elems details, Just value <- [KM.lookup "stage" observation]]
+          _ -> []
+        _ -> []
+      resourceStages = case metadata of
+        Object o -> case KM.lookup "resources" o of
+          Just (Array events) -> [stage | Object event <- toList events, Just stage <- [KM.lookup "stage" event]]
+          _ -> []
+        _ -> []
+  additionalStages <- mapM (either (throwIO . ReportError . Text.pack) pure . parseEither parseStage) (metadataStages ++ resourceStages)
+  forM_ (caseStages ++ additionalStages) $ \StageEvidence {logPaths} -> forM_ logPaths $ \relative -> do
+    target <- either (throwIO . ReportError . Text.pack . show) pure (mkRelativeTarget relative)
+    path <- checkedPath root target
+    exists <- Dir.doesFileExist path
+    unless exists (throwIO (ReportError ("missing evidence artifact: " <> Text.pack relative)))
+
+-- | Recover the last complete journal snapshot, ignoring only a truncated final
+-- non-newline record. Interior corruption, sequence gaps and changed ledgers
+-- are errors. The recovered run is explicitly untrustworthy and never resumed.
+recoverReport :: FilePath -> IO (Either ReportError StoredReport)
+recoverReport directory = handleErrors $ do
+  bytes <- BS.readFile (directory </> "journal.jsonl")
+  let lines' = BS.split 10 bytes
+      complete = if BS.null bytes || BS.last bytes == 10 then init lines' else lines'
+  records <- mapM decodeRecord (zip [0 :: Int ..] complete)
+  usable <- case reverse records of
+    [] -> throwIO (ReportError "empty journal")
+    Right stored : _ -> pure stored
+    Left why : rest | not (BS.null bytes) && BS.last bytes /= 10 && "end-of-input" `Text.isInfixOf` why -> case rest of
+      Right stored : _ -> pure stored
+      _ -> throwIO (ReportError "no complete journal record")
+    Left why : _ -> throwIO (ReportError why)
+  let interior = if not (BS.null bytes) && BS.last bytes /= 10 then init records else records
+  forM_ interior (either (throwIO . ReportError) (const (pure ())))
+  let ledgers = [ids | Right StoredReport {result = RunResult {selectedIds = ids}} <- records]
+  case ledgers of
+    first : rest -> unless (all (== first) rest) (throwIO (ReportError "journal selected ledger changed"))
+    [] -> throwIO (ReportError "journal has no ledger")
+  let StoredReport metadata RunResult {selectedIds, results, failures} = usable
+  selected <- either (throwIO . ReportError . Text.pack . show) pure (mkSelectedCases selectedIds)
+  recovered <- either (throwIO . ReportError . Text.pack . show) pure (summarizeRun selected results (failures ++ [RunInfrastructure "recovered journal; execution was not resumed"]))
+  let stored = StoredReport metadata recovered
+  validateArtifacts directory stored
+  pure stored
+  where
+    decodeRecord (expected, line) = case decodeJSON line of
+      Left why -> pure (Left (Text.pack why))
+      Right value -> do
+        record <-
+          either
+            (throwIO . ReportError . Text.pack)
+            pure
+            ( parseEither
+                ( strictObject "journal record" ["sequence", "event", "report"] $ \o -> do
+                    sequence <- o .: "sequence"
+                    unless (sequence == expected) (fail "journal sequence gap or duplicate")
+                    event <- o .: "event"
+                    unless ((event :: Text) `elem` ["selected", "snapshot", "stage", "case", "finalized"]) (fail "unknown journal event")
+                    o .: "report" >>= parseReport
+                )
+                value
+            )
+        pure (Right record)
+
+handleErrors :: forall a. IO a -> IO (Either ReportError a)
+handleErrors action =
+  catches
+    (Right <$> action)
+    [ Handler (pure . Left :: ReportError -> IO (Either ReportError a)),
+      Handler (\(e :: WorkspaceError) -> pure (Left (ReportError (Text.pack (show e))))),
+      Handler (\(e :: IOException) -> pure (Left (ReportError (Text.pack (show e)))))
+    ]
diff --git a/src/Test/Heni/Resource.hs b/src/Test/Heni/Resource.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Resource.hs
@@ -0,0 +1,172 @@
+{-# LANGUAGE RankNTypes #-}
+
+-- | Consumer-owned resource lifetimes. Trusted callbacks must implement their
+-- own external reset semantics; workspace disposal cannot roll back a service.
+module Test.Heni.Resource
+  ( ResourceScope (..),
+    ResourceBinding (..),
+    ResourceContext (..),
+    ResourceEvent (..),
+    ResourceProvider,
+    resourceName,
+    resourceScope,
+    resourceCapacity,
+    resourceCommands,
+    ResourceLease,
+    resourceBinding,
+    prepareResource,
+    managedResource,
+    commandResource,
+    withResources,
+    validateResources,
+    ResourcePhase (..),
+    ResourceError (..),
+    bindingEnvironment,
+  )
+where
+
+import Control.Exception
+import Control.Monad (unless)
+import Data.Char (isAsciiUpper)
+import Data.IORef
+import Data.List (nub)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Test.Heni.Case (ConfigError (..))
+import Test.Heni.Command
+import Test.Heni.Expectation
+import Test.Heni.Internal.Files (withFinalizer)
+import Test.Heni.Process
+import Test.Heni.Workspace
+
+-- | One acquisition for the campaign, each worker, or each claimed case.
+data ResourceScope = CampaignScope | WorkerScope | CaseScope deriving stock (Eq, Show)
+
+-- | Nonsecret identities/versions are reportable. Unknown epoch disables reuse;
+-- environment values remain in memory and are redacted from portable reports.
+data ResourceBinding = ResourceBinding
+  {identity :: !Text, version :: !Text, epoch :: !(Maybe Text), environment :: ![(String, String)]}
+  deriving stock (Eq, Show)
+
+-- | Owned context for resource hooks and their supervised command artifacts.
+data ResourceContext = ResourceContext
+  { instanceId :: !Text,
+    snapshot :: !SourceSnapshot,
+    artifactDirectory :: !FilePath,
+    observeResource :: !(ResourceEvent -> IO ())
+  }
+
+-- | Facts about completed ownership transitions, with no environment values.
+data ResourceEvent = ResourceEvent !Text !Text !ResourcePhase !ResourceBinding | ResourceCommandFinished !Text !Text !ResourcePhase !ProcessReport
+  deriving stock (Eq, Show)
+
+-- | Lifecycle failures differ from test assertions.
+data ResourcePhase = AcquireResource | ResetResource | ReleaseResource deriving stock (Eq, Show)
+
+-- | Resource name, failed lifecycle phase and original exception.
+data ResourceError = ResourceError !Text !ResourcePhase !SomeException
+  deriving stock (Show)
+  deriving anyclass (Exception)
+
+-- | Opaque lease; only its owner can update the prepared state.
+data ResourceLease = ResourceLease !(IO ResourceBinding) !(IO ResourceBinding)
+
+-- | A scoped CPS provider keeps arbitrary consumer values out of JSON/core types.
+data ResourceProvider = ResourceProvider
+  { -- | Uppercase identity used by claims and environment namespaces.
+    resourceName :: !Text,
+    -- | Ownership lifetime; each instance is acquired and released once.
+    resourceScope :: !ResourceScope,
+    -- | Maximum concurrent claims, also bounding worker-scoped acquisition.
+    resourceCapacity :: !Int,
+    -- | Portable lifecycle commands for preflight; arbitrary providers use [].
+    resourceCommands :: ![Command],
+    provide :: forall a. ResourceContext -> (ResourceLease -> IO a) -> IO a
+  }
+
+-- | Current identity without changing the external fixture.
+resourceBinding :: ResourceLease -> IO ResourceBinding
+resourceBinding (ResourceLease current _) = current
+
+-- | Reset before both baseline and candidate; a failure invalidates the case.
+prepareResource :: ResourceLease -> IO ResourceBinding
+prepareResource (ResourceLease _ reset) = reset
+
+-- | Acquire in declaration order and release in reverse order, including partial
+-- acquisition, callback failure and cancellation. Cleanup failures retain the
+-- primary exception via 'WorkspaceFinalizationError'. No timeout guarantee is
+-- possible for arbitrary uninterruptible consumer foreign calls.
+managedResource :: Text -> ResourceScope -> Int -> (ResourceContext -> IO (value, ResourceBinding)) -> (value -> IO ResourceBinding) -> (value -> IO ()) -> ResourceProvider
+managedResource name scope capacity acquire reset release = ResourceProvider name scope capacity [] $ \context@ResourceContext {instanceId, observeResource} action -> mask $ \restore -> do
+  (value, initial) <- phase AcquireResource (restore (acquire context))
+  current <- newIORef initial
+  let announce p b = observeResource (ResourceEvent name instanceId p b)
+      check b = either (throwIO . userError . show) pure (validateBinding name b)
+      resetLease = mask $ \unmask -> do
+        next <- phase ResetResource (unmask (reset value))
+        writeIORef current next
+        check next
+        announce ResetResource next
+        pure next
+      dispose = do
+        latest <- readIORef current
+        phase ReleaseResource (release value)
+        announce ReleaseResource latest
+  (check initial >> announce AcquireResource initial >> restore (action (ResourceLease (readIORef current) resetLease))) `withFinalizer` dispose
+  where
+    phase :: ResourcePhase -> IO b -> IO b
+    phase step work =
+      work `catch` \(e :: SomeException) -> case fromException e :: Maybe SomeAsyncException of
+        Just _ -> throwIO e
+        Nothing -> throwIO (ResourceError name step e)
+
+-- | Portable supervised setup/reset/teardown. Each provider instance owns a
+-- private workspace. Commands have independent declared budgets and logs.
+commandResource :: Text -> ResourceScope -> Int -> ResourceBinding -> Command -> Command -> Command -> ResourceProvider
+commandResource name scope capacity binding setup reset teardown = ResourceProvider name scope capacity [setup, reset, teardown] $ \context@ResourceContext {snapshot, artifactDirectory, instanceId, observeResource} action ->
+  withWorkspace snapshot $ \workspace -> do
+    counter <- newIORef (0 :: Int)
+    let run phase command = do
+          index <- atomicModifyIORef' counter (\n -> (n + 1, n))
+          let prefix = "resource-" ++ Text.unpack name ++ "-" ++ Text.unpack instanceId ++ "-" ++ show index
+          report@ProcessReport {evidence = StageEvidence {outcome}, errors} <-
+            runCommand (defaultProcessOptions artifactDirectory prefix) workspace (if phase == ReleaseResource then Teardown else Setup) (addEnvironment command)
+              `catch` \ProcessAborted {report, cause} -> throwIO cause `withFinalizer` observeResource (ResourceCommandFinished name instanceId phase report)
+          observeResource (ResourceCommandFinished name instanceId phase report)
+          unless (outcome == Exited 0 && null errors) (ioError (userError ("resource command " ++ show phase ++ ": " ++ show outcome ++ " " ++ show errors)))
+        addEnvironment (Command e a s w env b o) = Command e a s w (environment ++ filter ((`notElem` map fst environment) . fst) env) b o
+        ResourceBinding {environment} = binding
+        provider = managedResource name scope capacity (\_ -> run AcquireResource setup >> pure ((), binding)) (\() -> run ResetResource reset >> pure binding) (\() -> run ReleaseResource teardown)
+    provide provider context action
+
+-- | Compose ownership; the callback receives named opaque leases.
+withResources :: [ResourceProvider] -> ResourceContext -> ([(Text, ResourceLease)] -> IO a) -> IO a
+withResources [] _ action = action []
+withResources (provider : rest) context action = provide provider context $ \lease ->
+  withResources rest context (action . ((resourceName provider, lease) :))
+
+-- | Validate names, capacities and namespaces before any acquisition.
+validateResources :: [ResourceProvider] -> Either ConfigError ()
+validateResources providers = do
+  let names = map resourceName providers
+  unless (length names == length (nub names)) (Left (ConfigError "resources" "duplicate resource name"))
+  mapM_
+    ( \ResourceProvider {resourceName, resourceCapacity} -> do
+        unless (validName resourceName) (Left (ConfigError "resources.name" "use uppercase ASCII names with digits/underscores"))
+        unless (resourceCapacity > 0) (Left (ConfigError "resources.capacity" "capacity must be positive"))
+    )
+    providers
+
+validName :: Text -> Bool
+validName name = case Text.uncons name of Nothing -> False; Just (first, rest) -> isAsciiUpper first && Text.all (\c -> isAsciiUpper c || (c >= '0' && c <= '9') || c == '_') rest
+
+validateBinding :: Text -> ResourceBinding -> Either ConfigError ()
+validateBinding name ResourceBinding {identity, version, epoch, environment} = do
+  unless (not (Text.null identity || Text.null version) && maybe True (not . Text.null) epoch) (Left (ConfigError "resource.binding" "identity/version/known epoch must be nonempty"))
+  let keys = map fst environment
+      prefix = "HENI_RESOURCE_" <> name <> "_"
+  unless (length keys == length (nub keys) && all (\key -> prefix `Text.isPrefixOf` Text.pack key && not (any (`elem` ['=', '\0']) key)) keys && all (notElem '\0' . snd) environment) (Left (ConfigError "resource.environment" "duplicate or unnamespaced environment binding"))
+
+-- | Combine bindings only after validation; resource namespaces prevent clashes.
+bindingEnvironment :: [(Text, ResourceBinding)] -> Either ConfigError [(String, String)]
+bindingEnvironment bindings = mapM_ (uncurry validateBinding) bindings >> pure (concatMap (\(_, ResourceBinding {environment}) -> environment) bindings)
diff --git a/src/Test/Heni/Result.hs b/src/Test/Heni/Result.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Result.hs
@@ -0,0 +1,143 @@
+-- | Pure evidence classification and exactly-once campaign accounting.
+module Test.Heni.Result
+  ( MutationEvidence (..),
+    BaselineEvidence (..),
+    CaseEvidence (..),
+    Verdict (..),
+    DetectionKind (..),
+    CaseResult (..),
+    RunFailure (..),
+    RunStatus (..),
+    SemanticScore (..),
+    RunResult (..),
+    classifyCase,
+    summarizeRun,
+    validateStageEvidence,
+  )
+where
+
+import Data.List (find, nub)
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Test.Heni.Case (validateCase)
+import Test.Heni.Internal.Model
+
+-- | Classify observations without running IO. Validity gates precede all witnesses.
+classifyCase :: CaseDefinition -> CaseEvidence -> CaseResult
+classifyCase definition@CaseDefinition {caseId, expectation, evidenceMode} evidence@CaseEvidence {baseline, mutation, stages, infrastructureErrors, cleanupErrors, stopped}
+  | Left err <- validateCase definition = result InfrastructureError Nothing (Text.pack (show err))
+  | not (null infrastructureErrors && null cleanupErrors) = result InfrastructureError Nothing "infrastructure or cleanup failed"
+  | any stageInfrastructure stages = result InfrastructureError Nothing "launch, setup, output, or evidence protocol failed"
+  | length stages /= Set.size (Set.fromList [s | StageEvidence {stage = s} <- stages]) = result InfrastructureError Nothing "duplicate stage evidence"
+  | Just why <- stopped = result NotRun Nothing why
+  | baseline /= BaselinePassed = result NotRun Nothing "unchanged baseline did not pass"
+  | EditRejected why <- mutation = result InvalidMutation Nothing why
+  | expectation /= Control && mutation /= Changed = result InvalidMutation Nothing "mutation did not change bytes"
+  | expectation == Control && mutation /= Unchanged = result InvalidMutation Nothing "control changed bytes"
+  | any (\StageEvidence {stage, outcome} -> outcome == Timeout && (stage /= Replay || not requiresReplay)) stages = result TimedOut Nothing "stage exceeded wall-clock budget"
+  | any (\StageEvidence {outcome} -> outcome == Cancelled) stages = result NotRun Nothing "stage cancelled"
+  | otherwise = classifyExpectation expectation
+  where
+    requiresReplay = case expectation of NamedTests _ RequireReplay -> True; _ -> False
+    semanticCase = case expectation of
+      NamedTests {} -> True
+      Diagnostic Test _ _ -> True
+      _ -> False
+    result v d r = CaseResult caseId v d semanticCase (expectation == Control) r evidence
+    at s = find (\StageEvidence {stage} -> stage == s) stages
+    passed s = case at s of Just StageEvidence {outcome = Exited 0} -> True; _ -> False
+    buildGate next
+      | at Build == Nothing = result InfrastructureError Nothing "missing build evidence"
+      | not (passed Build) = result UnexpectedBuildFailure Nothing "semantic candidate did not build"
+      | otherwise = next
+    missing = result InfrastructureError Nothing "missing required stage evidence"
+    selectionGate next = case at Test of
+      Just StageEvidence {selection = SelectionEvidence {selected, executed}} | evidenceMode == StructuredTestsV1 && (maybe True (<= 0) selected || maybe True (<= 0) executed) -> result Uncovered Nothing "structured test selection did not execute"
+      _ -> next
+    wrong = result WrongFailure Nothing "observed failure did not match the declared expectation"
+    survived = result Survived Nothing "candidate passed without the required detection"
+    caught kind = result Caught (Just kind) "declared failure and witness matched"
+    diagnostic s code witness kind = case at s of
+      Nothing -> missing
+      Just StageEvidence {outcome, transcript}
+        | outcome == Exited code && witness `Text.isInfixOf` transcript -> caught kind
+        | outcome == Exited 0 -> survived
+        | otherwise -> wrong
+    classifyExpectation (Diagnostic Build code witness) = diagnostic Build code witness ExpectedBuildRejection
+    classifyExpectation (Diagnostic s code witness) = buildGate (selectionGate (diagnostic s code witness GenericDiagnostic))
+    classifyExpectation (BuildRejection code witness) = diagnostic Build code witness ExpectedBuildRejection
+    classifyExpectation (CrashProbe sig probe) = buildGate $ case at Test of
+      Nothing -> missing
+      Just StageEvidence {outcome, transcript}
+        | outcome == Signaled sig && probe `Text.isInfixOf` transcript -> caught ExplicitCrash
+        | outcome == Exited 0 -> survived
+        | otherwise -> wrong
+    classifyExpectation Control = buildGate $ selectionGate $ case at Test of
+      Nothing -> missing
+      Just StageEvidence {outcome = Exited 0} -> result Caught (Just ControlPassed) "unchanged control passed"
+      _ -> wrong
+    classifyExpectation (NamedTests expected policy) = buildGate $ case at Test of
+      Nothing -> missing
+      Just original@StageEvidence {outcome, selection = SelectionEvidence {selected, executed, executedIds, failedIds, skippedIds}}
+        | maybe True (<= 0) selected || maybe True (<= 0) executed || not (expected `Set.isSubsetOf` executedIds) || not (Set.null (Set.intersection expected skippedIds)) -> result Uncovered Nothing "intended tests did not execute"
+        | outcome == Exited 0 && Set.null failedIds -> survived
+        | outcome == Exited 0 || outcome /= Exited 1 || failedIds /= expected -> wrong
+        | policy == RequireReplay && not (reproduced expected original) -> result WrongFailure Nothing "required replay did not reproduce identical failure and generator identity"
+        | otherwise -> caught SemanticNamed
+    reproduced expected StageEvidence {replay = Just ReplayEvidence {token = t, generator = g, failures = fs}} =
+      not (Text.null t || Text.null g) && fs == expected && case at Replay of
+        Just StageEvidence {outcome = Exited 1, selection = SelectionEvidence {executed, executedIds, failedIds, skippedIds}, replay = Just ReplayEvidence {token = rt, generator = rg, failures = rfs}} ->
+          maybe False (> 0) executed && expected `Set.isSubsetOf` executedIds && Set.null (Set.intersection expected skippedIds) && failedIds == expected && rfs == expected && rt == t && rg == g
+        _ -> False
+    reproduced _ _ = False
+
+stageInfrastructure :: StageEvidence -> Bool
+stageInfrastructure StageEvidence {stage, outcome, truncated, selection = SelectionEvidence {discovered, selected, executed, executedIds, failedIds, skippedIds, setupErrors}} =
+  launchFailure || hookFailure || truncated || not (null setupErrors) || invalidCounts || invalidIds
+  where
+    launchFailure = case outcome of LaunchFailed {} -> True; _ -> False
+    hookFailure = stage `elem` [Setup, Teardown] && outcome `notElem` [Exited 0, Timeout, Cancelled]
+    invalidCounts =
+      any (maybe False (< 0)) [discovered, selected, executed]
+        || exceeds selected discovered
+        || exceeds executed selected
+        || maybe False (< Set.size executedIds) executed
+    exceeds (Just a) (Just b) = a > b
+    exceeds _ _ = False
+    invalidIds = not (failedIds `Set.isSubsetOf` executedIds) || not (Set.null (Set.intersection executedIds skippedIds))
+
+-- | Shared protocol/validity check for unchanged baselines and candidates.
+validateStageEvidence :: StageEvidence -> Either Text.Text ()
+validateStageEvidence evidence
+  | stageInfrastructure evidence = Left "launch, setup, output, or selection evidence is invalid"
+  | otherwise = Right ()
+
+-- | Reject duplicate/foreign results, fill missing results with 'NotRun', and
+-- recompute totals from the selected ledger. Input ordering cannot inflate scores.
+summarizeRun :: SelectedCases -> [CaseResult] -> [RunFailure] -> Either ConfigError RunResult
+summarizeRun (SelectedCases ids) supplied suppliedFailures
+  | length supplied /= Map.size byId = bad "duplicate case result"
+  | any (`notElem` ids) (Map.keys byId) = bad "result for unselected case"
+  | any (`notElem` ids) affected = bad "run failure refers to unselected case"
+  | otherwise = Right (RunResult ids ordered allFailures runStatus semanticScore)
+  where
+    bad = Left . ConfigError "results"
+    byId = Map.fromList [(i, r) | r@CaseResult {caseId = i} <- supplied]
+    missing i = CaseResult i NotRun Nothing False False "selected case did not complete" (CaseEvidence BaselineUnavailable Unchanged [] [] [] (Just "not executed"))
+    ordered = [Map.findWithDefault (missing i) i byId | i <- ids]
+    controlFailures = [ControlFailure i why | CaseResult {caseId = i, verdict, reason = why, control = True} <- ordered, verdict /= Caught]
+    baselineFailures = [BaselineFailure [i] why | CaseResult {caseId = i, evidence = CaseEvidence {baseline = BaselineFailed why}} <- ordered]
+    allFailures = nub (suppliedFailures ++ controlFailures ++ baselineFailures)
+    affected = concatMap (\case BaselineFailure xs _ -> xs; ControlFailure i _ -> [i]; _ -> []) suppliedFailures
+    hasInfra = any (\CaseResult {verdict} -> verdict == InfrastructureError) ordered || any (\case RunInfrastructure {} -> True; _ -> False) allFailures
+    interrupted = any (\case Interrupted {} -> True; _ -> False) allFailures
+    runStatus
+      | hasInfra = RunUntrustworthy
+      | interrupted = RunInterrupted
+      | not (null allFailures) || any (\CaseResult {verdict} -> verdict /= Caught) ordered = RunFailed
+      | otherwise = RunPassed
+    numerator = length [() | CaseResult {semantic = True, verdict = Caught} <- ordered]
+    denominator = numerator + length [() | CaseResult {semantic = True, verdict = Survived} <- ordered]
+    isComplete = null allFailures && all (\CaseResult {verdict} -> verdict `elem` [Caught, Survived]) ordered
+    semanticScore = if denominator == 0 then Nothing else Just (SemanticScore numerator denominator isComplete)
diff --git a/src/Test/Heni/Runner.hs b/src/Test/Heni/Runner.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Runner.hs
@@ -0,0 +1,615 @@
+-- | One baseline/candidate lifecycle shared by library users and the CLI.
+module Test.Heni.Runner
+  ( Campaign,
+    mkCampaign,
+    campaignCases,
+    campaignCommands,
+    RunOptions (..),
+    ReusePolicy (..),
+    defaultRunOptions,
+    RunEvent (..),
+    ConfigurationFailure (..),
+    runCampaign,
+  )
+where
+
+import Control.Concurrent.MVar
+import Control.Exception hiding (handle)
+import Control.Monad (forM_, unless, when)
+import Data.Aeson
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KM
+import Data.ByteString qualified as BS
+import Data.Foldable (toList)
+import Data.IORef
+import Data.List (sort)
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Text
+import Data.Version (showVersion)
+import System.Directory qualified as Dir
+import System.Environment (getEnvironment)
+import System.FilePath (takeDirectory)
+import System.Info qualified as Platform
+import Test.Heni.Adapter qualified as Adapter
+import Test.Heni.BuildReuse qualified as Build
+import Test.Heni.Case
+import Test.Heni.Command
+import Test.Heni.Crash qualified as Crash
+import Test.Heni.Edit
+import Test.Heni.Expectation
+import Test.Heni.Internal.Baseline qualified as Baseline
+import Test.Heni.Internal.Codec
+import Test.Heni.Internal.Files (digest, withFinalizer)
+import Test.Heni.Process
+import Test.Heni.Replay qualified as Replay
+import Test.Heni.Report
+import Test.Heni.Resource qualified as Resource
+import Test.Heni.Result
+import Test.Heni.Scheduler qualified as Scheduler
+import Test.Heni.Workspace
+import Prelude hiding (sequence)
+
+-- | Validated references and portable definitions, with optional trusted IO hooks.
+data Campaign = Campaign !(Map.Map CommandId Command) ![(CaseDefinition, CaseHooks)]
+
+-- | Construction rejects all case/command definition errors before execution.
+mkCampaign :: Map.Map CommandId Command -> [(CaseDefinition, CaseHooks)] -> Either ConfigError Campaign
+mkCampaign commands definitions = do
+  _ <- mkSelectedCases [i | (CaseDefinition {caseId = i}, _) <- definitions]
+  mapM_ validateCommand (Map.elems commands)
+  forM_ definitions $ \(definition@CaseDefinition {caseId, build, test, edits}, CaseHooks {customEdits}) -> do
+    validateCase definition
+    unless
+      (all (`Map.member` commands) (build : maybe [] pure test))
+      (Left (ConfigError (caseIdText caseId <> ".commands") "unknown command reference"))
+    unless
+      (length customEdits == Set.size (Set.fromList (map fst customEdits)))
+      (Left (ConfigError (caseIdText caseId <> ".hooks") "duplicate custom edit hook"))
+    forM_ edits $ \case
+      CustomEdit key | key `notElem` map fst customEdits -> Left (ConfigError (caseIdText caseId <> ".edits") "missing custom edit hook")
+      _ -> Right ()
+  pure (Campaign commands definitions)
+
+-- | Portable definitions in author order.
+campaignCases :: Campaign -> [CaseDefinition]
+campaignCases (Campaign _ definitions) = map fst definitions
+
+-- | Named reusable commands.
+campaignCommands :: Campaign -> Map.Map CommandId Command
+campaignCommands (Campaign commands _) = commands
+
+-- | Observer notifications do not own verdicts.
+data RunEvent = CaseStarted !CaseId | StageFinished !CaseId !Text !StageEvidence | CaseFinished !CaseResult
+  deriving stock (Eq, Show)
+
+-- | Fresh is the reference mode. Opt-in sharing requires a nonsecret toolchain
+-- contract; structured suites also require their exact producer identity. The
+-- consumer asserts that external inputs and test behavior do not depend on
+-- per-attempt identity fields. Unknown external state disables sharing.
+data ReusePolicy = Fresh | ReuseBaselines !Text !(Maybe Text)
+  deriving stock (Eq, Show)
+
+data BaselineKey = BaselineKey !Text !Command !(Maybe Command) !(Maybe Text) !Expectation !EvidenceMode ![(String, String)] ![(Text, Resource.ResourceBinding)] !ReusePolicy
+  deriving stock (Eq)
+
+data BaselineEntry = BaselineEntry !CaseId !(Maybe Text)
+
+-- | Explicit source/output roots and optional selected ledger. No mandatory output.
+data RunOptions = RunOptions
+  { sourceRoot :: !FilePath,
+    outputDirectory :: !FilePath,
+    sourcePolicy :: !SnapshotPolicy,
+    selectedCases :: !(Maybe SelectedCases),
+    progress :: !(RunEvent -> IO ()),
+    workspaceCleanup :: !(Workspace -> IO ()),
+    workers :: !Int,
+    resources :: ![Resource.ResourceProvider],
+    resourceClaims :: !(Map.Map CaseId [Text]),
+    reusePolicy :: !ReusePolicy,
+    buildReuse :: !(Maybe Build.BuildReuse)
+  }
+
+-- | Fresh serial workspaces, whole working-tree inputs, all cases, silent observer.
+defaultRunOptions :: FilePath -> FilePath -> RunOptions
+defaultRunOptions source output = RunOptions source output defaultSnapshotPolicy Nothing (const (pure ())) (const (pure ())) 1 [] Map.empty Fresh Nothing
+
+-- | Invalid configurations are separate from executed mutation results.
+newtype ConfigurationFailure = ConfigurationFailure ConfigError
+  deriving stock (Show)
+  deriving anyclass (Exception)
+
+-- | Execute selected cases. Runtime failures return structured results. On
+-- asynchronous cancellation, finalize the partial report and rethrow the cause.
+runCampaign :: RunOptions -> Campaign -> IO RunResult
+runCampaign RunOptions {sourceRoot, outputDirectory, sourcePolicy, selectedCases, progress, workspaceCleanup, workers, resources, resourceClaims, reusePolicy, buildReuse} campaign@(Campaign commands definitions) = mask $ \restore -> do
+  either (throwIO . ConfigurationFailure) pure (Resource.validateResources resources)
+  unless (workers > 0 && workers <= 256) (throwIO (ConfigurationFailure (ConfigError "workers" "must be between 1 and 256")))
+  forM_ (Map.toList resourceClaims) $ \(cid, claims) -> do
+    unless
+      (cid `elem` [caseId | (CaseDefinition {caseId}, _) <- definitions] && all (`elem` map Resource.resourceName resources) claims && length claims == Set.size (Set.fromList claims))
+      (throwIO (ConfigurationFailure (ConfigError "resources.claims" "unknown case/resource or duplicate claim")))
+  forM_ buildReuse (either (throwIO . ConfigurationFailure) pure . Build.validateBuildReuse)
+  case reusePolicy of
+    ReuseBaselines contract adapter ->
+      unless
+        (not (Text.null (Text.strip contract)) && maybe True (not . Text.null . Text.strip) adapter)
+        (throwIO (ConfigurationFailure (ConfigError "reuse" "toolchain/known adapter contracts must be nonempty")))
+    Fresh -> pure ()
+  selected <- either (throwIO . ConfigurationFailure) pure $ maybe (mkSelectedCases [i | (CaseDefinition {caseId = i}, _) <- definitions]) Right selectedCases
+  let ids = selectedCaseIds selected
+  unless
+    (all (`elem` [i | (CaseDefinition {caseId = i}, _) <- definitions]) ids)
+    (throwIO (ConfigurationFailure (ConfigError "selection" "unknown case ID")))
+  validateOutputLocation sourceRoot sourcePolicy outputDirectory `catch` \(e :: WorkspaceError) ->
+    throwIO (ConfigurationFailure (ConfigError "output" (Text.pack (show e))))
+  existing <- Dir.doesPathExist outputDirectory
+  when existing (throwIO (ConfigurationFailure (ConfigError "output" "run directory already exists")))
+  -- Executable availability is validated for selected commands only.
+  let chosen = [(d, h) | i <- ids, (d@CaseDefinition {caseId = j}, h) <- definitions, i == j]
+      commandIds = Set.fromList [c | (CaseDefinition {build, test}, _) <- chosen, c <- build : maybe [] pure test]
+  let activeNames = concat [Map.findWithDefault [] cid resourceClaims | cid <- ids]
+      activeResources = filter ((`elem` activeNames) . Resource.resourceName) resources
+      effectiveWorkers = minimum (workers : [Resource.resourceCapacity p | p <- activeResources, Resource.resourceScope p == Resource.WorkerScope])
+  forM_ (Set.toList commandIds) $ \key -> validateExecutable (commands Map.! key) >>= either (throwIO . ConfigurationFailure) pure
+  forM_ (concatMap Resource.resourceCommands activeResources) $ \command -> validateExecutable command >>= either (throwIO . ConfigurationFailure) pure
+  resultsRef <- newIORef Map.empty
+  failuresRef <- newIORef []
+  contextRef <-
+    newIORef
+      ( case initialMetadata campaign chosen of
+          Object o ->
+            Object
+              ( KM.insert
+                  "execution"
+                  ( object
+                      [ "workers" .= effectiveWorkers,
+                        "requestedWorkers" .= workers,
+                        "baselineReuse" .= reusePolicyJSON reusePolicy,
+                        "buildReuseContract" .= fmap (\Build.BuildReuse {contractIdentity} -> contractIdentity) buildReuse,
+                        "resourceDefinitions" .= [object ["name" .= Resource.resourceName p, "scope" .= resourceScopeText (Resource.resourceScope p), "capacity" .= Resource.resourceCapacity p, "commands" .= map redactedCommand (Resource.resourceCommands p)] | p <- resources],
+                        "resourceClaims" .= object [Key.fromText (caseIdText cid) .= claims | (cid, claims) <- Map.toAscList resourceClaims]
+                      ]
+                  )
+                  o
+              )
+          other -> other
+      )
+  sequenceRef <- newIORef 0
+  runIdentity <- newIORef ""
+  journalLock <- newMVar ()
+  observerLock <- newMVar ()
+  workerBindings <- newMVar Map.empty
+  baselineCache <- Baseline.newBaselineCache
+  let summary = do
+        results <- Map.elems <$> readIORef resultsRef
+        failures <- readIORef failuresRef
+        either (throwIO . ConfigurationFailure) pure (summarizeRun selected results failures)
+      notifyProgress event = withMVar observerLock (\_ -> progress event)
+      stored = StoredReport <$> readIORef contextRef <*> summary
+      journal event = withMVar journalLock $ \_ -> do
+        value <- stored
+        sequence <- readIORef sequenceRef
+        appendJournal outputDirectory sequence event value
+        writeIORef sequenceRef (sequence + 1)
+      addCaseDetails caseId key value = atomicModify_ contextRef $ \context -> case context of
+        Object metadata -> Object (mapToKeyMap metadata caseId key value)
+        _ -> context
+      runOne snapshot leases retainedWorker (definition@CaseDefinition {caseId, build, test, expectation, evidenceMode}, hooks) = do
+        baselineRef <- newIORef BaselineUnavailable
+        mutationRef <- newIORef Unchanged
+        stagesRef <- newIORef []
+        errorsRef <- newIORef []
+        cleanupRef <- newIORef []
+        producerIdentity <- newIORef Nothing
+        environmentRef <- newIORef []
+        actualBaselineRef <- newIORef Nothing
+        let currentEvidence stopped = CaseEvidence <$> readIORef baselineRef <*> readIORef mutationRef <*> readIORef stagesRef <*> readIORef errorsRef <*> readIORef cleanupRef <*> pure stopped
+            cleanup workspace =
+              workspaceCleanup workspace `catch` \(e :: SomeException) -> do
+                modifyIORef' cleanupRef (++ [Text.pack (displayException e)])
+                throwIO e
+            updatePartial = do
+              observations <- currentEvidence (Just "case execution is incomplete")
+              atomicModify_ resultsRef (Map.insert caseId (classifyCase definition observations))
+            runStage role workspace stage command = do
+              identity <- readIORef runIdentity
+              attempt <-
+                if stage `elem` [Test, Replay] && (evidenceMode == StructuredTestsV1 || isCrash expectation)
+                  then Just <$> Adapter.newEvidenceAttempt outputDirectory identity caseId stage role
+                  else pure Nothing
+              additions <- readIORef environmentRef
+              let prefix = Text.unpack (caseIdText caseId <> "-" <> role <> "-" <> stageName stage)
+                  wanted = witnessesFor expectation stage
+                  effectiveCommand = withEnvironment (additions ++ maybe [] Adapter.attemptEnvironment attempt) (withSelector definition command)
+                  options = case defaultProcessOptions outputDirectory prefix of
+                    ProcessOptions d p g _ _ observer -> ProcessOptions d p g wanted reservedEnvironment observer
+                  record processReport@ProcessReport {evidence, errors, logs, preservedWitnesses} = do
+                    normalized <- normalizeAttempt producerIdentity definition attempt evidence
+                    enhanced <- if evidenceMode == StructuredTestsV1 then observeStructured hooks normalized else enhance hooks normalized
+                    when (role == "candidate") $ do
+                      modifyIORef' stagesRef (++ [enhanced])
+                    let invalidEvidence = either pure (const []) (validateStageEvidence enhanced)
+                    modifyIORef' errorsRef (++ (errors ++ invalidEvidence))
+                    addCaseDetails
+                      caseId
+                      (role <> "-" <> stageName stage)
+                      ( object
+                          [ "stage" .= stageJSON enhanced,
+                            "logs" .= map logJSON logs,
+                            "preservedWitnesses" .= preservedWitnesses,
+                            "errors" .= errors,
+                            "command" .= redactedCommand effectiveCommand,
+                            "attempt" .= maybe Null Adapter.attemptIdentityJSON attempt
+                          ]
+                      )
+                    updatePartial
+                    journal "stage"
+                    notifyProgress (StageFinished caseId role enhanced)
+                    pure (processReport, enhanced)
+              process <- try (runCommand options workspace stage effectiveCommand)
+              case process of
+                Left ProcessAborted {report, cause} -> record report >> throwIO cause
+                Right report -> record report
+        updatePartial
+        attempted <- try $ restore $ do
+          notifyProgress (CaseStarted caseId)
+          prepared <- mapM (\(name, lease) -> (name,) <$> Resource.prepareResource lease) leases
+          additions <- either (throwIO . ConfigurationFailure) pure (Resource.bindingEnvironment prepared)
+          writeIORef environmentRef additions
+          addCaseDetails caseId "baseline-resources" (toJSON [resourceBindingJSON name binding | (name, binding) <- prepared])
+          let executeBaseline =
+                withWorkspace snapshot $ \workspace ->
+                  ( do
+                      (buildReport, buildEvidence) <- runStage "baseline" workspace Build (commands Map.! build)
+                      if not (stagePassed buildReport buildEvidence)
+                        then pure False
+                        else case test of
+                          Nothing -> pure True
+                          Just testId | needsTest expectation -> do
+                            (testReport, testEvidence) <- runStage "baseline" workspace Test (commands Map.! testId)
+                            pure (stagePassed testReport testEvidence && baselineSelection definition testEvidence)
+                          _ -> pure True
+                  )
+                    `withFinalizer` cleanup workspace
+          inherited <- sort <$> getEnvironment
+          let CaseDefinition {selector} = definition
+              baselineKey = BaselineKey (snapshotIdentity snapshot) (commands Map.! build) (if needsTest expectation then (commands Map.!) <$> test else Nothing) selector expectation evidenceMode inherited prepared reusePolicy
+              hooksAllow = case hooks of CaseHooks {customEdits, customEvidence} -> null customEdits && case customEvidence of Nothing -> True; Just _ -> False
+              epochsKnown = all (\(_, Resource.ResourceBinding {epoch}) -> epoch /= Nothing) prepared
+              adapterKnown = case (evidenceMode, reusePolicy) of (StructuredTestsV1, ReuseBaselines _ (Just _)) -> True; (StructuredTestsV1, _) -> False; _ -> True
+              eligible = reusePolicy /= Fresh && hooksAllow && epochsKnown && adapterKnown
+              compute = do
+                passed <- executeBaseline
+                writeIORef actualBaselineRef (Just passed)
+                producer <- readIORef producerIdentity
+                let producerAgrees = case (evidenceMode, reusePolicy) of (StructuredTestsV1, ReuseBaselines _ expected) -> expected == producer; _ -> True
+                pure (if passed && producerAgrees then Just (BaselineEntry caseId producer) else Nothing)
+          (reused, entry) <- if eligible then Baseline.shareBaseline baselineCache baselineKey compute else (False,) <$> compute
+          actual <- readIORef actualBaselineRef
+          let baselinePassed = case entry of Just _ -> True; Nothing -> actual == Just True
+              reason
+                | reusePolicy == Fresh = "fresh reference mode"
+                | not hooksAllow = "custom hooks have no portable baseline identity"
+                | not epochsKnown = "resource epoch is unknown"
+                | not adapterKnown = "structured producer identity is undeclared"
+                | reused = "matching campaign-local baseline contract"
+                | otherwise = "executed fresh baseline; only passing matching producer facts are shareable"
+          forM_ entry $ \(BaselineEntry _ producer) -> writeIORef producerIdentity producer
+          addCaseDetails caseId "baseline-reuse" (object ["reused" .= reused, "ownerCase" .= fmap (\(BaselineEntry owner _) -> caseIdText owner) entry, "reason" .= String reason, "policy" .= reusePolicyJSON reusePolicy, "environmentComparedInMemory" .= True])
+          writeIORef baselineRef (if baselinePassed then BaselinePassed else BaselineFailed "unchanged build/test or required selection failed")
+          when baselinePassed $ do
+            let withCandidate staged action = case retainedWorker of
+                  Nothing -> action staged Nothing
+                  Just worker -> do
+                    inheritedNow <- sort <$> getEnvironment
+                    Build.withBuildCandidate worker (commands Map.! build, additions ++ inheritedNow) staged (\workspace inputId -> action workspace (Just inputId))
+            applied <- withEditedWorkspace (patchRunner caseId) hooks snapshot definition $ \staged editEvidence ->
+              withCandidate staged $ \workspace inputId ->
+                ( do
+                    candidateBindings <- mapM (\(name, lease) -> (name,) <$> Resource.prepareResource lease) leases
+                    unless (candidateBindings == prepared) (ioError (userError "resource identity/epoch/environment changed between baseline and candidate"))
+                    candidateEnvironment <- either (throwIO . ConfigurationFailure) pure (Resource.bindingEnvironment candidateBindings)
+                    let buildEnvironment = maybe [] (\key -> [("HENI_BUILD_INPUT_ID", Text.unpack key)]) inputId
+                    writeIORef environmentRef (buildEnvironment ++ candidateEnvironment)
+                    addCaseDetails caseId "build-reuse" (object ["enabled" .= (inputId /= Nothing), "inputIdentity" .= inputId, "contract" .= fmap (\Build.BuildReuse {contractIdentity} -> contractIdentity) buildReuse])
+                    addCaseDetails caseId "candidate-resources" (toJSON [resourceBindingJSON name binding | (name, binding) <- candidateBindings])
+                    writeIORef mutationRef (if expectation == Control then Unchanged else Changed)
+                    addCaseDetails caseId "changes" (changesJSON editEvidence)
+                    (buildReport, buildEvidence) <- runStage "candidate" workspace Build (commands Map.! build)
+                    when (case buildEvidence of StageEvidence {outcome = Exited _} -> True; _ -> False) $ forM_ ((,) <$> retainedWorker <*> inputId) (\(worker, key) -> Build.confirmBuild worker key)
+                    when (stagePassed buildReport buildEvidence && needsTest expectation) $ forM_ test $ \testId -> do
+                      (_, testEvidence) <- runStage "candidate" workspace Test (commands Map.! testId)
+                      case (expectation, testEvidence, hooks) of
+                        (NamedTests expected RequireReplay, StageEvidence {outcome = Exited 1, replay = Just token, selection = SelectionEvidence {failedIds}}, CaseHooks {replayBuilder})
+                          | expected == failedIds && validateStageEvidence testEvidence == Right () -> do
+                              built <- maybe (pure (Right (Replay.replayCommand token (commands Map.! testId)))) (\builder -> builder token (commands Map.! testId)) replayBuilder
+                              case built of
+                                Left why -> addCaseDetails caseId "replayRejected" (String why)
+                                Right replayCommand -> do
+                                  _ <- runStage "candidate" workspace Replay replayCommand
+                                  pure ()
+                        _ -> pure ()
+                )
+                  `withFinalizer` cleanup workspace
+            case applied of
+              Left (InvalidEdit why) -> writeIORef mutationRef (EditRejected why)
+              Left (EditInfrastructure why) -> modifyIORef' errorsRef (++ [why])
+              Right () -> pure ()
+        case attempted of
+          Left (e :: SomeException) -> do
+            modifyIORef' cleanupRef (++ finalizationErrors e)
+            let original = originalException e
+            case fromException original :: Maybe SomeAsyncException of
+              Just _ -> do
+                observations <- currentEvidence (Just "execution interrupted")
+                atomicModify_ resultsRef (Map.insert caseId (classifyCase definition observations))
+                throwIO original
+              Nothing -> modifyIORef' errorsRef (++ [Text.pack (displayException e)])
+          Right () -> pure ()
+        observations <- currentEvidence Nothing
+        let result@CaseResult {verdict} = classifyCase definition observations
+        when (verdict == InfrastructureError) $ do
+          Baseline.clearBaselineCache baselineCache
+          forM_ retainedWorker Build.retireBuildWorker
+        atomicModify_ resultsRef (Map.insert caseId result)
+      patchRunner caseId workspace args = do
+        -- Patch check/apply share the exact supervisor used by build/test.
+        let prefix = Text.unpack (caseIdText caseId <> if "--check" `elem` args then "-patch-check" else "-patch-apply")
+            budget = either (error . show) id (mkStageBudget 5000000)
+            command = Command "git" args Set.empty Nothing [] budget (OutputPolicy 65536)
+        ProcessReport {evidence = patchEvidence@StageEvidence {outcome}, errors, logs} <- runCommand (defaultProcessOptions outputDirectory prefix) workspace Setup command `catch` \ProcessAborted {cause} -> throwIO cause
+        addCaseDetails
+          caseId
+          (if "--check" `elem` args then "patch-check" else "patch-apply")
+          (object ["stage" .= stageJSON patchEvidence, "logs" .= map logJSON logs, "errors" .= errors])
+        journal "stage"
+        unless (null errors) (throwIO (EditInfrastructure (Text.intercalate "; " errors)))
+        case outcome of
+          Exited 0 -> pure (Right ())
+          Exited _ -> pure (Left "Git rejected textual patch; see patch logs")
+          _ -> throwIO (EditInfrastructure ("Git patch supervisor failed: " <> Text.pack (show outcome)))
+  attempted <- try $ restore $ do
+    Dir.createDirectoryIfMissing True (takeDirectory outputDirectory)
+    Dir.createDirectory outputDirectory
+    identity <- Adapter.newRunIdentity outputDirectory
+    writeIORef runIdentity identity
+    atomicModify_ contextRef (\case Object o -> Object (KM.insert "runId" (String identity) o); other -> other)
+    journal "selected"
+    withSnapshot sourceRoot sourcePolicy $ \snapshot -> do
+      atomicModify_ contextRef (addSnapshot snapshot)
+      journal "snapshot"
+      let observeResource event = do
+            atomicModify_ contextRef (appendResourceEvent event)
+            journal "resource"
+          context ident = Resource.ResourceContext ident snapshot outputDirectory observeResource
+          scope target = filter ((== target) . Resource.resourceScope) activeResources
+          withWorker number action = Resource.withResources (scope Resource.WorkerScope) (context ("worker-" <> Text.pack (show number))) $ \leases ->
+            let run retained =
+                  (modifyMVar_ workerBindings (pure . Map.insert number (leases, retained)) >> action)
+                    `withFinalizer` modifyMVar_ workerBindings (pure . Map.delete number)
+             in maybe (run Nothing) (\provider -> Build.withBuildWorker snapshot provider (run . Just)) buildReuse
+      Resource.withResources (scope Resource.CampaignScope) (context "campaign") $ \campaignLeases ->
+        Scheduler.runScheduled
+          effectiveWorkers
+          (Map.fromList [(Resource.resourceName p, Resource.resourceCapacity p) | p <- activeResources])
+          [(item, Map.findWithDefault [] cid resourceClaims) | item@(CaseDefinition {caseId = cid}, _) <- chosen]
+          withWorker
+          $ \number item@(definition@CaseDefinition {caseId}, _) -> do
+            (workerLeases, retainedWorker) <- Map.findWithDefault ([], Nothing) number <$> readMVar workerBindings
+            let claims = Map.findWithDefault [] caseId resourceClaims
+                persistent = filter ((`elem` claims) . fst) (campaignLeases ++ workerLeases)
+                localProviders = filter ((`elem` claims) . Resource.resourceName) (scope Resource.CaseScope)
+            attempt <- try (Resource.withResources localProviders (context ("case-" <> caseIdText caseId)) (\leases -> runOne snapshot (persistent ++ leases) retainedWorker item))
+            case attempt of
+              Right () -> pure ()
+              Left (e :: SomeException) -> case fromException (originalException e) :: Maybe SomeAsyncException of
+                Just _ -> throwIO (originalException e)
+                Nothing -> do
+                  Baseline.clearBaselineCache baselineCache
+                  forM_ retainedWorker Build.retireBuildWorker
+                  prior <- Map.lookup caseId <$> readIORef resultsRef
+                  let old = case prior of Just CaseResult {evidence} -> evidence; Nothing -> CaseEvidence BaselineUnavailable Unchanged [] [] [] Nothing
+                      CaseEvidence b m stages errors clean stop = old
+                      failed = classifyCase definition (CaseEvidence b m stages errors (clean ++ [Text.pack (displayException e)]) stop)
+                  atomicModify_ resultsRef (Map.insert caseId failed)
+                  journal "resource-failure"
+            completed <- Map.lookup caseId <$> readIORef resultsRef
+            journal "case"
+            forM_ completed (notifyProgress . CaseFinished)
+  case attempted of
+    Left (e :: SomeException) -> do
+      atomicModify_ failuresRef (++ map RunInfrastructure (finalizationErrors e))
+      case fromException (originalException e) :: Maybe SomeAsyncException of
+        Just _ -> atomicModify_ failuresRef (++ [Interrupted (Text.pack (displayException (originalException e)))])
+        Nothing -> atomicModify_ failuresRef (++ [RunInfrastructure (Text.pack (displayException e))])
+    Right () -> pure ()
+  finalWrite <- try (journal "finalized" >> stored >>= writeReport outputDirectory) :: IO (Either SomeException ())
+  case finalWrite of
+    Left e -> atomicModify_ failuresRef (++ [RunInfrastructure ("report finalization failed: " <> Text.pack (displayException e))])
+    Right () -> pure ()
+  final <- summary
+  case attempted of
+    Left e | Just (_ :: SomeAsyncException) <- fromException (originalException e) -> throwIO (originalException e)
+    _ -> case finalWrite of
+      Left e | Just (_ :: SomeAsyncException) <- fromException e -> throwIO e
+      _ -> pure final
+
+stageName :: Stage -> Text
+stageName s = case enumValue stageTable s of String t -> t; _ -> error "stage table"
+
+needsTest :: Expectation -> Bool
+needsTest BuildRejection {} = False
+needsTest (Diagnostic Build _ _) = False
+needsTest _ = True
+
+witnessesFor :: Expectation -> Stage -> [Text]
+witnessesFor (Diagnostic s _ witness) stage | s == stage = [witness]
+witnessesFor (BuildRejection _ witness) Build = [witness]
+witnessesFor _ _ = []
+
+stagePassed :: ProcessReport -> StageEvidence -> Bool
+stagePassed ProcessReport {errors} evidence@StageEvidence {outcome} = outcome == Exited 0 && null errors && validateStageEvidence evidence == Right ()
+
+baselineSelection :: CaseDefinition -> StageEvidence -> Bool
+baselineSelection CaseDefinition {expectation = NamedTests expected _} StageEvidence {selection = SelectionEvidence {selected, executed, executedIds, failedIds, skippedIds}} =
+  maybe False (> 0) selected && maybe False (> 0) executed && expected `Set.isSubsetOf` executedIds && Set.null failedIds && Set.null (expected `Set.intersection` skippedIds)
+baselineSelection CaseDefinition {evidenceMode = StructuredTestsV1} StageEvidence {selection = SelectionEvidence {selected, executed, failedIds}} = maybe False (> 0) selected && maybe False (> 0) executed && Set.null failedIds
+baselineSelection _ _ = True
+
+enhance :: CaseHooks -> StageEvidence -> IO StageEvidence
+enhance CaseHooks {customEvidence = Nothing} evidence = pure evidence
+enhance _ evidence@StageEvidence {outcome} | outcome `elem` [Timeout, Cancelled] = pure evidence
+enhance CaseHooks {customEvidence = Just hook} evidence@(StageEvidence s o text unavailable _ _ duration logs) = do
+  supplied <- hook evidence
+  case supplied of
+    Left why -> pure (StageEvidence s o text True (SelectionEvidence Nothing Nothing Nothing Set.empty Set.empty Set.empty [why]) Nothing duration logs)
+    Right StageEvidence {selection, replay} -> pure (StageEvidence s o text unavailable selection replay duration logs)
+
+isCrash :: Expectation -> Bool
+isCrash CrashProbe {} = True
+isCrash _ = False
+
+reservedEnvironment :: [String]
+reservedEnvironment = ["HENI_EVIDENCE_FILE", "HENI_RUN_ID", "HENI_CASE_ID", "HENI_STAGE", "HENI_ROLE", "HENI_ATTEMPT", "HENI_SELECTOR", "HENI_REPLAY_TOKEN", "HENI_REPLAY_GENERATOR", "HENI_BUILD_INPUT_ID"]
+
+withEnvironment :: [(String, String)] -> Command -> Command
+withEnvironment additions (Command exe args sensitive working env budget output) =
+  Command exe args sensitive working (additions ++ filter (\(key, _) -> key `notElem` map fst additions) env) budget output
+
+-- Protocol evidence is authoritative. A custom observer can reject it, but cannot
+-- replace a framework's actual execution or replay membership.
+observeStructured :: CaseHooks -> StageEvidence -> IO StageEvidence
+observeStructured CaseHooks {customEvidence = Nothing} evidence = pure evidence
+observeStructured _ evidence@StageEvidence {outcome} | outcome `elem` [Timeout, Cancelled] = pure evidence
+observeStructured CaseHooks {customEvidence = Just hook} evidence@(StageEvidence s o text _ selection replay duration logs) =
+  hook evidence >>= \case
+    Right _ -> pure evidence
+    Left why -> pure (StageEvidence s o text True (addSelectionError why selection) replay duration logs)
+
+addSelectionError :: Text -> SelectionEvidence -> SelectionEvidence
+addSelectionError why (SelectionEvidence d s e ids failed skipped errors) = SelectionEvidence d s e ids failed skipped (errors ++ [why])
+
+normalizeAttempt :: IORef (Maybe Text) -> CaseDefinition -> Maybe Adapter.EvidenceAttempt -> StageEvidence -> IO StageEvidence
+normalizeAttempt _ _ Nothing evidence = pure evidence
+normalizeAttempt producerIdentity CaseDefinition {expectation, evidenceMode} (Just attempt@Adapter.EvidenceAttempt {outputRoot, role}) evidence@(StageEvidence s o text unavailable selection replay duration logs) = do
+  let addArtifact relative = do
+        exists <- Dir.doesFileExist (outputRoot ++ "/" ++ relative)
+        pure [relative | exists]
+  protocolArtifacts <- if evidenceMode == StructuredTestsV1 then addArtifact (Adapter.evidenceRelativePath attempt) else pure []
+  probeArtifacts <- if isCrash expectation then addArtifact (Crash.probeRelativePath attempt) else pure []
+  let artifacts = logs ++ protocolArtifacts ++ probeArtifacts
+  if o `elem` [Timeout, Cancelled]
+    then pure (StageEvidence s o text unavailable selection replay duration artifacts)
+    else case expectation of
+      CrashProbe _ probe -> do
+        valid <- Crash.readCrashProbe attempt probe
+        pure (StageEvidence s o (either (const "") (const probe) valid) unavailable selection replay duration artifacts)
+      _
+        | evidenceMode == StructuredTestsV1 ->
+            Adapter.readEvidence attempt >>= \case
+              Left why -> pure (StageEvidence s o text True (addSelectionError why unknownSelection) Nothing duration artifacts)
+              Right (selected, replayed, producer) -> do
+                expected <- readIORef producerIdentity
+                if role == "baseline" then writeIORef producerIdentity (Just producer) else pure ()
+                let normalized = if role /= "baseline" && expected /= Just producer then addSelectionError "reporter version changed after baseline" selected else selected
+                pure (StageEvidence s o text unavailable normalized replayed duration artifacts)
+      _ -> pure evidence
+
+initialMetadata :: Campaign -> [(CaseDefinition, CaseHooks)] -> Value
+initialMetadata (Campaign commands _) selected =
+  object
+    [ "platform" .= object ["os" .= Platform.os, "arch" .= Platform.arch, "compiler" .= Platform.compilerName, "compilerVersion" .= showVersion Platform.compilerVersion],
+      "sourcePolicy" .= String "selected working-tree bytes including dirty/untracked regular files",
+      "commands" .= object [Key.fromText (commandIdText key) .= redactedCommand command | (key, command) <- Map.toAscList commands],
+      "definitions" .= map (caseJSON . fst) selected,
+      "caseDetails" .= object [],
+      "resources" .= ([] :: [Value])
+    ]
+
+redactedCommand :: Command -> Value
+redactedCommand Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output} =
+  object
+    [ "executable" .= executable,
+      "arguments" .= [if Set.member i sensitiveArguments then "<redacted>" else arg | (i, arg) <- zip [0 ..] arguments],
+      "sensitiveArguments" .= Set.toAscList sensitiveArguments,
+      "workingDirectory" .= fmap targetPath workingDirectory,
+      "environmentOverrides" .= object [Key.fromString key .= String "<redacted>" | (key, _) <- environment],
+      "environmentRedacted" .= True,
+      "timeoutMicroseconds" .= budgetMicroseconds budget,
+      "outputLimitBytes" .= case output of OutputPolicy {limitBytes} -> limitBytes
+    ]
+
+addSnapshot :: SourceSnapshot -> Value -> Value
+addSnapshot snapshot (Object o) = Object (KM.insert "snapshot" (object ["identity" .= snapshotIdentity snapshot, "policy" .= policyJSON (snapshotPolicy snapshot)]) o)
+addSnapshot _ other = other
+
+policyJSON :: SnapshotPolicy -> Value
+policyJSON SnapshotPolicy {includes, excludes} = object ["includes" .= map targetPath includes, "excludes" .= map targetPath excludes, "defaultExcludedNames" .= defaultExcludedNames, "defaultExcludedPrefixes" .= defaultExcludedPrefixes]
+
+mapToKeyMap :: Object -> CaseId -> Text -> Value -> Object
+mapToKeyMap metadata caseId key value = KM.insert "caseDetails" (Object updated) metadata
+  where
+    existing = case KM.lookup "caseDetails" metadata of Just (Object o) -> o; _ -> KM.empty
+    caseKey = Key.fromText (caseIdText caseId)
+    details = case KM.lookup caseKey existing of Just (Object o) -> o; _ -> KM.empty
+    updated = KM.insert caseKey (Object (KM.insert (Key.fromText key) value details)) existing
+
+logJSON :: LogArtifact -> Value
+logJSON LogArtifact {stream, path, bytesSeen, bytesRetained, truncated} =
+  object
+    ["stream" .= (if stream == Stdout then String "stdout" else String "stderr"), "path" .= path, "bytesSeen" .= bytesSeen, "bytesRetained" .= bytesRetained, "truncated" .= truncated]
+
+changesJSON :: EditEvidence -> Value
+changesJSON (EditEvidence changes) = toJSON [object ["path" .= targetPath path, "before" .= nullable fileJSON before, "after" .= nullable fileJSON after] | FileChange {path, before, after} <- changes]
+  where
+    fileJSON FileEntry {bytes, executableBits} = object ["sha256" .= digest bytes, "executableBits" .= executableBits, "text" .= either (const Nothing) Just (Text.decodeUtf8' bytes), "byteLength" .= BS.length bytes]
+
+withSelector :: CaseDefinition -> Command -> Command
+withSelector CaseDefinition {selector} (Command executable arguments sensitive working environment budget output) =
+  Command executable arguments sensitive working (maybe environment (\s -> ("HENI_SELECTOR", Text.unpack s) : filter ((/= "HENI_SELECTOR") . fst) environment) selector) budget output
+
+originalException :: SomeException -> SomeException
+originalException e = case fromException e of
+  Just (WorkspaceFinalizationError (Just primary) _) -> originalException primary
+  Just (WorkspaceFinalizationError Nothing secondary) -> originalException secondary
+  _ -> case fromException e of
+    Just (Resource.ResourceError _ _ cause) -> originalException cause
+    _ -> e
+
+finalizationErrors :: SomeException -> [Text]
+finalizationErrors e = case fromException e of
+  Just (WorkspaceFinalizationError primary secondary) -> Text.pack (displayException secondary) : maybe [] finalizationErrors primary
+  _ -> case fromException e of
+    Just (Resource.ResourceError _ _ cause) -> finalizationErrors cause
+    _ -> []
+
+-- Atomic shared state; case-local evidence is private to one scheduled owner.
+atomicModify_ :: IORef a -> (a -> a) -> IO ()
+atomicModify_ ref update = atomicModifyIORef' ref (\value -> (update value, ()))
+
+resourceBindingJSON :: Text -> Resource.ResourceBinding -> Value
+resourceBindingJSON name Resource.ResourceBinding {identity, version, epoch, environment} =
+  object
+    ["name" .= name, "identity" .= identity, "version" .= version, "epoch" .= epoch, "environmentKeys" .= map fst environment, "environmentRedacted" .= True]
+
+appendResourceEvent :: Resource.ResourceEvent -> Value -> Value
+appendResourceEvent event (Object o) =
+  let old = case KM.lookup "resources" o of Just (Array a) -> toList a; _ -> []
+      value = case event of
+        Resource.ResourceEvent name instanceId phase binding -> object ["instance" .= instanceId, "phase" .= resourcePhaseText phase, "binding" .= resourceBindingJSON name binding]
+        Resource.ResourceCommandFinished name instanceId phase ProcessReport {evidence, logs, errors} -> object ["resource" .= name, "instance" .= instanceId, "phase" .= resourcePhaseText phase, "stage" .= stageJSON evidence, "logs" .= map logJSON logs, "errors" .= errors]
+   in Object (KM.insert "resources" (toJSON (old ++ [value])) o)
+appendResourceEvent _ other = other
+
+reusePolicyJSON :: ReusePolicy -> Value
+reusePolicyJSON Fresh = object ["mode" .= String "fresh"]
+reusePolicyJSON (ReuseBaselines toolchain adapter) = object ["mode" .= String "baselines", "toolchain" .= toolchain, "adapter" .= adapter]
+
+resourceScopeText :: Resource.ResourceScope -> Text
+resourceScopeText = \case Resource.CampaignScope -> "campaign"; Resource.WorkerScope -> "worker"; Resource.CaseScope -> "case"
+
+resourcePhaseText :: Resource.ResourcePhase -> Text
+resourcePhaseText = \case Resource.AcquireResource -> "acquire"; Resource.ResetResource -> "reset"; Resource.ReleaseResource -> "release"
diff --git a/src/Test/Heni/Scheduler.hs b/src/Test/Heni/Scheduler.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Scheduler.hs
@@ -0,0 +1,64 @@
+-- | Bounded workers with atomic multi-resource claims and owned cancellation.
+module Test.Heni.Scheduler (runScheduled) where
+
+import Control.Concurrent
+import Control.Concurrent.STM
+import Control.Exception
+import Control.Monad (forM, forM_, replicateM_, unless)
+import Data.List (findIndex)
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Test.Heni.Internal.Files (WorkspaceFinalizationError (..), withFinalizer)
+
+-- | Run a finite author-ordered queue. Claims are acquired atomically; a blocked
+-- item can be passed by independent work, and becomes eligible when its owners
+-- release capacity. Failures cancel/join every worker before returning. This
+-- coordinates one process only; external processes require external locks.
+runScheduled :: Int -> Map.Map Text Int -> [(a, [Text])] -> (Int -> IO () -> IO ()) -> (Int -> a -> IO ()) -> IO ()
+runScheduled count capacities work withWorker action = do
+  unless (count > 0 && count <= 256 && all (> 0) (Map.elems capacities)) (ioError (userError "invalid worker count or resource capacity"))
+  forM_ work $ \(_, claims) -> unless (all (`Map.member` capacities) claims && Set.size (Set.fromList claims) == length claims) (ioError (userError "unknown or duplicate resource claim"))
+  mask $ \restore -> do
+    queue <- newTVarIO work
+    active <- newTVarIO Map.empty
+    completed <- newChan
+    let takeWork = do
+          remaining <- readTVar queue
+          case remaining of
+            [] -> pure Nothing
+            _ -> do
+              usage <- readTVar active
+              let available (_, claims) = all (\name -> Map.findWithDefault 0 name usage < capacities Map.! name) claims
+              case findIndex available remaining of
+                Nothing -> retry
+                Just index -> case splitAt index remaining of
+                  (before, (item, claims) : after) -> do
+                    writeTVar queue (before ++ after)
+                    writeTVar active (foldr (\name -> Map.insertWith (+) name 1) usage claims)
+                    pure (Just (item, claims))
+                  _ -> retry
+        release claims = atomically (modifyTVar' active (\usage -> foldr (Map.adjust (subtract 1)) usage claims))
+        worker number = mask $ \restoreWorker ->
+          atomically takeWork >>= \case
+            Nothing -> pure ()
+            Just (item, claims) -> (restoreWorker (action number item) `withFinalizer` release claims) >> restoreWorker (worker number)
+    handles <- forM [1 .. min count (max 1 (length work))] $ \number -> do
+      done <- newEmptyMVar
+      tid <- forkIOWithUnmask $ \unmask -> do
+        result <- try (unmask (withWorker number (worker number)))
+        putMVar done (result :: Either SomeException ())
+        writeChan completed result
+      pure (tid, done)
+    let join = mapM_ (readMVar . snd) handles
+        cancel = do
+          forM_ handles (\(tid, _) -> throwTo tid ThreadKilled)
+          states <- mapM (readMVar . snd) handles
+          pure [e | Left e <- states, Nothing <- [fromException e :: Maybe SomeAsyncException]]
+        await = replicateM_ (length handles) $ readChan completed >>= either throwIO pure
+    outcome <- try (restore await)
+    case outcome of
+      Left (e :: SomeException) -> do
+        secondary <- cancel
+        throwIO (foldl (\primary extra -> toException (WorkspaceFinalizationError (Just primary) extra)) e secondary)
+      Right () -> join
diff --git a/src/Test/Heni/Workspace.hs b/src/Test/Heni/Workspace.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Heni/Workspace.hs
@@ -0,0 +1,142 @@
+-- | Immutable source bytes and exclusive disposable working copies.
+module Test.Heni.Workspace
+  ( SourceSnapshot,
+    SnapshotPolicy (..),
+    defaultSnapshotPolicy,
+    defaultExcludedNames,
+    defaultExcludedPrefixes,
+    Workspace,
+    WorkspaceError (..),
+    WorkspaceFinalizationError (..),
+    FileEntry (..),
+    withSnapshot,
+    withWorkspace,
+    workspaceRoot,
+    snapshotIdentity,
+    snapshotPolicy,
+    snapshotFiles,
+    verifyWorkspace,
+    resetWorkspace,
+    checkedWorkspacePath,
+    validateOutputLocation,
+  )
+where
+
+import Control.Exception (throwIO)
+import Control.Monad (forM_, unless, when)
+import Data.List (isPrefixOf)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as Text
+import System.Directory qualified as Dir
+import System.FilePath (makeRelative, splitDirectories, (</>))
+import Test.Heni.Case (RelativeTarget, targetPath)
+import Test.Heni.Internal.Files
+
+-- | Includes selected working-tree bytes (dirty and untracked), never implicit HEAD.
+data SnapshotPolicy = SnapshotPolicy
+  { includes :: ![RelativeTarget],
+    excludes :: ![RelativeTarget]
+  }
+  deriving stock (Eq, Show)
+
+-- | Empty includes means the root. Default excluded names apply at every depth.
+defaultSnapshotPolicy :: SnapshotPolicy
+defaultSnapshotPolicy = SnapshotPolicy [] []
+
+-- | Stored in memory: callers cannot mutate snapshot bytes through a filesystem path.
+data SourceSnapshot = SourceSnapshot !Text !SnapshotPolicy !Inventory
+
+-- | Constructor hidden: only Heni allocates and owns a cleanup target.
+newtype Workspace = Workspace FilePath
+
+-- | SHA-256 over framed sorted names, bytes, executable bits and policy.
+snapshotIdentity :: SourceSnapshot -> Text
+snapshotIdentity (SourceSnapshot identity _ _) = identity
+
+-- | Effective inclusion and exclusion choices.
+snapshotPolicy :: SourceSnapshot -> SnapshotPolicy
+snapshotPolicy (SourceSnapshot _ policy _) = policy
+
+-- | Immutable selected bytes, including binary fixtures.
+snapshotFiles :: SourceSnapshot -> Map.Map RelativeTarget FileEntry
+snapshotFiles (SourceSnapshot _ _ files) = files
+
+-- | Exclusive directory for consumer commands. This is not an OS sandbox.
+workspaceRoot :: Workspace -> FilePath
+workspaceRoot (Workspace root) = root
+
+-- | Effective default directory/file names, recorded in snapshot identities.
+defaultExcludedNames :: [FilePath]
+defaultExcludedNames = [".git", "dist", "dist-newstyle", "cabal-dev", ".direnv", "result", "mutation-results", ".heni", ".DS_Store"]
+
+-- | Name prefixes excluded at every depth.
+defaultExcludedPrefixes :: [FilePath]
+defaultExcludedPrefixes = ["result-", ".heni-"]
+
+excludedBy :: SnapshotPolicy -> FilePath -> Bool
+excludedBy (SnapshotPolicy ins outs) p = defaultExcluded || explicitExcluded || outsideSelection
+  where
+    parts = splitDirectories p
+    defaultExcluded = any (\part -> part `elem` defaultExcludedNames || any (`isPrefixOf` part) defaultExcludedPrefixes) parts
+    prefix a b = a == b || (a ++ "/") `isPrefixOf` b
+    explicitExcluded = any (\t -> prefix (targetPath t) p) outs
+    outsideSelection = not (null ins) && not (any (\t -> prefix p (targetPath t) || prefix (targetPath t) p) ins)
+
+-- | Capture twice and require identical inventory; this detects ordinary concurrent
+-- changes, but is not an atomic OS snapshot under adversarial writers.
+withSnapshot :: FilePath -> SnapshotPolicy -> (SourceSnapshot -> IO a) -> IO a
+withSnapshot root policy action = do
+  canonical <- Dir.canonicalizePath root
+  when (canonical == "/" || canonical == "/nix/store" || "/nix/store/" `isPrefixOf` canonical) (throwIO (UnsafeSource "source root is not a project directory"))
+  symlink <- Dir.pathIsSymbolicLink root
+  when symlink (throwIO (UnsafeSource "source root is a symlink"))
+  let SnapshotPolicy {includes} = policy
+  forM_ includes $ \target -> do
+    path <- checkedPath canonical target
+    exists <- Dir.doesPathExist path
+    unless exists (throwIO (UnsafeSource ("included input does not exist: " <> Text.pack (targetPath target))))
+  before <- inventory (excludedBy policy) canonical
+  after <- inventory (excludedBy policy) canonical
+  unless (before == after) (throwIO SourceChanged)
+  action (SourceSnapshot (inventoryDigest (Text.pack (show policy ++ show defaultExcludedNames ++ show defaultExcludedPrefixes)) before) policy before)
+
+-- | Bracket allocation and removal. Cleanup exceptions remain observable.
+withWorkspace :: SourceSnapshot -> (Workspace -> IO a) -> IO a
+withWorkspace (SourceSnapshot _ _ files) action = withOwnedDirectory ".heni-workspace-" $ \root -> do
+  materialize root files
+  action (Workspace root)
+
+-- | Verify exact source inventory. Build outputs are deliberately excluded.
+verifyWorkspace :: SourceSnapshot -> Workspace -> IO ()
+verifyWorkspace (SourceSnapshot _ policy files) (Workspace root) = do
+  actual <- inventory (excludedBy policy) root
+  unless (actual == files) (throwIO (WorkspaceMismatch "workspace differs from pristine snapshot"))
+
+-- | Reset only an opaque owned workspace, then independently verify it.
+-- A thrown exception invalidates reuse; the caller must allocate a fresh copy.
+resetWorkspace :: SourceSnapshot -> Workspace -> IO ()
+resetWorkspace snap@(SourceSnapshot _ _ files) (Workspace root) = do
+  -- Validate the root before listing or deleting anything.
+  _ <- inventory (const True) root
+  names <- Dir.listDirectory root
+  mapM_ (Dir.removePathForcibly . (root </>)) names
+  materialize root files
+  verifyWorkspace snap (Workspace root)
+
+-- | Validate every existing component before an operation; rejects symlink escapes.
+checkedWorkspacePath :: Workspace -> RelativeTarget -> IO FilePath
+checkedWorkspacePath (Workspace root) = checkedPath root
+
+-- | Output under the source root is permitted only when the effective policy
+-- excludes it. Ancestors and source-equal locations are always rejected.
+validateOutputLocation :: FilePath -> SnapshotPolicy -> FilePath -> IO ()
+validateOutputLocation source policy output = do
+  s <- Dir.canonicalizePath source
+  o <- Dir.canonicalizePath output
+  let relative = makeRelative s o
+      outputAncestor = o == s || (o ++ "/") `isPrefixOf` s
+      outputInside = not (null relative) && not (".." `elem` splitDirectories relative) && not ("/" `isPrefixOf` relative)
+  when
+    (outputAncestor || (outputInside && not (excludedBy policy relative)))
+    (throwIO (UnsafeSource "output must be outside the source or explicitly excluded"))
diff --git a/test/adapter/Main.hs b/test/adapter/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/adapter/Main.hs
@@ -0,0 +1,91 @@
+module Main (main) where
+
+import Control.Exception
+import Control.Monad (forM_, unless)
+import Data.Aeson
+import Data.Aeson.KeyMap qualified as KM
+import Data.ByteString qualified as BS
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import System.Directory qualified as Dir
+import System.Environment (lookupEnv)
+import System.FilePath (takeDirectory, (</>))
+import System.Posix.Signals (sigABRT)
+import System.Posix.Temp (mkdtemp)
+import System.Timeout (timeout)
+import Test.Heni
+import Test.Heni.Adapter
+import Test.Heni.Replay qualified as Replay
+
+assert' :: String -> Bool -> IO ()
+assert' name good = unless good (error name)
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+main :: IO ()
+main =
+  timeout 120000000 checks >>= \case
+    Nothing -> error "adapter integration watchdog"
+    Just () -> pure ()
+
+checks :: IO ()
+checks = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-adapter-test-")) Dir.removePathForcibly $ \root -> do
+  let source = root </> "source"
+      budget = checked (mkStageBudget 2000000)
+      buildId = checked (mkCommandId "build")
+      testId = checked (mkCommandId "test")
+      caseId = checked (mkCaseId "named")
+      command args = Command "heni-evidence-fixture" args Set.empty Nothing [] budget (OutputPolicy 1024)
+      definition expectation mode = CaseDefinition caseId "Detect the intended assertion" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" "mutant" 1] buildId (Just testId) Nothing expectation mode
+      named policy = definition (NamedTests (Set.singleton "suite/intended") policy) StructuredTestsV1
+      run name args chosenDefinition hooks =
+        runCampaign
+          (defaultRunOptions source (root </> name))
+          (checked (mkCampaign (Map.fromList [(buildId, command ["exit", "0"]), (testId, command args)]) [(chosenDefinition, hooks)]))
+      single verdict RunResult {results} = case results of [CaseResult {verdict = v}] -> v == verdict; _ -> False
+  Dir.createDirectory source
+  BS.writeFile (source </> "input") "anchor\n"
+  forM_ [("intended", Caught), ("wrong", WrongFailure), ("skipped", Uncovered), ("setup", InfrastructureError), ("stale", InfrastructureError), ("partial", InfrastructureError), ("counts", InfrastructureError), ("version", InfrastructureError)] $ \(mode, expected) -> do
+    result <- run mode ["protocol", "input", mode] (named NoReplay) noHooks
+    unless (single expected result) $ do
+      stored <- readReport (root </> mode </> "report.json")
+      print (case stored of Right StoredReport {metadata = Object o} -> KM.lookup "caseDetails" o; _ -> Nothing)
+    assert' ("protocol fixture " ++ mode ++ ": " ++ show result) (single expected result)
+  replay <- run "replay" ["protocol", "input", "replay"] (named RequireReplay) noHooks
+  assert' "required replay actual execution" (single Caught replay && case replay of RunResult {results = [CaseResult {evidence = CaseEvidence {stages}}]} -> length stages == 3; _ -> False)
+  let badBuilder = CaseHooks [] Nothing (Just (\token originalCommand -> pure (Right (changeToken (Replay.replayCommand token originalCommand)))))
+      changeToken (Command exe args sensitive working env changedBudget output) = Command exe args sensitive working (("HENI_REPLAY_TOKEN", "different-token") : filter ((/= "HENI_REPLAY_TOKEN") . fst) env) changedBudget output
+  mismatch <- run "replay-mismatch" ["protocol", "input", "replay"] (named RequireReplay) badBuilder
+  assert' "opaque token mismatch cannot be caught" (single WrongFailure mismatch)
+  replayTimeout <- run "replay-timeout" ["protocol", "input", "replay-timeout"] (named RequireReplay) noHooks
+  assert' ("replay timeout is a rejected reproduction with retained stage timeout: " ++ show replayTimeout) (single WrongFailure replayTimeout && case replayTimeout of RunResult {results = [CaseResult {evidence = CaseEvidence {stages}}]} -> any (\StageEvidence {stage, outcome} -> stage == Replay && outcome == Timeout) stages; _ -> False)
+  let empty = case named NoReplay of CaseDefinition i d ts es b t _ e mode -> CaseDefinition i d ts es b t (Just "absent") e mode
+  uncovered <- run "empty" ["protocol", "input", "intended"] empty noHooks
+  assert' "known empty baseline cannot run candidate" (single NotRun uncovered)
+  forM_ [("good", Caught), ("missing", WrongFailure), ("stale", WrongFailure), ("wrong-signal", WrongFailure), ("timeout", TimedOut), ("exit", WrongFailure)] $ \(mode, expected) -> do
+    result <- run ("crash-" ++ mode) ["crash", "input", mode] (definition (CrashProbe (fromIntegral sigABRT) "operation-reached") GenericCommands) noHooks
+    assert' ("fresh crash probe " ++ mode ++ ": " ++ show result) (single expected result)
+  assert' "stable IDs escape separators unambiguously" (stableTestId ["suite/a", "x~y"] == "suite~1a/x~0y")
+  -- Direct codec conformance uses an actual writer, not invented stdout.
+  Dir.createDirectory (root </> "protocol")
+  identity <- newRunIdentity (root </> "protocol")
+  attempt <- newEvidenceAttempt (root </> "protocol") identity caseId Test "candidate"
+  emitSuite attempt (SuiteEvidence "fixture/v1" (Just ["suite/intended"]) ["suite/intended"] [TestPassed "suite/intended"] [] Nothing [])
+  parsed <- readEvidence attempt
+  assert' "protocol round trip" (case parsed of Right (SelectionEvidence {executed = Just 1}, Nothing, "fixture/v1") -> True; _ -> False)
+  schemaOutput <- lookupEnv "HENI_SCHEMA_EVIDENCE"
+  forM_ schemaOutput $ \path -> Dir.createDirectoryIfMissing True (takeDirectory path) >> Dir.copyFile (evidencePath attempt) path
+  duplicated <- try (emitSuite attempt (SuiteEvidence "fixture/v1" Nothing [] [] [] Nothing [])) :: IO (Either EvidenceError ())
+  assert' "writer refuses stale destination" (case duplicated of Left _ -> True; _ -> False)
+  invalidated <- newEvidenceAttempt (root </> "protocol") identity caseId Test "candidate"
+  _ <-
+    try
+      ( guardEvidenceAttempt invalidated $ do
+          emitSuite invalidated (SuiteEvidence "fixture/v1" Nothing ["suite/intended"] [TestFailed "suite/intended"] [] Nothing [])
+          ioError (userError "cleanup after completion")
+      ) ::
+      IO (Either IOException ())
+  bad <- readEvidence invalidated
+  assert' "post-completion reporter exception invalidates proof" (case bad of Left _ -> True; _ -> False)
+  putStrLn "Attempt-bound protocol, named attribution, replay and native probe checks passed"
diff --git a/test/api/Consumer.hs b/test/api/Consumer.hs
new file mode 100644
--- /dev/null
+++ b/test/api/Consumer.hs
@@ -0,0 +1,63 @@
+-- A separate consumer: imports only the exposed umbrella, never internal modules.
+module Consumer (definitions, namedCase, diagnosticCase, buildCase, replayCase, controlCase, crashCase) where
+
+import Data.Set qualified as Set
+import Test.Heni
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+baseCase :: CaseDefinition
+baseCase =
+  CaseDefinition
+    (checked (mkCaseId "guard-bypass"))
+    "Removing the capacity guard must be detected."
+    (Set.singleton "regression")
+    [Replace (checked (mkRelativeTarget "src/Capacity.hs")) "used < limit" "True" 1]
+    (checked (mkCommandId "build"))
+    (Just (checked (mkCommandId "test")))
+    (Just "rejects full capacity")
+    (NamedTests (Set.singleton "rejects full capacity") NoReplay)
+    GenericCommands
+
+namedCase, diagnosticCase, buildCase, replayCase, controlCase, crashCase :: CaseDefinition
+namedCase = baseCase
+diagnosticCase = changeExpectation (Diagnostic Test 1 "capacity rule violated") baseCase
+buildCase = changeExpectation (BuildRejection 1 "unbound variable") baseCase
+replayCase = changeExpectation (NamedTests (Set.singleton "rejects full capacity") RequireReplay) baseCase
+controlCase = case baseCase of
+  CaseDefinition i d ts _ b t s _ mode -> CaseDefinition i d ts [] b t s Control mode
+crashCase = changeExpectation (CrashProbe 6 "capacity-probe: native operation reached") baseCase
+
+changeExpectation :: Expectation -> CaseDefinition -> CaseDefinition
+changeExpectation e (CaseDefinition i d ts es b t s _ mode) = CaseDefinition i d ts es b t s e mode
+
+-- Four independently authored construction trials, corresponding to RES-1 patterns.
+-- These are synthetic classifier inputs, not executed migrations.
+definitions :: [CaseDefinition]
+definitions = [namedCase, patchCatalogue, generatedCode, diagnosticCase]
+  where
+    patchCatalogue = case baseCase of
+      CaseDefinition _ d ts _ b t s e mode ->
+        CaseDefinition
+          (checked (mkCaseId "patch-guard"))
+          d
+          ts
+          [Patch "diff --git a/src/Capacity.hs b/src/Capacity.hs\n--- a/src/Capacity.hs\n+++ b/src/Capacity.hs\n@@ -1 +1 @@\n-used < limit\n+True\n"]
+          b
+          t
+          s
+          e
+          mode
+    generatedCode = case baseCase of
+      CaseDefinition _ d ts _ b t s e mode ->
+        CaseDefinition
+          (checked (mkCaseId "generated-guard"))
+          d
+          ts
+          [CustomEdit "regenerate-without-guard"]
+          b
+          t
+          s
+          e
+          mode
diff --git a/test/api/Labels.hs b/test/api/Labels.hs
new file mode 100644
--- /dev/null
+++ b/test/api/Labels.hs
@@ -0,0 +1,15 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+
+-- Compile regression for consumer-defined overloaded labels.
+module Labels (labelWorks) where
+
+import GHC.OverloadedLabels (IsLabel (..))
+import Test.Heni ()
+
+data ConsumerLabel = ConsumerLabel deriving stock (Eq)
+
+instance IsLabel "consumer" ConsumerLabel where fromLabel = ConsumerLabel
+
+labelWorks :: Bool
+labelWorks = #consumer == ConsumerLabel
diff --git a/test/evidence-fixture/Main.hs b/test/evidence-fixture/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/evidence-fixture/Main.hs
@@ -0,0 +1,83 @@
+module Main (main) where
+
+import Control.Concurrent (threadDelay)
+import Data.ByteString qualified as BS
+import Data.List (isInfixOf)
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Text
+import System.Environment (getArgs, lookupEnv)
+import System.Exit (ExitCode (..), exitWith)
+import System.IO
+import System.Posix.Process (getProcessID)
+import System.Posix.Signals
+import Test.Heni.Adapter
+import Test.Heni.Crash
+import Test.Heni.Expectation (ReplayEvidence (..))
+
+main :: IO ()
+main = do
+  hSetBuffering stdout NoBuffering
+  hSetBuffering stderr NoBuffering
+  getArgs >>= \case
+    ["exit", "0"] -> pure ()
+    ["protocol", path, mode] -> protocolFixture path mode
+    ["crash", path, mode] -> crashFixture path mode
+    _ -> error "unknown evidence fixture mode"
+
+protocolFixture :: FilePath -> String -> IO ()
+protocolFixture path mode = do
+  mutated <- isInfixOf "mutant" <$> readFile path
+  selector <- fmap Text.pack <$> lookupEnv "HENI_SELECTOR"
+  currentStage <- lookupEnv "HENI_STAGE"
+  let allIds = ["suite/intended", "suite/other"]
+      selected = maybe allIds (\s -> filter (Text.isInfixOf s) allIds) selector
+      observation ident
+        | not mutated = TestPassed ident
+        | ident == "suite/intended" && mode == "skipped" = TestSkipped ident
+        | ident == "suite/intended" && mode == "setup" = TestSetupFailed ident "fixture setup failed"
+        | ident == "suite/other" && mode == "wrong" = TestFailed ident
+        | ident == "suite/intended" && mode /= "wrong" = TestFailed ident
+        | otherwise = TestPassed ident
+      observations = map observation selected
+      failed = Set.fromList [ident | TestFailed ident <- observations]
+  token <- maybe "fixture-token" Text.pack <$> lookupEnv "HENI_REPLAY_TOKEN"
+  generator <- maybe "fixture-generator" Text.pack <$> lookupEnv "HENI_REPLAY_GENERATOR"
+  let replay = if mutated && mode `elem` ["replay", "replay-timeout"] then Just (ReplayEvidence token generator failed) else Nothing
+      producer = if mutated && mode == "version" then "fixture/v2" else "fixture/v1"
+      suite = SuiteEvidence producer (Just allIds) selected observations [] replay ["counterexample"]
+  currentEvidenceAttempt
+    >>= mapM_
+      ( \attempt -> do
+          let destination = if mutated && mode == "stale" then case attempt of EvidenceAttempt root dir _ ident stage role -> EvidenceAttempt root dir "stale-run" ident stage role else attempt
+          emitSuite destination suite
+          if mutated
+            then case mode of
+              "partial" -> do
+                bytes <- BS.readFile (evidencePath attempt)
+                BS.writeFile (evidencePath attempt) (BS.intercalate "\n" (init (init (BS.split 10 bytes))))
+              "counts" -> do
+                bytes <- BS.readFile (evidencePath attempt)
+                let text = Text.replace "\"executed\":2" "\"executed\":9" (Text.decodeUtf8 bytes)
+                BS.writeFile (evidencePath attempt) (Text.encodeUtf8 text)
+              _ -> pure ()
+            else pure ()
+      )
+  if mutated && mode == "replay-timeout" && currentStage == Just "replay" then threadDelay 30000000 else pure ()
+  if any (\case TestFailed {} -> True; TestSetupFailed {} -> True; _ -> False) observations then exitWith (ExitFailure 1) else pure ()
+
+crashFixture :: FilePath -> String -> IO ()
+crashFixture path mode = do
+  mutated <- isInfixOf "mutant" <$> readFile path
+  if not mutated
+    then recordCrashProbe "operation-reached"
+    else do
+      case mode of
+        "missing" -> putStrLn "operation-reached"
+        "stale" -> currentEvidenceAttempt >>= mapM_ (\case EvidenceAttempt root dir _ ident stage role -> writeCrashProbe (EvidenceAttempt root dir "stale-run" ident stage role) "operation-reached")
+        _ -> recordCrashProbe "operation-reached"
+      case mode of
+        "timeout" -> threadDelay 30000000
+        "exit" -> exitWith (ExitFailure (128 + fromIntegral sigABRT))
+        "wrong-signal" -> getProcessID >>= signalProcess sigTERM
+        _ -> getProcessID >>= signalProcess sigABRT
diff --git a/test/fixture/Main.hs b/test/fixture/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/fixture/Main.hs
@@ -0,0 +1,97 @@
+module Main (main) where
+
+import Control.Concurrent (threadDelay)
+import Control.Monad (forever, replicateM_)
+import Data.ByteString qualified as BS
+import Data.List (isInfixOf)
+import System.Directory qualified as Dir
+import System.Environment (getArgs, getExecutablePath, lookupEnv)
+import System.Exit (ExitCode (..), exitWith)
+import System.IO
+import System.IO.Error (catchIOError)
+import System.Posix.Process (createSession, getProcessID)
+import System.Posix.Signals
+import System.Process qualified as P
+
+main :: IO ()
+main = do
+  hSetBuffering stdout NoBuffering
+  hSetBuffering stderr NoBuffering
+  getArgs >>= \case
+    ["exit", code] -> exitWith (if code == "0" then ExitSuccess else ExitFailure (read code))
+    ["signal"] -> getProcessID >>= signalProcess sigABRT
+    ["sleep"] -> putStrLn "exact-witness" >> threadDelay 30000000
+    ["ignore", marker] -> do
+      _ <- installHandler sigTERM Ignore Nothing
+      pid <- getProcessID
+      writeFile marker (show pid)
+      forever (threadDelay 1000000)
+    ["descendant", marker] -> do
+      exe <- getExecutablePath
+      _ <- P.createProcess (P.proc exe ["ignore", marker])
+      waitForMarker marker
+      -- Exit while the descendant retains stdout/stderr and ignores SIGTERM.
+      pure ()
+    ["detached", marker] -> do
+      _ <- createSession
+      pid <- getProcessID
+      writeFile marker (show pid)
+      forever (threadDelay 1000000)
+    ["detach", marker] -> do
+      exe <- getExecutablePath
+      _ <- P.createProcess (P.proc exe ["detached", marker])
+      waitForMarker marker
+    ["flood"] -> do
+      replicateM_ 200 (BS.hPut stdout (BS.replicate 32768 65) >> BS.hPut stderr (BS.replicate 32768 66))
+      BS.hPut stdout "exact-"
+      threadDelay 10000
+      BS.hPut stdout "witness"
+      exitWith (ExitFailure 1)
+    ["invalid-utf8"] -> BS.hPut stdout (BS.pack [255]) >> exitWith (ExitFailure 1)
+    ["incremental-build", mode] -> do
+      content <- readFile "input"
+      key <- maybe "fresh" id <$> lookupEnv "HENI_BUILD_INPUT_ID"
+      Dir.createDirectoryIfMissing True "dist-newstyle"
+      exists <- Dir.doesFileExist "dist-newstyle/observed-key"
+      old <- if exists then readFile "dist-newstyle/observed-key" else pure "absent"
+      length old `seq` pure ()
+      if (mode == "unsafe" && exists) || old == key
+        then pure ()
+        else do
+          writeFile "dist-newstyle/compiled" content
+          writeFile "dist-newstyle/observed-key" key
+      if mode == "generated" then writeFile "unexpected-generated" "unexpected" else pure ()
+      if mode == "reject" && content == "mutant" then putStrLn "intended-build-rejection" >> exitWith (ExitFailure 1) else pure ()
+    ["incremental-test"] -> do
+      compiled <- readFile "dist-newstyle/compiled"
+      if compiled == "mutant" then putStrLn "capacity rule violated" >> exitWith (ExitFailure 1) else pure ()
+    ["counter", path] -> do
+      previous <- readFile path `catchIOError` const (pure "0")
+      let n = (read previous :: Int) + 1
+      length previous `seq` writeFile path (show n)
+      if n > 1 then putStrLn "unchanged control failed" >> exitWith (ExitFailure 1) else pure ()
+    ["check", path, mode] -> do
+      content <- readFile path
+      if "mutant" `isInfixOf` content
+        then case mode of
+          "diagnostic" -> putStrLn "capacity rule violated" >> exitWith (ExitFailure 1)
+          "wrong" -> putStrLn "unrelated failure" >> exitWith (ExitFailure 1)
+          "timeout" -> putStrLn "capacity rule violated" >> threadDelay 30000000
+          _ -> pure ()
+        else pure ()
+    ["check", path, "timeout", marker] -> do
+      content <- readFile path
+      if "mutant" `isInfixOf` content
+        then do
+          pid <- getProcessID
+          writeFile marker (show pid)
+          putStrLn "capacity rule violated"
+          threadDelay 30000000
+        else pure ()
+    "echo" : args -> mapM_ print args
+    _ -> error "unknown fixture mode"
+
+waitForMarker :: FilePath -> IO ()
+waitForMarker marker = do
+  ready <- (not . null) <$> (readFile marker `catchIOError` const (pure ""))
+  if ready then pure () else threadDelay 5000 >> waitForMarker marker
diff --git a/test/migration/Legacy.hs b/test/migration/Legacy.hs
new file mode 100644
--- /dev/null
+++ b/test/migration/Legacy.hs
@@ -0,0 +1,88 @@
+-- | Independently authored finite comparison harnesses, not production runners
+-- and not copies of the surveyed repositories. Deliberately exit-only verdicts
+-- demonstrate why attributable evidence matters. Never used for hang fixtures.
+module Legacy (runLegacy, copyFixture) where
+
+import Control.Exception (bracket)
+import Control.Monad (forM_, unless)
+import Data.Text qualified as Text
+import Data.Text.IO qualified as Text
+import System.Directory qualified as Dir
+import System.Exit (ExitCode (..))
+import System.FilePath ((</>))
+import System.Posix.Temp (mkdtemp)
+import System.Process qualified as P
+
+-- BEGIN shared-orchestration
+copyFixture :: FilePath -> FilePath -> IO ()
+copyFixture source destination = do
+  Dir.createDirectoryIfMissing True destination
+  names <- Dir.listDirectory source
+  forM_ names $ \name -> do
+    directory <- Dir.doesDirectoryExist (source </> name)
+    if directory
+      then copyFixture (source </> name) (destination </> name)
+      else Dir.copyFile (source </> name) (destination </> name)
+
+withCopy :: FilePath -> (FilePath -> IO a) -> IO a
+withCopy source action = bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "legacy-comparison-")) Dir.removePathForcibly $ \root -> copyFixture source root >> action root
+
+command :: FilePath -> String -> [String] -> IO ExitCode
+command root exe args = do
+  (code, _, _) <- P.readCreateProcessWithExitCode ((P.proc exe args) {P.cwd = Just root}) ""
+  pure code
+
+baseline :: ExitCode -> IO ()
+baseline code = unless (code == ExitSuccess) (error "comparison fixture baseline failed")
+
+-- END shared-orchestration
+
+runLegacy :: String -> FilePath -> IO Bool
+runLegacy "replacement" source = withCopy source $ \root -> do
+  -- BEGIN replacement-orchestration
+  command root "cabal" ["test", "fixture-test", "--offline", "-j1"] >>= baseline
+  -- BEGIN replacement-definition
+  text <- Text.readFile (root </> "src/Capacity.hs")
+  Text.writeFile (root </> "src/Capacity.hs") (Text.replace "used < limit" "True" text)
+  -- END replacement-definition
+  code <- command root "cabal" ["test", "fixture-test", "--offline", "-j1"]
+  pure (code /= ExitSuccess)
+-- END replacement-orchestration
+runLegacy "patch-catalogue" source = withCopy source $ \root -> do
+  -- BEGIN patch-catalogue-orchestration
+  command root "cabal" ["test", "fixture-test", "--offline", "-j1"] >>= baseline
+  -- BEGIN patch-catalogue-definition
+  command root "git" ["apply", "--check", "patches/guard-bypass.patch"] >>= baseline
+  command root "git" ["apply", "patches/guard-bypass.patch"] >>= baseline
+  -- END patch-catalogue-definition
+  code <- command root "cabal" ["test", "fixture-test", "--offline", "-j1"]
+  pure (code /= ExitSuccess)
+-- END patch-catalogue-orchestration
+runLegacy "generated-code" source = withCopy source $ \root -> do
+  -- BEGIN generated-code-orchestration
+  command root "runghc" ["tools/Build.hs"] >>= baseline
+  command root "cabal" ["test", "fixture-test", "--offline", "-j1"] >>= baseline
+  -- BEGIN generated-code-definition
+  text <- Text.readFile (root </> "model.txt")
+  Text.writeFile (root </> "model.txt") (Text.replace "mode=strict" "mode=open" text)
+  -- END generated-code-definition
+  built <- command root "runghc" ["tools/Build.hs"]
+  if built /= ExitSuccess then pure True else (/= ExitSuccess) <$> command root "cabal" ["test", "fixture-test", "--offline", "-j1"]
+-- END generated-code-orchestration
+runLegacy "diagnostic-checker" source = withCopy source $ \root -> do
+  -- BEGIN diagnostic-checker-orchestration
+  command root "runghc" ["tools/Build.hs"] >>= baseline
+  command root "./dist-newstyle/checker" [] >>= baseline
+  -- BEGIN diagnostic-checker-definition
+  text <- Text.readFile (root </> "policy.txt")
+  Text.writeFile (root </> "policy.txt") (Text.replace "capacity=10" "capacity=-1" text)
+  -- END diagnostic-checker-definition
+  (/= ExitSuccess) <$> command root "./dist-newstyle/checker" []
+-- END diagnostic-checker-orchestration
+-- A finite negative comparison: this is the historical exit-only mistake.
+runLegacy "unrelated-compile-failure" source = withCopy source $ \root -> do
+  command root "cabal" ["test", "fixture-test", "--offline", "-j1"] >>= baseline
+  text <- Text.readFile (root </> "src/Capacity.hs")
+  Text.writeFile (root </> "src/Capacity.hs") (Text.replace "used < limit" "missingCapacityGuard" text)
+  (/= ExitSuccess) <$> command root "cabal" ["test", "fixture-test", "--offline", "-j1"]
+runLegacy _ _ = error "unknown comparison family"
diff --git a/test/migration/Main.hs b/test/migration/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/migration/Main.hs
@@ -0,0 +1,175 @@
+module Main (main) where
+
+import Control.Exception
+import Control.Monad (forM, forM_, unless)
+import Data.Aeson
+import Data.Aeson.KeyMap qualified as KM
+import Data.Aeson.Types (Pair)
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.IO qualified as Text
+import Data.Version (showVersion)
+import GHC.Clock (getMonotonicTimeNSec)
+import Legacy
+import Paths_heni (getDataFileName)
+import System.Directory qualified as Dir
+import System.Environment (getArgs, lookupEnv)
+import System.Exit (ExitCode (..))
+import System.FilePath (takeDirectory, takeFileName, (</>))
+import System.IO (BufferMode (..), hSetBuffering, stdout)
+import System.Info qualified as Platform
+import System.Posix.Signals (sigABRT)
+import System.Posix.Temp (mkdtemp)
+import System.Process (readProcessWithExitCode)
+import System.Timeout (timeout)
+import Test.Heni
+import Test.Heni.Workspace
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+assert' :: String -> Bool -> IO ()
+assert' why good = unless good (error why)
+
+jsonField :: Key -> Value -> Value
+jsonField key (Object o) = maybe Null id (KM.lookup key o)
+jsonField _ _ = Null
+
+set :: Key -> Value -> Value -> Value
+set key value (Object o) = Object (KM.insert key value o)
+set _ _ other = other
+
+signature :: RunResult -> [(Text, Verdict, Maybe DetectionKind, [(Stage, ProcessOutcome)])]
+signature RunResult {results} = [(caseIdText caseId, verdict, detection, [(stage, outcome) | StageEvidence {stage, outcome} <- stages]) | CaseResult {caseId, verdict, detection, evidence = CaseEvidence {stages}} <- results]
+
+main :: IO ()
+main = do
+  hSetBuffering stdout LineBuffering
+  timeout 1200000000 suite >>= \case Nothing -> error "migration watchdog (20 minutes)"; Just () -> pure ()
+
+suite :: IO ()
+suite = bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "heni-migration-")) preserveAndRemove $ \root -> do
+  cliExecutable <- lookupEnv "HENI_CLI_EXE" >>= maybe (error "set HENI_CLI_EXE to the separately built heni-cli executable") pure
+  args <- getArgs
+  let families = if "--rich-only" `elem` args then [] else ["replacement", "patch-catalogue", "generated-code", "diagnostic-checker"]
+  records <- forM families $ \family -> do
+    putStrLn ("migration family: " ++ family)
+    sourceManifest <- getDataFileName ("examples/migrations/" ++ family ++ "/heni.json")
+    original <- BS.readFile sourceManifest >>= either (error . show) pure . eitherDecodeStrict
+    legacyStart <- getMonotonicTimeNSec
+    legacyCaught <- runLegacy family (takeDirectory sourceManifest)
+    legacyEnd <- getMonotonicTimeNSec
+    assert' (family ++ " legacy intended detection") legacyCaught
+    legacyWrong <- if family == "replacement" then Just <$> runLegacy "unrelated-compile-failure" (takeDirectory sourceManifest) else pure Nothing
+    assert' "exit-only comparison demonstrates unrelated compiler false positive" (family /= "replacement" || legacyWrong == Just True)
+    withSnapshot (takeDirectory sourceManifest) defaultSnapshotPolicy $ \snapshot -> withWorkspace snapshot $ \outer -> do
+      let source = workspaceRoot outer
+          edits path before after = [Replace (checked (mkRelativeTarget path)) before after 1]
+          (ident, target, witness) = case family of
+            "generated-code" -> ("generated-guard", "model.txt", "generated capacity: FAIL")
+            "diagnostic-checker" -> ("invalid-capacity", "policy.txt", "policy.capacity: maximum must be positive")
+            _ -> ("guard-bypass", "src/Capacity.hs", "rejects full capacity: FAIL")
+          (old, new) = case family of "generated-code" -> ("mode=strict", "mode=open"); "diagnostic-checker" -> ("capacity=10", "capacity=-1"); _ -> ("used < limit", "True")
+          base cid change expect = CaseDefinition (checked (mkCaseId cid)) "Fixture-specific migration" Set.empty change (checked (mkCommandId "build")) (Just (checked (mkCommandId "test"))) Nothing expect GenericCommands
+          expected = Diagnostic Test 1 witness
+      patch <- if family == "patch-catalogue" then Just <$> Text.readFile (source </> "patches/guard-bypass.patch") else pure Nothing
+      survivorPatch <- if family == "patch-catalogue" then Just <$> Text.readFile (source </> "patches/survivor.patch") else pure Nothing
+      let caught = base ident (maybe (edits target old new) (pure . Patch) patch) expected
+          survivor = base "surviving-label" (maybe (edits target "baseline" "variant") (pure . Patch) survivorPatch) expected
+          compileError = base "unrelated-compile-error" (edits target "used < limit" "missingCapacityGuard") expected
+          rejection = base "expected-build-rejection" (edits target "used < limit" "missingCapacityGuard") (BuildRejection 1 "not in scope")
+          portableCases = [caught, survivor] ++ (if family == "replacement" then [compileError, rejection] else [])
+          value = set "execution" (object ["workers" .= (2 :: Int), "baselineReuse" .= object ["toolchain" .= String "local GHC/Cabal fixture contract; no external state"]]) (set "cases" (toJSON (map caseDefinitionJSON portableCases)) original)
+          manifestPath = source </> "heni.json"
+      LBS.writeFile manifestPath (encode value)
+      manifest <- readManifest manifestPath >>= either (error . show) pure
+      let customGenerated definition = case definition of
+            CaseDefinition cid desc tags _ build test selector expect mode ->
+              ( CaseDefinition cid desc tags [CustomEdit "generator-input"] build test selector expect mode,
+                CaseHooks
+                  [ ( "generator-input",
+                      \WorkspaceOps {readTarget, writeTarget} -> do
+                        let path = checked (mkRelativeTarget "model.txt")
+                        content <- readTarget path
+                        unless (Text.count "mode=strict" content == 1) (ioError (userError "generator input anchor mismatch"))
+                        writeTarget path (Text.replace "mode=strict" "mode=open" content)
+                    )
+                  ]
+                  Nothing
+                  Nothing
+              )
+          typed = [if family == "generated-code" && index == 0 then customGenerated d else (d, noHooks) | (index, d) <- zip [0 :: Int ..] portableCases]
+          campaign = checked (mkCampaign (campaignCommands (manifestCampaign manifest)) typed)
+      start <- getMonotonicTimeNSec
+      library <- runCampaign (defaultRunOptions source (root </> family </> "library")) campaign
+      middle <- getMonotonicTimeNSec
+      (exit, _, err) <- readProcessWithExitCode cliExecutable ["run", manifestPath, "--jobs", "2", "--output", root </> family </> "cli", "--json"] ""
+      end <- getMonotonicTimeNSec
+      assert' (family ++ " expected nonpassing survivor exit: " ++ err) (exit == ExitFailure 1)
+      cliReport@StoredReport {result = cli} <- readReport (root </> family </> "cli/report.json") >>= either (error . show) pure
+      libReport <- readReport (root </> family </> "library/report.json") >>= either (error . show) pure
+      assert' (family ++ " typed/manifest semantic parity: " ++ show (signature library, signature cli)) (signature library == signature cli)
+      assert' (family ++ " intended/surviving variants") (map (\(_, v, _, _) -> v) (signature library) == [Caught, Survived] ++ (if family == "replacement" then [UnexpectedBuildFailure, Caught] else []))
+      let StoredReport {metadata = lm} = libReport; StoredReport {metadata = cm} = cliReport
+      assert' "both paths capture identical selected source bytes" (jsonField "identity" (jsonField "snapshot" lm) == jsonField "identity" (jsonField "snapshot" cm))
+      whenGenerated family $ do
+        output <- BS.readFile (root </> family </> "library/generated-guard-candidate-build.stdout.log")
+        assert' "mutated generator input reaches compiled artifact" ("artifact: admits used limit = True" `BS.isInfixOf` output)
+      -- Reading a stale schema is a diagnostic action, never a rerun.
+      LBS.writeFile (root </> family </> "stale-report.json") (encode (set "schemaVersion" (Number 999) (reportJSON cliReport)))
+      stale <- readReport (root </> family </> "stale-report.json")
+      assert' "stale report schema is rejected" (case stale of Left _ -> True; _ -> False)
+      pure (object ["family" .= family, "legacyIntendedCaught" .= legacyCaught, "legacyUnrelatedCompileAccepted" .= legacyWrong, "legacySeconds" .= seconds legacyStart legacyEnd, "typedFreshSeconds" .= seconds start middle, "cliParallelReuseSeconds" .= seconds middle end, "outcomes" .= [show verdict | CaseResult {verdict} <- case library of RunResult {results} -> results], "typedReport" .= reportJSON libReport, "cliReport" .= reportJSON cliReport])
+  rich <- richTrials cliExecutable root
+  let output = object ["trialSelection" .= String (if null families then "rich-only" else "complete"), "operator" .= String "automated agent-authored trial; no human usability timing claimed", "platform" .= object ["os" .= Platform.os, "arch" .= Platform.arch, "compilerVersion" .= showVersion Platform.compilerVersion], "families" .= records, "richer" .= rich]
+  destination <- lookupEnv "HENI_MIGRATION_RESULTS"
+  forM_ destination $ \path -> do
+    Dir.createDirectoryIfMissing True (takeDirectory path)
+    let evidenceDirectory = takeDirectory path </> takeFileName root
+    LBS.writeFile path (encode (set "evidenceDirectory" (String (Text.pack evidenceDirectory)) output))
+  putStrLn (if null families then "Richer evidence trial passed" else "Four migration families, typed/CLI parity, precise negatives and richer evidence passed")
+  where
+    seconds start end = fromIntegral (end - start) / 1e9 :: Double
+    whenGenerated family action = if family == "generated-code" then action else pure ()
+
+richTrials :: FilePath -> FilePath -> IO Value
+richTrials cliExecutable root = do
+  let source = root </> "rich-source"
+      build = "build" :: Text
+      command args budget = object ["executable" .= String "heni-evidence-fixture", "arguments" .= (args :: [String]), "timeoutSeconds" .= (budget :: Double)]
+      namedCommand mode = command ["protocol", "input", mode] 5
+      replace old new = object ["kind" .= String "replace", "path" .= String "input", "old" .= (old :: Text), "new" .= (new :: Text)]
+      named replay = object ["kind" .= String "named-tests", "failures" .= (["suite/intended"] :: [Text]), "replayRequired" .= replay]
+      define :: Text -> Text -> Value -> Value -> [Value] -> [Pair] -> Value
+      define cid test expect adapter edits extras = object (["id" .= (cid :: Text), "description" .= String "Combined evidence migration", "build" .= build, "test" .= (test :: Text), "expect" .= expect, "adapter" .= adapter, "edits" .= edits] ++ extras)
+      diagnostic = object ["kind" .= String "diagnostic", "stage" .= String "test", "exitCode" .= (1 :: Int), "outputContains" .= String "capacity rule violated"]
+      normal cid test = define cid test (named False) (String "heni-v1") [replace "anchor" "mutant"]
+      resource :: Text -> Text -> Text -> Value
+      resource name setup teardown = object ["name" .= name, "identity" .= String "fixture", "version" .= String "v1", "epoch" .= String "clean", "setup" .= setup, "reset" .= build, "teardown" .= teardown]
+      cases = [normal "intended" "intended" [], normal "wrong-named" "wrong" [], normal "empty-selection" "intended" ["selector" .= String "absent"], define "stale-edit" "generic" diagnostic Null [replace "missing" "mutant"] [], define "hang" "hang" diagnostic Null [replace "anchor" "mutant"] [], normal "setup-failed" "intended" ["resources" .= (["SETUP"] :: [Text])], normal "teardown-failed" "intended" ["resources" .= (["TEARDOWN"] :: [Text])], define "replay" "replay" (named True) (String "heni-v1") [replace "anchor" "mutant"] [], define "crash" "crash" (object ["kind" .= String "crash", "signal" .= (fromIntegral sigABRT :: Int), "probe" .= String "operation-reached"]) Null [replace "anchor" "mutant"] [], define "control" "intended" (object ["kind" .= String "control"]) (String "heni-v1") [] []]
+      value = object ["schemaVersion" .= (1 :: Int), "execution" .= object ["workers" .= (2 :: Int), "baselineReuse" .= object ["toolchain" .= String "standalone protocol fixture/v1", "adapter" .= String "fixture/v1"]], "commands" .= object ["build" .= command ["exit", "0"] 5, "fail" .= command ["exit", "1"] 5, "intended" .= namedCommand "intended", "wrong" .= namedCommand "wrong", "replay" .= namedCommand "replay", "crash" .= command ["crash", "input", "good"] 5, "generic" .= object ["executable" .= String "heni-fixture", "arguments" .= (["check", "input", "diagnostic"] :: [String])], "hang" .= object ["executable" .= String "heni-fixture", "arguments" .= (["check", "input", "timeout"] :: [String]), "timeoutSeconds" .= (0.5 :: Double)]], "resources" .= [resource ("SETUP" :: Text) ("fail" :: Text) build, resource ("TEARDOWN" :: Text) build ("fail" :: Text)], "cases" .= cases]
+  Dir.createDirectory source
+  BS.writeFile (source </> "input") "anchor"
+  LBS.writeFile (source </> "heni.json") (encode value)
+  manifest <- readManifest (source </> "heni.json") >>= either (error . show) pure
+  lib <- runCampaign ((manifestRunOptions (root </> "rich-library") manifest) {workers = 1, reusePolicy = Fresh}) (manifestCampaign manifest)
+  (exit, _, err) <- readProcessWithExitCode cliExecutable ["run", source </> "heni.json", "--output", root </> "rich-cli", "--json"] ""
+  assert' ("rich infrastructure exit: " ++ err) (exit == ExitFailure 2)
+  report@StoredReport {result = cli} <- readReport (root </> "rich-cli/report.json") >>= either (error . show) pure
+  let issues RunResult {results} = [(caseIdText caseId, infrastructureErrors, cleanupErrors) | CaseResult {caseId, evidence = CaseEvidence {infrastructureErrors, cleanupErrors}} <- results]
+  assert' ("richer typed/CLI parity: " ++ show (signature lib, signature cli) ++ "; issues: " ++ show (issues lib, issues cli)) (signature lib == signature cli)
+  assert' "richer outcomes preserve named, replay, crash, resource and control meaning" (map (\(_, v, _, _) -> v) (signature lib) == [Caught, WrongFailure, NotRun, InvalidMutation, TimedOut, InfrastructureError, InfrastructureError, Caught, Caught, Caught])
+  pure (reportJSON report)
+
+preserveAndRemove :: FilePath -> IO ()
+preserveAndRemove root = do
+  destination <- lookupEnv "HENI_MIGRATION_RESULTS"
+  forM_ destination $ \path -> do
+    Dir.createDirectoryIfMissing True (takeDirectory path)
+    let evidenceDirectory = takeDirectory path </> takeFileName root
+    copyFixture root evidenceDirectory
+    putStrLn ("migration evidence retained: " ++ evidenceDirectory)
+  Dir.removePathForcibly root
diff --git a/test/process/Main.hs b/test/process/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/process/Main.hs
@@ -0,0 +1,130 @@
+module Main (main) where
+
+import Control.Concurrent
+import Control.Exception
+import Control.Monad (forM_, unless)
+import Data.ByteString qualified as BS
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import System.Directory qualified as Dir
+import System.FilePath ((</>))
+import System.Posix.Files (createSymbolicLink, fileExist, setFileMode)
+import System.Posix.Signals
+import System.Posix.Temp (mkdtemp)
+import System.Posix.Types qualified
+import System.Timeout (timeout)
+import Test.Heni
+import Test.Heni.Process
+import Test.Heni.Workspace
+
+assert' :: String -> Bool -> IO ()
+assert' name good = unless good (error name)
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+command :: [String] -> Int -> Command
+command args micros = Command "heni-fixture" args Set.empty Nothing [] (checked (mkStageBudget micros)) (OutputPolicy 1024)
+
+main :: IO ()
+main = do
+  result <- timeout 20000000 suite
+  assert' "outer process-suite watchdog" (result == Just ())
+
+suite :: IO ()
+suite = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-process-test-")) Dir.removePathForcibly $ \root -> do
+  Dir.createDirectory (root </> "source")
+  withSnapshot (root </> "source") defaultSnapshotPolicy $ \snapshot -> withWorkspace snapshot $ \workspace -> do
+    let opts prefix = case defaultProcessOptions (root </> "logs") prefix of
+          ProcessOptions d p g _ e observer -> ProcessOptions d p g ["exact-witness"] e observer
+        run prefix args micros = runCommand (opts prefix) workspace Test (command args micros)
+        outcomeOf ProcessReport {evidence = StageEvidence {outcome}} = outcome
+    missing <- runCommand (opts "missing") workspace Test (Command "heni-definitely-absent" [] Set.empty Nothing [] (checked (mkStageBudget 100000)) (OutputPolicy 100))
+    assert' "launch failure" (case outcomeOf missing of LaunchFailed {} -> True; _ -> False)
+    exited <- run "exit" ["exit", "1"] 1000000
+    assert' ("exit differs from signal: " ++ show (outcomeOf exited)) (outcomeOf exited == Exited 1)
+    let localCommand = Command "./local-fixture" ["exit", "1"] Set.empty Nothing [] (checked (mkStageBudget 2000000)) (OutputPolicy 1024)
+    deferred <- validateExecutable localCommand
+    assert' "workspace executable availability is deferred until build" (deferred == Right ())
+    installed <- Dir.findExecutable "heni-fixture" >>= maybe (error "missing fixture") pure
+    Dir.copyFile installed (workspaceRoot workspace </> "local-fixture")
+    setFileMode (workspaceRoot workspace </> "local-fixture") 0o700
+    local <- runCommand (opts "workspace-program") workspace Test localCommand
+    assert' "built workspace executable runs from owned tree" (outcomeOf local == Exited 1)
+    assert' "workspace executable traversal is invalid" (case validateCommand (Command "./../escape" [] Set.empty Nothing [] (checked (mkStageBudget 100000)) (OutputPolicy 100)) of Left _ -> True; _ -> False)
+    Dir.removeFile (workspaceRoot workspace </> "local-fixture")
+    createSymbolicLink installed (workspaceRoot workspace </> "local-fixture")
+    escaped <- runCommand (opts "workspace-symlink") workspace Test localCommand
+    assert' "workspace executable symlinks are rejected before launch" (case outcomeOf escaped of LaunchFailed {} -> True; _ -> False)
+    Dir.removeFile (workspaceRoot workspace </> "local-fixture")
+    signaled <- run "signal" ["signal"] 1000000
+    assert' "actual signal" (outcomeOf signaled == Signaled (fromIntegral sigABRT))
+    timed <- run "timeout" ["sleep"] 50000
+    assert' "witness before timeout" (outcomeOf timed == Timeout)
+    assert' "bounded timeout and cleanup" (case timed of ProcessReport {evidence = StageEvidence {durationSeconds}} -> durationSeconds < 2)
+    forM_ [1 .. 12 :: Int] $ \n -> do
+      repeated <- run ("timeout-race-" ++ show n) ["sleep"] 20000
+      assert' ("repeated group disappearance retains clean timeout: " ++ show repeated) (outcomeOf repeated == Timeout && case repeated of ProcessReport {errors} -> null errors)
+    literal <- run "literal" ["echo", "space word", "'quotes'", "$HOME", "; touch nope"] 1000000
+    assert' "literal argument command succeeds" (outcomeOf literal == Exited 0)
+    bytes <- BS.readFile (root </> "logs/literal.stdout.log")
+    assert' "arguments preserved literally" (all (`BS.isInfixOf` bytes) ["space word", "'quotes'", "$HOME", "; touch nope"])
+    noFile <- fileExist (workspaceRoot workspace </> "nope")
+    assert' "no shell interpolation" (not noFile)
+    flood <- run "flood" ["flood"] 5000000
+    assert' "concurrent drains complete" (outcomeOf flood == Exited 1)
+    assert' "witness retained beyond log cap and chunk boundary" (case flood of ProcessReport {preservedWitnesses, logs, evidence = StageEvidence {truncated}} -> preservedWitnesses == ["exact-witness"] && not truncated && all (\LogArtifact {bytesRetained} -> bytesRetained <= 1024) logs && any (\LogArtifact {truncated = t} -> t) logs)
+    malformed <- runCommand (case opts "invalid" of ProcessOptions d p g _ e o -> ProcessOptions d p g ["\xfffd"] e o) workspace Test (command ["invalid-utf8"] 1000000)
+    assert' "invalid UTF8 cannot fabricate witness" (case malformed of ProcessReport {preservedWitnesses, evidence = StageEvidence {transcript}} -> null preservedWitnesses && Text.null transcript)
+    let marker = root </> "descendant.pid"
+    descendants <- run "descendant" ["descendant", marker] 2000000
+    pid <- read <$> readFile marker
+    assert' "leader normal exit preserved" (outcomeOf descendants == Exited 0)
+    assertNotRunning pid
+    assert' "pipe inheritance bounded" (case descendants of ProcessReport {evidence = StageEvidence {durationSeconds}} -> durationSeconds < 2)
+    observerFailed <- runCommand (case opts "observer" of ProcessOptions d p g ws e _ -> ProcessOptions d p g ws e (\_ _ -> ioError (userError "observer failed"))) workspace Test (command ["sleep"] 5000000)
+    assert' "observer failure captured" (case observerFailed of ProcessReport {errors} -> not (null errors))
+    forM_ [1 .. 3 :: Int] $ \n -> do
+      done <- newEmptyMVar
+      let cancelMarker = root </> ("cancel-" ++ show n ++ ".pid")
+      tid <- forkFinally (run ("cancel-" ++ show n) ["ignore", cancelMarker] 5000000) (putMVar done)
+      waitMarker cancelMarker
+      cancelPid <- read <$> readFile cancelMarker
+      throwTo tid ThreadKilled
+      _ <- forkIO (throwTo tid ThreadKilled)
+      completed <- timeout 2000000 (takeMVar done)
+      let cancelledWithEvidence = case completed of
+            Just (Left e) -> case fromException e of
+              Just ProcessAborted {} -> True
+              Nothing -> False
+            _ -> False
+      assert' "asynchronous cancellation carries evidence" cancelledWithEvidence
+      assertNotRunning cancelPid
+    -- Detached processes are outside group supervision. The test owns and
+    -- explicitly cleans up this PID; this documents the boundary.
+    let detachedMarker = root </> "detached.pid"
+    detached <- run "detach" ["detach", detachedMarker] 1000000
+    detachedPid <- read <$> readFile detachedMarker
+    signalProcess sigKILL detachedPid
+    assert' "detached pipe writer reported" (case detached of ProcessReport {errors} -> not (null errors))
+  putStrLn "POSIX process outcome, timeout, descendant, cancellation and bounded log checks passed"
+
+waitMarker :: FilePath -> IO ()
+waitMarker p = do
+  exists <- Dir.doesFileExist p
+  if exists
+    then do contents <- readFile p; if null contents then threadDelay 5000 >> waitMarker p else pure ()
+    else threadDelay 5000 >> waitMarker p
+
+assertNotRunning :: System.Posix.Types.ProcessID -> IO ()
+assertNotRunning pid = do
+  -- signal 0 may still observe an orphan zombie until the system reaps it.
+  -- A second fixture-independent query uses the OS process state via getProcessStatus
+  -- only for owned direct children; marker descendants are checked through a
+  -- bounded disappearance poll here.
+  gone <- timeout 1000000 waitGone
+  assert' "owned descendant no longer running" (gone == Just ())
+  where
+    waitGone = do
+      alive <- try (signalProcess nullSignal pid) :: IO (Either IOException ())
+      case alive of Left _ -> pure (); Right () -> threadDelay 5000 >> waitGone
diff --git a/test/runner/Main.hs b/test/runner/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/runner/Main.hs
@@ -0,0 +1,152 @@
+module Main (main) where
+
+import Control.Concurrent
+import Control.Exception
+import Control.Monad (forM_, unless, when)
+import Data.ByteString qualified as BS
+import Data.IORef
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Definitions (exampleCampaign)
+import Paths_heni (getDataFileName)
+import System.Directory qualified as Dir
+import System.Environment (lookupEnv)
+import System.FilePath (takeDirectory, (</>))
+import System.Posix.Temp (mkdtemp)
+import System.Timeout (timeout)
+import Test.Heni
+
+assert' :: String -> Bool -> IO ()
+assert' name good = unless good (error name)
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+main :: IO ()
+main = do
+  result <- timeout 180000000 suite
+  assert' "outer campaign watchdog" (result == Just ())
+
+suite :: IO ()
+suite = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-runner-test-")) Dir.removePathForcibly $ \root -> do
+  fixture <- getDataFileName "examples/minimal"
+  original <- BS.readFile (fixture </> "src/Capacity.hs")
+  actual <- runCampaign (defaultRunOptions fixture (root </> "real")) exampleCampaign
+  assert' "actual Cabal baseline/candidate classifications" (case actual of RunResult {results} -> [v | CaseResult {verdict = v} <- results] == [Caught, Survived, UnexpectedBuildFailure])
+  BS.readFile (fixture </> "src/Capacity.hs") >>= assert' "actual source unchanged" . (== original)
+  stored <- readReport (root </> "real/report.json")
+  assert' "portable report round trip" (case stored of Right StoredReport {result} -> result == actual; _ -> False)
+  schemaOutput <- lookupEnv "HENI_SCHEMA_REPORT"
+  forM_ schemaOutput $ \path -> do
+    Dir.createDirectoryIfMissing True (takeDirectory path)
+    Dir.copyFile (root </> "real/report.json") path
+  recovered <- recoverReport (root </> "real")
+  assert' "journal recovery is explicitly incomplete" (case recovered of Right StoredReport {result = RunResult {status = RunUntrustworthy, results}} -> length results == 3; _ -> False)
+  BS.appendFile (root </> "real/journal.jsonl") "{\"sequence\":"
+  truncated <- recoverReport (root </> "real")
+  assert' "truncated last record tolerated" (case truncated of Right _ -> True; _ -> False)
+  Dir.renameDirectory (root </> "real") (root </> "moved")
+  moved <- readReport (root </> "moved/report.json")
+  assert' "moved report retains artifacts" (case moved of Right _ -> True; _ -> False)
+  journal <- BS.readFile (root </> "moved/journal.jsonl")
+  BS.writeFile (root </> "moved/journal.jsonl") ("garbage\n" <> journal)
+  corrupt <- recoverReport (root </> "moved")
+  assert' "interior corruption rejected" (case corrupt of Left _ -> True; _ -> False)
+  let selectedId = checked (mkCaseId "case")
+      buildId = checked (mkCommandId "build")
+      testId = checked (mkCommandId "test")
+      budget = checked (mkStageBudget 1000000)
+      cmd args = Command "heni-fixture" args Set.empty Nothing [] budget (OutputPolicy 1024)
+      source = root </> "source"
+      definition = CaseDefinition selectedId "Detect the intended fixture fault" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" "mutant" 1] buildId (Just testId) Nothing (Diagnostic Test 1 "capacity rule violated") GenericCommands
+      campaign mode c hooks = checked (mkCampaign (Map.fromList [(buildId, cmd ["exit", "0"]), (testId, cmd ["check", "input", mode])]) [(c, hooks)])
+  Dir.createDirectory source
+  BS.writeFile (source </> "input") "anchor\n"
+  forM_ [("caught", "diagnostic", Caught), ("wrong", "wrong", WrongFailure), ("timeout", "timeout", TimedOut), ("survivor", "pass", Survived)] $ \(name, mode, expected) -> do
+    observed <- runCampaign (defaultRunOptions source (root </> name)) (campaign mode definition noHooks)
+    assert' (name ++ " classification") (case observed of RunResult {results = [CaseResult {verdict}]} -> verdict == expected; _ -> False)
+  let invalid = case definition of CaseDefinition i d ts _ b t s e mode -> CaseDefinition i d ts [Replace (checked (mkRelativeTarget "input")) "missing" "bad" 1] b t s e mode
+  stale <- runCampaign (defaultRunOptions source (root </> "stale")) (campaign "diagnostic" invalid noHooks)
+  assert' "invalid edit distinct" (case stale of RunResult {results = [CaseResult {verdict = InvalidMutation}]} -> True; _ -> False)
+  let patch = "diff --git a/input b/input\n--- a/input\n+++ b/input\n@@ -1 +1 @@\n-anchor\n+mutant\n"
+      patchedDefinition = case definition of CaseDefinition i d ts _ b t s e mode -> CaseDefinition i d ts [Patch patch] b t s e mode
+  available <- Dir.findExecutable "git"
+  required <- lookupEnv "HENI_REQUIRE_GIT"
+  when (required == Just "1" && available == Nothing) (error "patch validation requires Git")
+  case available of
+    Nothing -> putStrLn "Git unavailable: optional production patch check skipped"
+    Just _ -> do
+      patched <- runCampaign (defaultRunOptions source (root </> "patch")) (campaign "diagnostic" patchedDefinition noHooks)
+      assert' "production Git path uses supervised patch runner" (case patched of RunResult {results = [CaseResult {verdict = Caught}]} -> True; _ -> False)
+  let control = case definition of CaseDefinition i d ts _ b t s _ mode -> CaseDefinition i d ts [] b t s Control mode
+      failedControl = checked (mkCampaign (Map.fromList [(buildId, cmd ["exit", "0"]), (testId, cmd ["counter", root </> "control-counter"])]) [(control, noHooks)])
+  controlResult <- runCampaign (defaultRunOptions source (root </> "control")) failedControl
+  assert' "failed control cannot pass run" (case controlResult of RunResult {status = RunFailed, results = [CaseResult {verdict = WrongFailure}], failures = [ControlFailure {}]} -> True; _ -> False)
+  BS.writeFile (source </> "input") "mutant"
+  baseline <- runCampaign (defaultRunOptions source (root </> "baseline")) (campaign "diagnostic" definition noHooks)
+  assert' "failed baseline prevents candidate" (case baseline of RunResult {results = [CaseResult {verdict = NotRun}], failures = [BaselineFailure {}]} -> True; _ -> False)
+  BS.writeFile (source </> "input") "anchor\n"
+  let failedObserver = (defaultRunOptions source (root </> "observer")) {progress = \_ -> ioError (userError "observer failed")}
+  observer <- runCampaign failedObserver (campaign "diagnostic" definition noHooks)
+  assert' "observer failure untrustworthy" (case observer of RunResult {status = RunUntrustworthy} -> True; _ -> False)
+  let named = case definition of CaseDefinition i d ts es b t s _ mode -> CaseDefinition i d ts es b t s (NamedTests (Set.singleton "intended") NoReplay) mode
+  uncoveredBaseline <- runCampaign (defaultRunOptions source (root </> "empty-selection")) (campaign "diagnostic" named noHooks)
+  assert' "absent named selection blocks candidate" (case uncoveredBaseline of RunResult {results = [CaseResult {verdict = NotRun}], failures = [BaselineFailure {}]} -> True; _ -> False)
+  let existing = root </> "caught"
+  duplicate <- try (runCampaign (defaultRunOptions source existing) (campaign "diagnostic" definition noHooks)) :: IO (Either ConfigurationFailure RunResult)
+  assert' "existing output refused" (case duplicate of Left _ -> True; _ -> False)
+  -- Interrupt while a real candidate command is running. Finalization preserves
+  -- the active and queued case exactly once before rethrowing cancellation.
+  let readyPath = root </> "candidate-ready"
+  done <- newEmptyMVar
+  let second = case definition of CaseDefinition _ d ts es b t s e mode -> CaseDefinition (checked (mkCaseId "queued")) d ts es b t s e mode
+      commands = Map.fromList [(buildId, cmd ["exit", "0"]), (testId, cmd ["check", "input", "timeout", readyPath])]
+      cancellationCampaign = checked (mkCampaign commands [(definition, noHooks), (second, noHooks)])
+      cancelOptions = defaultRunOptions source (root </> "cancelled")
+  tid <- forkFinally (runCampaign cancelOptions cancellationCampaign) (putMVar done)
+  let waitReady = do exists <- Dir.doesFileExist readyPath; unless exists (threadDelay 5000 >> waitReady)
+  waitReady
+  throwTo tid ThreadKilled
+  finished <- timeout 3000000 (takeMVar done)
+  assert' "cancellation rethrown after finalization" (case finished of Just (Left _) -> True; _ -> False)
+  partial <- readReport (root </> "cancelled/report.json")
+  assert' "interrupted ledger exactly once" (case partial of Right StoredReport {result = RunResult {results, status = RunInterrupted}} -> length results == 2 && all (\CaseResult {verdict} -> verdict == NotRun) results; _ -> False)
+  -- Environment values and explicitly sensitive argv are redacted in reports.
+  let secretCommand = Command "heni-fixture" ["echo", "private-token"] (Set.singleton 1) Nothing [("TOKEN", "private-token")] budget (OutputPolicy 1024)
+      secretCampaign = checked (mkCampaign (Map.fromList [(buildId, secretCommand), (testId, cmd ["check", "input", "diagnostic"])]) [(definition, noHooks)])
+  _ <- runCampaign (defaultRunOptions source (root </> "redacted")) secretCampaign
+  reportBytes <- BS.readFile (root </> "redacted/report.json")
+  assert' "explicit secrets redacted in portable report" (not ("private-token" `BS.isInfixOf` reportBytes))
+  cleanupCount <- newIORef (0 :: Int)
+  let badCleanup =
+        (defaultRunOptions source (root </> "cleanup-failure"))
+          { workspaceCleanup = \_ -> do
+              n <- atomicModifyIORef' cleanupCount (\n -> (n + 1, n + 1))
+              if n == 2 then ioError (userError "consumer cleanup failed") else pure ()
+          }
+  cleanupFailed <- runCampaign badCleanup (campaign "diagnostic" definition noHooks)
+  assert'
+    "cleanup failure retains primary candidate evidence"
+    ( case cleanupFailed of
+        RunResult {status = RunUntrustworthy, results = [CaseResult {verdict = InfrastructureError, evidence = CaseEvidence {stages, cleanupErrors}}]} -> length stages == 2 && not (null cleanupErrors)
+        _ -> False
+    )
+  -- A syntactically complete bad record is corruption, not a truncated suffix.
+  originalJournal <- BS.readFile (root </> "caught/journal.jsonl")
+  BS.appendFile (root </> "caught/journal.jsonl") "null"
+  badLast <- recoverReport (root </> "caught")
+  assert' "complete invalid final journal record rejected" (case badLast of Left _ -> True; _ -> False)
+  BS.writeFile (root </> "caught/journal.jsonl") originalJournal
+  Dir.removeFile (root </> "caught/case-baseline-test.stdout.log")
+  missingArtifact <- readReport (root </> "caught/report.json")
+  assert' "missing baseline artifact is rejected" (case missingArtifact of Left _ -> True; _ -> False)
+  -- Failed final writes still return infrastructure information in memory.
+  let sabotage =
+        (defaultRunOptions source (root </> "unwritable"))
+          { progress = \case
+              CaseFinished {} -> Dir.removePathForcibly (root </> "unwritable") >> BS.writeFile (root </> "unwritable") "blocks report directory"
+              _ -> pure ()
+          }
+  diskFailure <- runCampaign sabotage (campaign "diagnostic" definition noHooks)
+  assert' "report IO failure outranks caught case" (case diskFailure of RunResult {status = RunUntrustworthy} -> True; _ -> False)
+  putStrLn "Real Cabal mutation cycle, lifecycle, portable reports and recovery checks passed"
diff --git a/test/scheduler/Main.hs b/test/scheduler/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/scheduler/Main.hs
@@ -0,0 +1,267 @@
+module Main (main) where
+
+import Control.Concurrent
+import Control.Exception
+import Control.Monad (unless)
+import Data.ByteString qualified as BS
+import Data.IORef
+import Data.List (sort)
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Encoding
+import System.Directory qualified as Dir
+import System.Environment (lookupEnv, setEnv, unsetEnv)
+import System.FilePath ((</>))
+import System.Posix.Files (setFileTimes)
+import System.Posix.Temp (mkdtemp)
+import System.Timeout (timeout)
+import Test.Heni
+import Test.Heni.BuildReuse qualified as Build
+import Test.Heni.Resource qualified as R
+import Test.Heni.Scheduler (runScheduled)
+import Test.Heni.Workspace
+
+assert' :: String -> Bool -> IO ()
+assert' why good = unless good (error why)
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+modify_ :: IORef a -> (a -> a) -> IO ()
+modify_ ref f = atomicModifyIORef' ref (\a -> (f a, ()))
+
+main :: IO ()
+main = timeout 120000000 checks >>= \case Nothing -> error "scheduler watchdog"; Just () -> pure ()
+
+checks :: IO ()
+checks = bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "heni-scheduler-")) Dir.removePathForcibly $ \root -> do
+  let source = root </> "source"
+  Dir.createDirectory source
+  writeFile (source </> "input") "anchor"
+  lifecycle <- newIORef []
+  let binding = R.ResourceBinding "fixture" "v1" (Just "clean-1") [("HENI_RESOURCE_DB_TOKEN", "secret-not-reportable")]
+      makeProvider name scope failAcquire failRelease =
+        R.managedResource
+          name
+          scope
+          (if name == "WORKER" then 2 else 1)
+          (\_ -> do modify_ lifecycle (++ [name <> "+"]); if failAcquire then ioError (userError "setup") else pure ((), if name == "DB" then binding else R.ResourceBinding "other" "v1" (Just "clean-1") []))
+          (\() -> modify_ lifecycle (++ [name <> "="]) >> pure (if name == "DB" then binding else R.ResourceBinding "other" "v1" (Just "clean-1") []))
+          (\() -> do modify_ lifecycle (++ [name <> "-"]); if failRelease then ioError (userError "cleanup") else pure ())
+      db = makeProvider "DB" R.CaseScope False False
+  withSnapshot source defaultSnapshotPolicy $ \snapshot -> do
+    let context = R.ResourceContext "test" snapshot root (const (pure ()))
+    R.withResources [db, makeProvider "SECOND" R.CaseScope False False] context $ \leases -> mapM_ (R.prepareResource . snd) leases
+    events <- readIORef lifecycle
+    assert' "declaration acquisition/reverse release" (events == ["DB+", "SECOND+", "DB=", "SECOND=", "SECOND-", "DB-"])
+    writeIORef lifecycle []
+    partial <- try (R.withResources [db, makeProvider "SECOND" R.CaseScope True False] context (const (pure ()))) :: IO (Either R.ResourceError ())
+    assert' "partial acquisition reports setup" (case partial of Left _ -> True; _ -> False)
+    events' <- readIORef lifecycle
+    assert' "partial acquisition releases earlier ownership" (events' == ["DB+", "SECOND+", "DB-"])
+    both <- try (R.withResources [makeProvider "DB" R.CaseScope False True] context (const (ioError (userError "primary")))) :: IO (Either WorkspaceFinalizationError ())
+    assert' "primary plus cleanup errors preserved" (case both of Left (WorkspaceFinalizationError (Just _) _) -> True; _ -> False)
+  withSnapshot source defaultSnapshotPolicy $ \snapshot -> do
+    writeIORef lifecycle []
+    timed <- timeout 10000 (R.withResources [db] (R.ResourceContext "timed" snapshot root (const (pure ()))) (const (threadDelay 1000000)))
+    releasedAfterTimeout <- readIORef lifecycle
+    assert' "timeout releases acquired callback resources" (timed == Nothing && releasedAfterTimeout == ["DB+", "DB-"])
+  usage <- newIORef (0 :: Int, 0 :: Int)
+  completed <- newIORef []
+  let doWork _ item = bracket_ (modify_ usage (\(n, peak) -> (n + 1, max peak (n + 1)))) (modify_ usage (\(n, peak) -> (n - 1, peak))) (threadDelay 30000 >> modify_ completed (++ [item]))
+  runScheduled 3 (Map.fromList [("A", 1), ("B", 1)]) [(1 :: Int, ["A", "B"]), (2, ["B", "A"]), (3, ["A"])] (\_ action -> action) doWork
+  (_, peak) <- readIORef usage
+  assert' "atomic opposite-order claims never overlap" (peak == 1)
+  done <- readIORef completed
+  assert' "finite queue completes exactly once" (sort done == [1, 2, 3])
+  entered <- newMVar (0 :: Int)
+  barrier <- newEmptyMVar
+  runScheduled
+    2
+    Map.empty
+    [(1 :: Int, []), (2, [])]
+    (\_ action -> action)
+    ( \_ _ -> do
+        n <- modifyMVar entered (\v -> pure (v + 1, v + 1))
+        if n == 2 then putMVar barrier () else pure ()
+        readMVar barrier
+    )
+  let bid = checked (mkCommandId "build")
+      tid = checked (mkCommandId "test")
+      ids = map (checked . mkCaseId) ["first", "second", "queued"]
+      command args = Command "heni-fixture" args Set.empty Nothing [] (checked (mkStageBudget 5000000)) (OutputPolicy 1024)
+      definition cid = CaseDefinition cid "resource scheduling" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" "mutant" 1] bid (Just tid) Nothing (Diagnostic Test 1 "capacity rule violated") GenericCommands
+      campaign = checked (mkCampaign (Map.fromList [(bid, command ["exit", "0"]), (tid, command ["check", "input", "diagnostic"])]) [(definition cid, noHooks) | cid <- ids])
+      claims = Map.fromList [(cid, ["DB"]) | cid <- ids]
+  paths <- newIORef []
+  let options name workers =
+        (defaultRunOptions source (root </> name))
+          { workers = workers,
+            resources = [db],
+            resourceClaims = claims,
+            workspaceCleanup = \workspace -> modify_ paths (++ [workspaceRoot workspace])
+          }
+  serial <- runCampaign (options "serial" 1) campaign
+  parallel <- runCampaign (options "parallel" 3) campaign
+  let outcomes RunResult {results} = map (\CaseResult {caseId, verdict} -> (caseId, verdict)) results
+  assert' "serial/parallel verdict and ledger parity" (outcomes serial == outcomes parallel && all ((== Caught) . snd) (outcomes parallel))
+  roots <- readIORef paths
+  assert' "all baseline/candidate trees are exclusive" (length roots == 12 && Set.size (Set.fromList roots) == 12)
+  portable <- readReport (root </> "parallel/report.json")
+  assert' "parallel journal/report artifacts remain readable" (case portable of Right _ -> True; _ -> False)
+  writeIORef lifecycle []
+  let scopedProviders = [makeProvider "DB" R.CampaignScope False False, makeProvider "WORKER" R.WorkerScope False False, makeProvider "CASE" R.CaseScope False False]
+  _ <- runCampaign ((options "all-scopes" 2) {resources = scopedProviders, resourceClaims = Map.fromList [(cid, ["DB", "WORKER", "CASE"]) | cid <- ids]}) campaign
+  scopeEvents <- readIORef lifecycle
+  let occurrences event = length (filter (== event) scopeEvents)
+  assert' ("campaign/worker/case acquire and release lifetimes: " ++ show scopeEvents) (occurrences "DB+" == 1 && occurrences "DB-" == 1 && occurrences "WORKER+" == 2 && occurrences "WORKER-" == 2 && occurrences "CASE+" == 3 && occurrences "CASE-" == 3)
+  writeIORef lifecycle []
+  _ <- runCampaign ((options "worker-acquisition-capacity" 3) {resources = [makeProvider "DB" R.WorkerScope False False]}) campaign
+  workerEvents <- readIORef lifecycle
+  assert' "worker acquisition itself respects declared capacity" (length (filter (== "DB+") workerEvents) == 1 && length (filter (== "DB-") workerEvents) == 1)
+  baselineCount <- newIORef (0 :: Int)
+  let observeBaseline (StageFinished _ "baseline" StageEvidence {stage = Test}) = modify_ baselineCount (+ 1)
+      observeBaseline _ = pure ()
+      reuseOptions name provider count = (options name count) {resources = [provider], reusePolicy = ReuseBaselines "fixture-toolchain/v1" Nothing, progress = observeBaseline}
+      runCount name provider count chosen = do
+        writeIORef baselineCount 0
+        result <- runCampaign (reuseOptions name provider count) chosen
+        invocations <- readIORef baselineCount
+        pure (result, invocations)
+  (reused, count) <- runCount "reuse" db 3 campaign
+  assert' "compatible baselines execute exactly once across workers" (count == 1 && outcomes reused == outcomes parallel)
+  originalReuseReport <- BS.readFile (root </> "reuse/report.json")
+  BS.writeFile (root </> "reuse/report.json") (Encoding.encodeUtf8 (Text.replace "\"ownerCase\":\"first\"" "\"ownerCase\":\"absent\"" (Encoding.decodeUtf8 originalReuseReport)))
+  badOwner <- readReport (root </> "reuse/report.json")
+  assert' "reuse provenance cannot reference absent producer evidence" (case badOwner of Left _ -> True; _ -> False)
+  BS.writeFile (root </> "reuse/report.json") originalReuseReport
+  let bid2 = checked (mkCommandId "different-build")
+      alter cid select buildId = case definition cid of CaseDefinition i d ts es _ t _ e mode -> CaseDefinition i d ts es buildId t select e mode
+      varied =
+        checked
+          ( mkCampaign
+              (Map.fromList [(bid, command ["exit", "0"]), (bid2, command ["echo", "different build option"]), (tid, command ["check", "input", "diagnostic"])])
+              [(alter (ids !! 0) Nothing bid, noHooks), (alter (ids !! 1) (Just "different selection") bid, noHooks), (alter (ids !! 2) Nothing bid2, noHooks)]
+          )
+  (_, differentCount) <- runCount "different-contracts" db 1 varied
+  assert' "selector and command/build changes invalidate sharing" (differentCount == 3)
+  let unknown = R.managedResource "DB" R.CaseScope 1 (\_ -> pure ((), R.ResourceBinding "fixture" "v1" Nothing [])) (\() -> pure (R.ResourceBinding "fixture" "v1" Nothing [])) (\() -> pure ())
+  (_, unknownCount) <- runCount "unknown-epoch" unknown 2 campaign
+  assert' "unknown external state disables reuse" (unknownCount == 3)
+  epochCounter <- newIORef (0 :: Int)
+  let changing =
+        R.managedResource
+          "DB"
+          R.CaseScope
+          1
+          ( \_ -> do
+              n <- atomicModifyIORef' epochCounter (\v -> (v + 1, v + 1))
+              let b = R.ResourceBinding "fixture" "v1" (Just (Text.pack (show n))) []
+              pure (b, b)
+          )
+          pure
+          (const (pure ()))
+  (_, epochCount) <- runCount "changed-epoch" changing 1 campaign
+  assert' "changed resource epoch invalidates sharing" (epochCount == 3)
+  cleanupCount <- newIORef (0 :: Int)
+  writeIORef baselineCount 0
+  resetFailed <-
+    runCampaign
+      ( (reuseOptions "cleanup-invalidates" db 1)
+          { workspaceCleanup = \_ -> do
+              n <- atomicModifyIORef' cleanupCount (\v -> (v + 1, v + 1)); if n == 2 then ioError (userError "failed reset") else pure ()
+          }
+      )
+      campaign
+  afterFailure <- readIORef baselineCount
+  assert' "failed cleanup retires cached evidence; next case is fresh" (afterFailure == 2 && case resetFailed of RunResult {results = CaseResult {verdict = InfrastructureError} : CaseResult {verdict = Caught} : _} -> True; _ -> False)
+  secretReport <- BS.readFile (root </> "reuse/report.json")
+  assert' "resource values and secret comparison never enter reports" (not ("secret-not-reportable" `BS.isInfixOf` secretReport))
+  finishedEvents <- newIORef []
+  let brokenTeardown = makeProvider "DB" R.CaseScope False True
+      observeFinished (CaseFinished CaseResult {caseId, verdict}) = modify_ finishedEvents (++ [(caseId, verdict)])
+      observeFinished _ = pure ()
+  releasedRun <- runCampaign ((options "final-event-after-release" 2) {resources = [brokenTeardown], progress = observeFinished}) campaign
+  finalEvents <- readIORef finishedEvents
+  assert' "one final progress event after failing case teardown" (sort finalEvents == sort (outcomes releasedRun) && length finalEvents == 3 && all ((== InfrastructureError) . snd) finalEvents)
+  priorEnv <- lookupEnv "HENI_ENV_CONFORMANCE"
+  let restoreEnv = maybe (unsetEnv "HENI_ENV_CONFORMANCE") (setEnv "HENI_ENV_CONFORMANCE") priorEnv
+  ( do
+      setEnv "HENI_ENV_CONFORMANCE" "first-state"
+      writeIORef baselineCount 0
+      let envObserver event = do
+            observeBaseline event
+            case event of CaseFinished {} -> setEnv "HENI_ENV_CONFORMANCE" "second-state"; _ -> pure ()
+      _ <- runCampaign ((reuseOptions "environment-change" db 1) {progress = envObserver}) campaign
+      envCount <- readIORef baselineCount
+      assert' "inherited environment contract is compared in memory" (envCount == 2)
+    )
+    `finally` restoreEnv
+  writeFile (source </> "changed-test-or-fixture") "changed input"
+  (_, newRunCount) <- runCount "new-snapshot" db 1 campaign
+  assert' "a new campaign with changed inputs starts without a verdict cache" (newRunCount == 1)
+  Dir.removeFile (source </> "changed-test-or-fixture")
+  buildRoots <- newIORef []
+  let buildProvider =
+        Build.BuildReuse
+          "content-key-fixture/v1"
+          [checked (mkRelativeTarget "dist-newstyle")]
+          (\workspace -> setFileTimes (workspaceRoot workspace </> "input") 0 0)
+          ( \workspace expected -> do
+              observed <- Text.pack <$> readFile (workspaceRoot workspace </> "dist-newstyle/observed-key")
+              pure (if observed == expected then Right () else Left "build did not observe restored/mutated input key")
+          )
+      alternating mode =
+        checked
+          ( mkCampaign
+              (Map.fromList [(bid, command ["incremental-build", mode]), (tid, command ["incremental-test"])])
+              [(case definition cid of CaseDefinition i d ts _ b t sel e m -> CaseDefinition i d ts [Replace (checked (mkRelativeTarget "input")) "anchor" changed 1] b t sel e m, noHooks) | (cid, changed) <- zip ids ["mutant", "safe__", "mutant"]]
+          )
+      buildOptions name provider = (defaultRunOptions source (root </> name)) {buildReuse = Just provider, workspaceCleanup = \workspace -> modify_ buildRoots (++ [workspaceRoot workspace])}
+  incremental <- runCampaign (buildOptions "incremental" buildProvider) (alternating "safe")
+  freshAlternating <- runCampaign (defaultRunOptions source (root </> "fresh-alternating")) (alternating "safe")
+  assert' "same-size alternating edits with identical coarse mtimes preserve fresh outcomes" (outcomes incremental == outcomes freshAlternating && map snd (outcomes incremental) == [Caught, Survived, Caught])
+  seenRoots <- readIORef buildRoots
+  assert' "one private incremental candidate tree, separate fresh baselines" (Set.size (Set.fromList seenRoots) == 4)
+  unsafe <- runCampaign (buildOptions "unsafe-invalidation" buildProvider) (alternating "unsafe")
+  assert' "stale build observations are infrastructure, never detection" (case unsafe of RunResult {results = [CaseResult {verdict = Caught}, CaseResult {verdict = InfrastructureError}, CaseResult {verdict = Caught}]} -> True; _ -> False)
+  let rejected =
+        checked
+          ( mkCampaign
+              (campaignCommands (alternating "reject"))
+              [(case d of CaseDefinition i desc tags edits b t sel _ mode -> CaseDefinition i desc tags edits b t sel (BuildRejection 1 "intended-build-rejection") mode, noHooks) | d <- campaignCases (alternating "reject")]
+          )
+      badObservation = case buildProvider of Build.BuildReuse contract dirs prep _ -> Build.BuildReuse contract dirs prep (\_ _ -> pure (Left "unproved compiler input"))
+  verifiedRejection <- runCampaign (buildOptions "verified-build-rejection" buildProvider) rejected
+  assert' "verified retained compiler rejection keeps expected-build meaning" (map snd (outcomes verifiedRejection) == [Caught, Survived, Caught])
+  rejectedBuild <- runCampaign (buildOptions "incremental-build-rejection" badObservation) rejected
+  assert' "expected compiler rejection also requires observed input identity" (all ((== InfrastructureError) . snd) (outcomes rejectedBuild))
+  let unexpected = case buildProvider of Build.BuildReuse contract dirs prep verify -> Build.BuildReuse contract dirs (\workspace -> prep workspace >> writeFile (workspaceRoot workspace </> "unexpected-generated") "bad") verify
+  generated <- runCampaign (buildOptions "generated-input" unexpected) (alternating "safe")
+  assert' "undeclared generated state invalidates retained worker" (all ((== InfrastructureError) . snd) (outcomes generated))
+  acquired <- newIORef (0 :: Int)
+  released <- newIORef (0 :: Int)
+  ready <- newEmptyMVar
+  enteredCount <- newIORef (0 :: Int)
+  let held = R.managedResource "HELD" R.CaseScope 2 (\_ -> modify_ acquired (+ 1) >> pure ((), R.ResourceBinding "held" "v1" Nothing [])) (\() -> pure (R.ResourceBinding "held" "v1" Nothing [])) (\() -> modify_ released (+ 1))
+      waitCampaign = checked (mkCampaign (Map.fromList [(bid, command ["sleep"]), (tid, command ["exit", "0"])]) [(definition cid, noHooks) | cid <- ids])
+      observe (CaseStarted _) = do n <- atomicModifyIORef' enteredCount (\v -> (v + 1, v + 1)); if n == 2 then putMVar ready () else pure ()
+      observe _ = pure ()
+  finished <- newEmptyMVar
+  thread <- forkFinally (runCampaign ((defaultRunOptions source (root </> "cancel")) {workers = 2, resources = [held], resourceClaims = Map.fromList [(cid, ["HELD"]) | cid <- ids], progress = observe}) waitCampaign) (putMVar finished)
+  takeMVar ready
+  throwTo thread ThreadKilled
+  cancelled <- takeMVar finished
+  let wasCancelled = case cancelled of
+        Left e -> case fromException e :: Maybe AsyncException of
+          Just ThreadKilled -> True
+          _ -> False
+        Right _ -> False
+  assert' "cancellation rethrows" wasCancelled
+  a <- readIORef acquired
+  r <- readIORef released
+  assert' "all acquired resources released on cancellation" (a == 2 && r == a)
+  partialReport <- readReport (root </> "cancel/report.json")
+  assert' "queued and running IDs finalized exactly once" (case partialReport of Right StoredReport {result = RunResult {results, status}} -> length results == 3 && status == RunInterrupted; _ -> False)
+  putStrLn "Scoped ownership, atomic capacities, isolated workers, parity and cancellation passed"
diff --git a/test/unit/Main.hs b/test/unit/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/unit/Main.hs
@@ -0,0 +1,135 @@
+module Main (main) where
+
+import Consumer
+import Control.Monad (forM_, unless)
+import Data.Set qualified as Set
+import Data.Text qualified
+import Labels (labelWorks)
+import Test.Heni
+
+assert :: String -> Bool -> IO ()
+assert name condition = unless condition (error name)
+
+buildOk :: StageEvidence
+buildOk = stageEvidence Build (Exited 0) ""
+
+stageEvidence :: Stage -> ProcessOutcome -> String -> StageEvidence
+stageEvidence s o text = StageEvidence s o (fromString text) False unknownSelection Nothing 0 []
+  where
+    fromString = Data.Text.pack
+
+namedFailure :: StageEvidence
+namedFailure =
+  StageEvidence
+    Test
+    (Exited 1)
+    ""
+    False
+    (SelectionEvidence (Just 1) (Just 1) (Just 1) intended intended Set.empty [])
+    Nothing
+    0
+    []
+
+intended :: Set.Set Data.Text.Text
+intended = Set.singleton "rejects full capacity"
+
+observations :: [StageEvidence] -> CaseEvidence
+observations ss = CaseEvidence BaselinePassed Changed ss [] [] Nothing
+
+verdictOf :: CaseDefinition -> CaseEvidence -> Verdict
+verdictOf c e = case classifyCase c e of CaseResult {verdict} -> verdict
+
+withSelection :: SelectionEvidence -> StageEvidence -> StageEvidence
+withSelection sel (StageEvidence s o txt tr _ r d logs) = StageEvidence s o txt tr sel r d logs
+
+withReplay :: ReplayEvidence -> StageEvidence -> StageEvidence
+withReplay r (StageEvidence s o txt tr sel _ d logs) = StageEvidence s o txt tr sel (Just r) d logs
+
+main :: IO ()
+main = do
+  assert "own label instance" labelWorks
+  forM_ definitions (\c -> assert "public construction" (validateCase c == Right ()))
+  let matching = observations [buildOk, namedFailure]
+      wrongNamed =
+        withSelection
+          ( SelectionEvidence
+              (Just 2)
+              (Just 2)
+              (Just 2)
+              (Set.insert "unrelated" intended)
+              (Set.singleton "unrelated")
+              Set.empty
+              []
+          )
+          namedFailure
+      extraNamed =
+        withSelection
+          ( SelectionEvidence
+              (Just 2)
+              (Just 2)
+              (Just 2)
+              (Set.insert "unrelated" intended)
+              (Set.insert "unrelated" intended)
+              Set.empty
+              []
+          )
+          namedFailure
+      zero = withSelection (SelectionEvidence (Just 1) (Just 0) (Just 0) Set.empty Set.empty intended []) namedFailure
+      setup = withSelection (SelectionEvidence (Just 1) (Just 1) (Just 1) intended intended Set.empty ["fixture setup failed"]) namedFailure
+      testDiag = stageEvidence Test (Exited 1) "capacity rule violated"
+      baselineFailed = CaseEvidence (BaselineFailed "unchanged test failed") Changed [] [] [] Nothing
+      replayInfo = ReplayEvidence "seed:42" "size=20" intended
+      replayStage = withReplay replayInfo (case namedFailure of StageEvidence _ o txt tr sel r d logs -> StageEvidence Replay o txt tr sel r d logs)
+      replayMismatch = withReplay (ReplayEvidence "seed:43" "size=20" intended) replayStage
+      scenarios =
+        [ ("correct named failure", namedCase, matching, Caught),
+          ("wrong named failure", namedCase, observations [buildOk, wrongNamed], WrongFailure),
+          ("extra named failure", namedCase, observations [buildOk, extraNamed], WrongFailure),
+          ("wrong exit", diagnosticCase, observations [buildOk, stageEvidence Test (Exited 2) "capacity rule violated"], WrongFailure),
+          ("diagnostic before timeout", diagnosticCase, observations [buildOk, stageEvidence Test Timeout "capacity rule violated"], TimedOut),
+          ("zero tests", namedCase, observations [buildOk, zero], Uncovered),
+          ("unknown counts", namedCase, observations [buildOk, testDiag], Uncovered),
+          ("skipped intended test", namedCase, observations [buildOk, zero], Uncovered),
+          ("semantic compile failure", namedCase, observations [stageEvidence Build (Exited 1) "compile error"], UnexpectedBuildFailure),
+          ("intentional compile rejection", buildCase, observations [stageEvidence Build (Exited 1) "unbound variable"], Caught),
+          ("unrelated compile rejection", buildCase, observations [stageEvidence Build (Exited 1) "missing package"], WrongFailure),
+          ("stale edit", namedCase, CaseEvidence BaselinePassed (EditRejected "anchor missing") [] [] [] Nothing, InvalidMutation),
+          ("no-op edit", namedCase, CaseEvidence BaselinePassed Unchanged [] [] [] Nothing, InvalidMutation),
+          ("baseline failure", namedCase, baselineFailed, NotRun),
+          ("setup failure with named witness", namedCase, observations [buildOk, setup], InfrastructureError),
+          ("cleanup failure with named witness", namedCase, CaseEvidence BaselinePassed Changed [buildOk, namedFailure] [] ["reset failed"] Nothing, InfrastructureError),
+          ("crash without probe", crashCase, observations [buildOk, stageEvidence Test (Signaled 6) ""], WrongFailure),
+          ("crash with probe", crashCase, observations [buildOk, stageEvidence Test (Signaled 6) "capacity-probe: native operation reached"], Caught),
+          ("runner kill cannot be crash", crashCase, observations [buildOk, stageEvidence Test Timeout "capacity-probe: native operation reached"], TimedOut),
+          ("missing replay", replayCase, matching, WrongFailure),
+          ("matching replay", replayCase, observations [buildOk, withReplay replayInfo namedFailure, replayStage], Caught),
+          ("replay identity mismatch", replayCase, observations [buildOk, withReplay replayInfo namedFailure, replayMismatch], WrongFailure),
+          ("duplicate stage", namedCase, observations [buildOk, namedFailure, namedFailure], InfrastructureError),
+          ("diagnostic detection", diagnosticCase, observations [buildOk, testDiag], Caught),
+          ("survivor", diagnosticCase, observations [buildOk, stageEvidence Test (Exited 0) ""], Survived),
+          ("passing control", controlCase, CaseEvidence BaselinePassed Unchanged [buildOk, stageEvidence Test (Exited 0) ""] [] [] Nothing, Caught),
+          ("failing control", controlCase, CaseEvidence BaselinePassed Unchanged [buildOk, testDiag] [] [] Nothing, WrongFailure),
+          ("truncated witness", diagnosticCase, observations [buildOk, StageEvidence Test (Exited 1) "capacity rule violated" True unknownSelection Nothing 0 []], InfrastructureError)
+        ]
+  forM_ scenarios $ \(name, c, e, expected) -> assert (name ++ ": " ++ show (classifyCase c e)) (verdictOf c e == expected)
+  let checked = either (error . show) id
+      i = case namedCase of CaseDefinition {caseId} -> caseId
+      selected = checked (mkSelectedCases [i])
+      result = classifyCase namedCase matching
+      summary = checked (summarizeRun selected [result] [])
+  assert "duplicate results rejected" (case summarizeRun selected [result, result] [] of Left _ -> True; _ -> False)
+  assert "missing result accounted" (case checked (summarizeRun selected [] []) of RunResult {results = [CaseResult {verdict = NotRun}]} -> True; _ -> False)
+  assert "single semantic score" (case summary of RunResult {score = Just (SemanticScore 1 1 True), status = RunPassed} -> True; _ -> False)
+  assert "baseline becomes run failure" (case checked (summarizeRun selected [classifyCase namedCase baselineFailed] []) of RunResult {failures = [BaselineFailure {}], status = RunFailed} -> True; _ -> False)
+  assert "control becomes run failure" (case checked (summarizeRun selected [classifyCase controlCase (CaseEvidence BaselinePassed Unchanged [buildOk, testDiag] [] [] Nothing)] []) of RunResult {failures = [ControlFailure {}]} -> True; _ -> False)
+  assert "zero denominator" (case checked (summarizeRun selected [classifyCase buildCase (observations [stageEvidence Build (Exited 1) "unbound variable"])] []) of RunResult {score = Nothing} -> True; _ -> False)
+  assert "infrastructure precedence" (case checked (summarizeRun selected [classifyCase diagnosticCase (observations [buildOk, stageEvidence Test (Exited 0) ""])] [RunInfrastructure "report failed"]) of RunResult {status = RunUntrustworthy} -> True; _ -> False)
+  forM_ ["", "/absolute", "../escape", "a/../b", "a//b", "C:drive", "a\\b", "a/./b"] $ \p ->
+    assert ("reject path " ++ p) (case mkRelativeTarget p of Left _ -> True; _ -> False)
+  assert "budget zero rejected" (case mkStageBudget 0 of Left _ -> True; _ -> False)
+  assert "empty selection rejected" (case mkSelectedCases [] of Left _ -> True; _ -> False)
+  assert "duplicate selection rejected" (case mkSelectedCases [i, i] of Left _ -> True; _ -> False)
+  -- Every permutation of unique stage evidence classifies identically; duplication
+  -- is a protocol error rather than an inflated count.
+  assert "stage reordering" (verdictOf namedCase (observations [namedFailure, buildOk]) == Caught)
+  putStrLn (show (length scenarios) ++ " classifier scenarios and ledger/API invariants passed")
diff --git a/test/workspace/Main.hs b/test/workspace/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/workspace/Main.hs
@@ -0,0 +1,142 @@
+module Main (main) where
+
+import Control.Concurrent (threadDelay)
+import Control.Exception
+import Control.Monad (forM_, unless, when)
+import Data.ByteString qualified as BS
+import Data.IORef
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import System.Directory qualified as Dir
+import System.Environment (lookupEnv)
+import System.Exit (ExitCode (..))
+import System.FilePath ((</>))
+import System.Posix.Files qualified as Posix
+import System.Posix.Temp (mkdtemp)
+import System.Process qualified as Process
+import System.Timeout (timeout)
+import Test.Heni
+import Test.Heni.Edit
+import Test.Heni.Workspace
+
+assert' :: String -> Bool -> IO ()
+assert' message condition = unless condition (error message)
+
+checked :: (Show e) => Either e a -> a
+checked = either (error . show) id
+
+target :: FilePath -> RelativeTarget
+target = checked . mkRelativeTarget
+
+caseWith :: [Edit] -> CaseDefinition
+caseWith edits =
+  CaseDefinition
+    (checked (mkCaseId "workspace-test"))
+    "Test checked mutations"
+    Set.empty
+    edits
+    (checked (mkCommandId "build"))
+    (Just (checked (mkCommandId "test")))
+    Nothing
+    (Diagnostic Test 1 "witness")
+    GenericCommands
+
+withTree :: (FilePath -> IO a) -> IO a
+withTree action = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-test-")) Dir.removePathForcibly action
+
+-- Test-only bounded helper until EP-3 supplies the production supervisor. Git
+-- doesn't spawn descendants for apply; timeout cancels its process bracket.
+gitRunner :: PatchRunner
+gitRunner workspace args = do
+  let root = workspaceRoot workspace
+  result <- timeout 5000000 (Process.readCreateProcessWithExitCode (Process.proc "git" ("-C" : root : args)) "")
+  pure $ case result of
+    Just (ExitSuccess, _, _) -> Right ()
+    Just (_, out, err) -> Left (Text.pack (out ++ err))
+    Nothing -> Left "git apply timeout"
+
+main :: IO ()
+main = withTree $ \source -> do
+  BS.writeFile (source </> "dirty.txt") "anchor\n"
+  BS.writeFile (source </> "untracked.bin") (BS.pack [0, 255, 1])
+  Dir.createDirectory (source </> "dist-newstyle")
+  BS.writeFile (source </> "dist-newstyle/ignored") "build"
+  Dir.createDirectory (source </> "mutation-results")
+  BS.writeFile (source </> "mutation-results/report.json") "ignored"
+  withSnapshot source defaultSnapshotPolicy $ \snapshot -> do
+    let files = snapshotFiles snapshot
+        edit = Replace (target "dirty.txt") "anchor" "changed" 1
+        run edits = withEditedWorkspace gitRunner noHooks snapshot (caseWith edits) (\w e -> (,) <$> BS.readFile (workspaceRoot w </> "dirty.txt") <*> pure e)
+    assert' "selected dirty/untracked bytes" (Map.size files == 2 && Map.member (target "untracked.bin") files)
+    assert' "SHA256 identity" (Text.length (snapshotIdentity snapshot) == 64)
+    withSnapshot source defaultSnapshotPolicy $ \again -> assert' "deterministic identity" (snapshotIdentity again == snapshotIdentity snapshot)
+    result <- run [edit]
+    assert' "checked replacement and touched inventory" (case result of Right ("changed\n", EditEvidence [FileChange {}]) -> True; _ -> False)
+    original <- BS.readFile (source </> "dirty.txt")
+    assert' "source untouched" (original == "anchor\n")
+    withWorkspace snapshot $ \other -> do
+      originalOther <- BS.readFile (workspaceRoot other </> "dirty.txt")
+      assert' "other workspace pristine" (originalOther == "anchor\n")
+    forM_ [[Replace (target "dirty.txt") "missing" "bad" 1], [Replace (target "dirty.txt") "anchor" "x" 2], [edit, Replace (target "dirty.txt") "missing" "x" 1], [edit, Replace (target "dirty.txt") "changed" "anchor" 1]] $ \edits -> do
+      failed <- run edits
+      assert' "stale/ambiguous/partial/no-op rejected" (case failed of Left InvalidEdit {} -> True; _ -> False)
+    malformed <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Replace (target "untracked.bin") "x" "y" 1]) (\_ _ -> pure ())
+    assert' "UTF8 error is invalid edit" (case malformed of Left InvalidEdit {} -> True; _ -> False)
+    -- No callback is published on partial failure.
+    published <- newIORef False
+    _ <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [edit, Replace (target "dirty.txt") "missing" "bad" 1]) (\_ _ -> writeIORef published True)
+    readIORef published >>= assert' "partial candidate unpublished" . not
+    created <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Create (target "new.txt") "created", Delete (target "dirty.txt") "anchor\n"]) $ \_ (EditEvidence changes) -> pure (length changes)
+    assert' "creation/deletion recorded" (created == Right 2)
+    duplicateCreate <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Create (target "dirty.txt") "created"]) (\_ _ -> pure ())
+    assert' "creation precondition" (case duplicateCreate of Left InvalidEdit {} -> True; _ -> False)
+    let control = case caseWith [] of CaseDefinition i d ts es b t s _ mode -> CaseDefinition i d ts es b t s Control mode
+    controlResult <- withEditedWorkspace gitRunner noHooks snapshot control (\_ (EditEvidence changes) -> pure (null changes))
+    assert' "unchanged control accepted" (controlResult == Right True)
+    withWorkspace snapshot $ \w -> do
+      let WorkspaceOps {writeTarget} = workspaceOperations w
+      writeTarget (target "dirty.txt") "changed"
+      differs <- try (verifyWorkspace snapshot w) :: IO (Either WorkspaceError ())
+      assert' "verification detects changes" (case differs of Left WorkspaceMismatch {} -> True; _ -> False)
+      resetWorkspace snapshot w
+      verifyWorkspace snapshot w
+      withTree $ \outside -> do
+        BS.writeFile (outside </> "victim") "safe"
+        Posix.createSymbolicLink (outside </> "victim") (workspaceRoot w </> "escape")
+        rejected <- try (writeTarget (target "escape") "bad") :: IO (Either WorkspaceError ())
+        assert' "symlink write rejected" (case rejected of Left UnsafeSource {} -> True; _ -> False)
+        BS.readFile (outside </> "victim") >>= assert' "outside bytes untouched" . (== "safe")
+      -- A deleted owned root produces an observable reset failure.
+      Dir.removePathForcibly (workspaceRoot w)
+      reset <- try (resetWorkspace snapshot w) :: IO (Either IOException ())
+      assert' "reset failure invalidates workspace" (case reset of Left _ -> True; _ -> False)
+    let patch = "diff --git a/dirty.txt b/dirty.txt\n--- a/dirty.txt\n+++ b/dirty.txt\n@@ -1 +1 @@\n-anchor\n+patched\n"
+    available <- Dir.findExecutable "git"
+    required <- lookupEnv "HENI_REQUIRE_GIT"
+    when (required == Just "1" && available == Nothing) (error "patch validation requires Git")
+    case available of
+      Nothing -> putStrLn "Git unavailable: optional positive patch checks skipped"
+      Just _ -> do
+        patched <- run [Patch patch]
+        assert' "textual patch" (case patched of Right ("patched\n", _) -> True; _ -> False)
+        stalePatch <- run [Patch (patch <> "diff --git a/missing b/missing\n--- a/missing\n+++ b/missing\n@@ -1 +1 @@\n-old\n+new\n")]
+        assert' "partial patch discarded" (case stalePatch of Left InvalidEdit {} -> True; _ -> False)
+        again <- run [edit]
+        assert' "patch failure cannot contaminate next case" (case again of Right ("changed\n", _) -> True; _ -> False)
+    unsafePatch <- run [Patch (Text.replace "--- a/dirty.txt" "--- a/../escape" patch)]
+    assert' "all patch header paths checked" (case unsafePatch of Left InvalidEdit {} -> True; _ -> False)
+    missingGit <- withEditedWorkspace (\_ _ -> throwIO (userError "git is unavailable")) noHooks snapshot (caseWith [Patch patch]) (\_ _ -> pure ())
+    assert' "missing Git infrastructure capability" (case missingGit of Left EditInfrastructure {} -> True; _ -> False)
+    let hooks = CaseHooks [("cancel", \_ -> threadDelay 10000000)] Nothing Nothing
+    cancelled <- timeout 20000 (withEditedWorkspace gitRunner hooks snapshot (caseWith [CustomEdit "cancel"]) (\_ _ -> pure ()))
+    assert' "cancellation bounded" (case cancelled of Nothing -> True; _ -> False)
+    withWorkspace snapshot (verifyWorkspace snapshot)
+    validateOutputLocation source defaultSnapshotPolicy (source </> "mutation-results")
+    invalidOutput <- try (validateOutputLocation source defaultSnapshotPolicy (source </> "new-results")) :: IO (Either WorkspaceError ())
+    assert' "nested output exclusion required" (case invalidOutput of Left _ -> True; _ -> False)
+  withTree $ \outside -> do
+    Posix.createSymbolicLink outside (source </> "source-link")
+    rejected <- try (withSnapshot source defaultSnapshotPolicy (\_ -> pure ())) :: IO (Either WorkspaceError ())
+    assert' "source symlink rejected" (case rejected of Left UnsafeSource {} -> True; _ -> False)
+  putStrLn "Workspace isolation, transactional edits, patch and recovery checks passed"
