diff --git a/docs/contents.md b/docs/contents.md
index 06c131482..7d6cba2d6 100644
--- a/docs/contents.md
+++ b/docs/contents.md
@@ -18,6 +18,11 @@ operator, user, and contributor references are easier to find.
- [git-change-detection-helpers-design.md](git-change-detection-helpers-design.md):
Git change-detection and glob-matching contracts and verification guidance
for maintainers, reviewers, and manifest authors.
+- [UX and semantic design](netsuke-test-framework-ux-design.md): Test
+ dialect, mocking model, and command surface for the Netsukefile testing
+ framework.
+- [Technical design](netsuke-test-framework-technical-design.md):
+ Implementation architecture for the Netsukefile testing framework.
- [roadmap.md](roadmap.md): Phased implementation plan and tracked delivery
work.
- [archive/roadmap-completed-foundations.md](archive/roadmap-completed-foundations.md):
@@ -32,6 +37,9 @@ operator, user, and contributor references are easier to find.
- [rfcs/0001-structured-command-blocks.md](rfcs/0001-structured-command-blocks.md):
Proposed structured command blocks, shell-free argv templates, typed Jinja
interpolation, stream routing, and pipeline semantics.
+- [rfcs/0007-netsukefile-testing-framework.md](rfcs/0007-netsukefile-testing-framework.md):
+ Proposed Netsukefile testing framework: the `netsuke test` command, the YAML
+ test dialect, and its mocking model.
## Decision records
diff --git a/docs/netsuke-test-framework-technical-design.md b/docs/netsuke-test-framework-technical-design.md
new file mode 100644
index 000000000..02227741a
--- /dev/null
+++ b/docs/netsuke-test-framework-technical-design.md
@@ -0,0 +1,976 @@
+# Netsuke test framework technical design
+
+## Front matter
+
+- **Status:** Draft.
+- **Scope:** The implementation architecture of the Netsukefile testing
+ framework: pipeline integration, injection seams, the test-suite parser, the
+ mock engine, the fixture engine, command-line integration, and the
+ verification obligations the implementation must discharge. The user-facing
+ dialect is normative in the companion
+ [UX and semantic design](netsuke-test-framework-ux-design.md); this document
+ does not restate its semantics except where implementation detail depends on
+ them.
+- **Primary audience:** Netsuke developers implementing `netsuke test`, and
+ reviewers assessing the architecture.
+- **Governing documents:**
+ [RFC 0007](rfcs/0007-netsukefile-testing-framework.md); [netsuke-design.md](netsuke-design.md)
+ for the compiler pipeline; [ADR-008](adr-008-environment-seam-taxonomy.md)
+ for environment seams;
+ [ADR-010](adr-010-scope-glob-capability-to-literal-prefix.md) for filesystem
+ capability scoping. Accepted ADRs take precedence over this document.
+
+## 1. Constraints
+
+Non-negotiable constraints the rest of the document assumes:
+
+- **C1 — one compiler.** `netsuke test` calls the same public library
+ functions as the build path: the manifest loader, `BuildGraph` lowering, and
+ `ninja_gen::generate`. The test runner adds overlays; it never re-implements
+ manifest semantics.
+- **C2 — no ambient mutation.** The runner must not mutate the process
+ environment, the project root, or global state. All test I/O happens inside
+ per-case `cap-std` sandboxes; all environment values flow through injected
+ readers per ADR-008.
+- **C3 — no execution.** No action in the first version spawns Ninja, build
+ commands, or fixture shell commands. The stdlib command helpers are disabled
+ under test (§5.5). When the deferred `execute` action does arrive it should
+ drive `NinjaProcessOptions` — the narrow execution type carrying working
+ directory, job count, and stderr suppression — rather than fabricating a
+ `Cli`, since that decoupling exists precisely so non-CLI callers can run
+ Ninja.
+- **C4 — deterministic by default.** Clock, network, and environment are
+ test-controlled. An unmocked impure call is an error, not a silent
+ passthrough.
+- **C5 — localized, stream-pure output.** User-facing strings go through
+ the Fluent localization layer; `--json` obeys the one-document stream
+ contract that the wider CLI roadmap mandates.
+
+## 2. The gap this closes
+
+Netsukefiles carry logic — `foreach` expansion, `when` conditions, macros,
+environment probes, globbing, executable discovery — and nothing verifies it.
+An author changes a `when` condition and learns whether it still matches by
+running a build and reading the output. That check is slow, answers differently
+on different machines, and cannot express the cases that matter most: a target
+that must _not_ be generated, or a manifest that must fail with a particular
+diagnostic.
+
+The pipeline is already shaped to close this. Manifest loading is staged and
+injectable, `BuildGraph::from_manifest` and `ninja_gen::generate` are public
+functions over plain data, and `netsuke help targets` has already established a
+restricted load mode. What is missing is a way to drive that pipeline with
+substituted seams and assert on what comes out. The architecture below adds
+exactly that, and nothing else: no second evaluator, no re-implementation of
+manifest semantics.
+
+## 3. Architecture summary
+
+The feature adds one new subsystem, `src/testing/`, plus narrow seams in
+existing modules. The runner parses test files into a test-suite AST, then
+executes each case in a child process that builds a per-case `TestContext`
+(sandbox, doubles, environment, clock), drives pipeline actions through the
+existing manifest/IR/Ninja code with an overlay-carrying options structure, and
+evaluates assertions against structured result views. The parent supervises
+those children and renders the report.
+
+The process boundary exists to make `--timeout` enforceable (§9.1); it is not a
+second evaluator. The diagram below shows the flow for one case, with the
+parent/child split marked: discovery and parsing happen once in the parent,
+case execution happens in a killable child, and the `CaseResult` returns over a
+versioned frame protocol for reporting.
+
+```mermaid
+graph TD
+ subgraph parent["parent process"]
+ A["netsuke test (CLI)"] --> B[discovery]
+ B --> C[test-suite parser]
+ C --> D["scheduler owner
queue, fail-fast, report"]
+ D --> S["case supervisor
deadline, kill, reap"]
+ D --> N["report renderer
human / JSON"]
+ end
+ S -->|spawn| E
+ subgraph child["child process (one case)"]
+ E[TestContext
sandbox, doubles, env, clock]
+ E --> F[fixture engine]
+ E --> G[pipeline actions]
+ G --> H["manifest loader
(with overlays)"]
+ H --> I[BuildGraph lowering]
+ I --> J[ninja_gen]
+ H --> K[mock journal]
+ G --> L[result views]
+ L --> M[assertion evaluator]
+ K --> M
+ end
+ M -->|CaseResult frames| D
+ S -->|timeout: synthesized error| D
+```
+
+_Figure 1: Test case execution flow across the process boundary. Existing
+compiler components (manifest loader, BuildGraph, ninja_gen) are reused
+unchanged apart from overlay injection at environment-construction time. The
+supervisor enforces the deadline and, on expiry, supplies a synthesized errored
+result in place of the child's. Every result returns to the scheduler owner,
+which alone holds scheduling state and writes the report._
+
+## 4. Pipeline integration
+
+### 4.1. The pipeline today
+
+The loader driver `from_str_named` (`src/manifest/mod.rs:109`) runs six stages:
+read, `serde_saphyr` parse into a JSON value tree, MiniJinja environment
+construction (strict undefined; `env()` and `glob()` registered from an injected
+`EnvReader` and the glob expander; stdlib registered according to the selected
+`StdlibRegistration`; manifest `vars` exposed as globals), macro registration
+plus `expand_foreach` (which also evaluates `when`), `serde_json::from_value`
+deserialization into `NetsukeManifest`, and `render_manifest` string rendering.
+The fullest-parameterized entry point is `from_path_with_policy_and_env`
+(`src/manifest/mod.rs:385`), which already accepts a `NetworkPolicy`, an
+`EnvReader`, and a coarse `ManifestLoadStage` callback.
+
+Two ordering facts drive the design. First, `foreach` and `when` evaluate
+against the raw value tree _before_ typed deserialization, so doubles for
+`glob`, `env`, and friends must be installed in the environment before
+`expand_foreach` runs. Second, manifest macros are registered before expansion
+(`register_manifest_macros`, `src/manifest/jinja_macros/mod.rs`), so macro
+substitution is an overlay registered _after_ manifest macros and _before_
+expansion.
+
+### 4.2. The restricted-load precedent
+
+`netsuke help targets` established the pattern this framework extends. The
+loader already selects its standard-library boundary through an enum
+(`StdlibRegistration`, `src/manifest/mod.rs:99`) with two variants:
+`Full(Box)` for builds, and `ManifestQuery` for side-effect-free
+discovery. `src/manifest/query.rs` owns that boundary, and
+`register_manifest_query` (`src/stdlib/register.rs:135`) implements it by
+registering the pure helpers and replacing `env`, `glob`, `fetch`, `shell`,
+`grep`, and `contents` with stubs that raise a located diagnostic naming the
+unavailable operation.
+
+The test runner is a third load mode of exactly this shape, so it extends the
+existing enum rather than introducing a parallel mechanism:
+
+```rust
+enum StdlibRegistration {
+ Full(Box),
+ ManifestQuery,
+ Test(Box), // sandbox-rooted; impure helpers refuse
+}
+```
+
+Three consequences follow, each replacing machinery this design would otherwise
+have invented:
+
+- The disabled-helper diagnostic already exists.
+ `manifest_query_operation_error` (`src/stdlib/register.rs:262`) is the
+ template for the "unavailable under test" messages in §5.5; the test mode
+ reuses the mechanism with its own message keys rather than intercepting
+ MiniJinja's unknown-function error.
+- `disabled_env_reader` (`src/manifest/env_reader.rs:79`) already provides
+ a reader that refuses every lookup. The test reader is that reader with the
+ case's declared variables layered over it (§5.1).
+- `src/manifest/query.rs` is the precedent module for a capability-scoped
+ non-build load, and the test runner's loader entry belongs beside it rather
+ than in a new location.
+
+### 4.3. Loader options
+
+The loader gains an options-carrying entry point; the existing entry points
+become thin wrappers over it with default options.
+
+```rust
+pub struct ManifestLoadOptions<'a> {
+ pub registration: StdlibRegistration,
+ pub env_reader: Option,
+ pub overlays: Option,
+ pub on_stage: Option<&'a mut dyn FnMut(ManifestLoadStage)>,
+}
+
+/// Test-supplied substitutions applied to the MiniJinja environment
+/// after stdlib and manifest-macro registration, before foreach expansion.
+pub struct TemplateOverlays {
+ pub functions: IndexMap,
+ pub macro_substitutions: IndexMap,
+}
+```
+
+`registration` carries the stdlib boundary rather than a bare `StdlibConfig`
+plus a separate `NetworkPolicy`, because §4.2 already binds those together per
+mode: the network policy for a test load is a property of
+`StdlibRegistration::Test`, not an independently settable knob. This keeps one
+place where a load mode's capabilities are decided. `Test` boxes its payload
+like `Full`, matching the existing constructors in `parse_with_config.rs` and
+`query.rs` and keeping the variants near enough in size that the enum stays
+cheap to move.
+
+The structure is named `TemplateOverlays`, not `EnvOverlays`: in this codebase
+"env" means the process environment (ADR-008, `EnvReader`), and these overlays
+substitute callables in the MiniJinja _template_ environment. The clock
+deliberately does not appear here — it has exactly one owner, `StdlibConfig`
+(§5.2). `on_stage` keeps the existing `&mut dyn FnMut` shape from
+`from_path_with_policy_and_env`.
+
+`OverlayCallable` wraps a double's dispatch closure (§7). Registration order
+inside environment construction becomes:
+
+1. `env()` and `glob()` from the effective `EnvReader` and glob expander;
+2. stdlib via `register_with_config` (with the test's `StdlibConfig`);
+3. manifest `vars` globals;
+4. manifest macros;
+5. **overlays** — test doubles registered last so they shadow same-named
+ stdlib functions (MiniJinja `add_function` replaces an existing
+ registration), plus macro substitutions, which additionally rewrite the
+ macro-import prelude (§5.4);
+6. `expand_foreach`, deserialization, rendering as today.
+
+The overlay hook is compiled unconditionally: it is an ordinary parameter, not
+a test-only `cfg`, because the test runner is a production code path of the
+shipped binary.
+
+### 4.4. Result views
+
+`NetsukeManifest` already derives `Serialize` (`src/ast/mod.rs:102`), and
+`GraphView` (`src/graph_view/`) is an existing deterministic projection of
+`BuildGraph` with sorted nodes and edges. The assertion layer builds on both:
+
+- `result.manifest` — the rendered manifest serialized to a MiniJinja
+ value.
+- `result.graph` — a `TestGraphView` wrapping `GraphView` with the helper
+ methods the UX design promises (`has_target`, `has_rule`, `target(name)`
+ field access), exposed as a MiniJinja object.
+- `result.ninja` — the string from `ninja_gen::generate`
+ (`src/ninja_gen/mod.rs:106`), which is already deterministic for snapshot
+ tests.
+
+The IR types themselves are not exposed: the views are a stable assertion
+surface that can hold shape while internal IR evolves.
+
+## 5. Injection seams
+
+Each seam follows the ADR-008 taxonomy; two exist, two are new.
+
+### 5.1. Environment (existing)
+
+`EnvReader` (`src/manifest/env_reader.rs:56`) is an
+`Arc Result + Send + Sync>`. The runner
+builds one from the case's `given.env` map: declared names return their values,
+`unset` names and everything else return `EnvReadError::NotPresent`. The host
+environment is reachable only through an explicit future opt-in; the default
+reader never consults it (C2, C4).
+
+### 5.2. Clock (new seam)
+
+`now()` currently calls `OffsetDateTime::now_utc()` directly
+(`src/stdlib/time/mod.rs:62`) — a gap relative to ADR-008. The stdlib time
+module gains a clock provider in the `EnvReader` shape (an `Arc` closure,
+because MiniJinja registration requires `Send + Sync`):
+
+```rust
+pub type ClockProvider = Arc OffsetDateTime + Send + Sync>;
+```
+
+Production registration wraps `OffsetDateTime::now_utc`; the test runner
+supplies a fixed instant parsed from `given.clock.now`. The seam lives in
+`StdlibConfig` alongside the existing `path_override` and `home_directory`
+knobs — the clock's single owner — and is a prerequisite refactor deliverable
+in its own right.
+
+### 5.3. Network (policy, not transport)
+
+`fetch()` builds its HTTP agent inline (`src/stdlib/network/mod.rs`), so
+transport injection would be invasive. The test runner does not need it: a test
+that wants `fetch` results declares a double for the `fetch` function itself,
+and overlays register after the stdlib (§4.3), so the double shadows the
+refusing stub. The real network code is therefore unreachable under test —
+either the overlay answers the call, or the refusing stub raises a diagnostic
+telling the author to declare one. No transport seam is built, and no live
+agent is ever constructed.
+
+### 5.4. Macro substitution (new mechanism)
+
+`substitute("stand_in")` compiles the stand-in macro from the test file through
+the same `register_macro` path as manifest macros, then installs a journalling
+wrapper that records the call and delegates to the compiled stand-in. Signature
+arity is validated when the macro is called, as with ordinary manifest macros;
+earlier validation is a possible refinement, not a first-version requirement.
+
+Installing that wrapper takes more than `add_function`. `register_macro` does
+two things per macro: it adds a global function, _and_ it appends
+`{% from '' import %}` to a `MACRO_IMPORTS_GLOBAL` string that
+`render_template` (`src/manifest/jinja_macros/mod.rs`) prepends to every
+template it renders. An imported macro binds a template-local name, and
+template-local names resolve ahead of environment globals — so a same-named
+`add_function` overlay is shadowed at render time, and a substituted
+`compile_cmd` would still run the original macro while journalling nothing.
+
+Macro substitution therefore edits the prelude as well as the global: for each
+substituted name the overlay removes that name's entry from
+`MACRO_IMPORTS_GLOBAL` and re-adds an import bound to the stand-in's compiled
+template. Both halves are one operation and must stay together; the phase-1
+spike (§14) covers the prelude rewrite, not merely `add_function` replacement,
+because the prelude is the half that actually decides which macro renders.
+
+### 5.5. Stdlib configuration under test
+
+The runner constructs a per-case `StdlibConfig` (`src/stdlib/config/mod.rs:21`)
+rooted at the sandbox:
+
+| Knob | Test value |
+| ----------------------------------- | -------------------------------------------- |
+| `workspace_root` | the case sandbox directory (`cap-std` `Dir`) |
+| `network_policy` | deny all |
+| `path_override`, `pathext_override` | empty unless the case stubs `which` |
+| `home_directory` | `HomeDirectory::Missing` |
+| command helpers | registered as refusing stubs (C3) |
+
+_Table 1: Per-case stdlib configuration._
+
+The command helpers, `fetch`, and the other impure entry points are registered
+as stubs that refuse, following `register_manifest_query` (§4.2) rather than
+being left unregistered. Refusing stubs beat omission: under strict-undefined
+MiniJinja an absent function yields a generic unknown-function error, whereas a
+registered stub raises a located diagnostic that names the operation and points
+at the double syntax. The test mode supplies its own message keys through the
+existing `manifest_query_operation_error` shape.
+
+`workspace_root` alone does not achieve that scoping, and the design must not
+pretend otherwise. Two filesystem helpers bypass it today:
+
+- `glob()` is registered in `src/manifest/mod.rs` over
+ `glob::expand_glob(&pattern)`, which takes no workspace root. Its matcher
+ traverses ambiently, and the capability it opens is rooted at the pattern's
+ literal prefix — `.` for a relative pattern, meaning the process working
+ directory. ADR-010 records the ambient traversal as an accepted limitation of
+ the build path.
+- File tests (`dir`, `file`, `symlink`, and the rest) reach
+ `path::file_type_matches`, whose `parent_dir` helper calls
+ `Dir::open_ambient_dir(.., ambient_authority())`. `register_file_tests` never
+ receives `StdlibConfig` at all.
+
+Left unaddressed, a fixture-built project would be globbed from the runner's
+working directory instead of its own sandbox, which is both host-dependent and
+the exact non-determinism this framework exists to remove. The test
+registration therefore supplies sandbox-rooted adapters for both helpers:
+`glob()` resolves relative patterns against the case sandbox `Dir`, and the
+file tests resolve their paths through the same handle, rejecting escapes
+rather than falling back to ambient authority. These adapters are test-mode
+components, not changes to the build path's behaviour, so ADR-010's accepted
+limitation stands for builds while tests get the stronger guarantee they
+require.
+
+With those adapters in place the visibility consequence is worth stating
+plainly: the project tree is _not_ visible to a manifest under test. A
+default-subject test sees only the files its fixtures and `given.fs` created.
+Invariant I4 (§12) is scoped accordingly, and invariant I5's
+no-ambient-filesystem claim depends on these adapters existing.
+
+## 6. Test-suite AST and parser
+
+A separate AST in `src/testing/ast.rs`; `NetsukeManifest` is not stretched.
+
+```rust
+pub struct TestFile {
+ pub netsuke_test_version: DialectVersion, // MAJOR.MINOR, not semver
+ pub imports: Vec,
+ pub vars: IndexMap,
+ pub macros: Vec, // reused from src/ast/mod.rs
+ pub fixtures: IndexMap,
+ pub cases: IndexMap, // test_* keys, document order
+}
+```
+
+Parsing partitions top-level keys: the five known keys deserialize into typed
+structures with `deny_unknown_fields`; keys matching `test_[A-Za-z0-9_]+`
+deserialize as `TestCase`; anything else is a diagnostic with a
+nearest-known-key suggestion. `IndexMap` preserves declaration order for cases,
+`let` bindings, and mock entries, because the dialect's semantics depend on
+document order.
+
+Discovery reads the manifest's `tests` block by raw extraction of the top-level
+`tests` key from the `serde_saphyr` value tree, with no template evaluation —
+so discovery works before the subject's template environment exists, and
+succeeds even when the subject manifest does not load. Imports form a directed
+acyclic graph: a cycle is a suite error naming the cycle, and resolution depth
+is capped. Support files parse once per run into shared immutable declarations,
+not once per case.
+
+Validation at parse time: duplicate keys (already fatal in `serde_saphyr`), at
+least one step per case, at least one of `given`/`when`/`then` per step,
+fixture references resolve, imports stay inside the test tree, expression
+fields reject `{{`-style template syntax (the UX design's expression/template
+split), and matcher objects use only the closed vocabulary.
+
+The `tests` block on the manifest side is a new optional field on
+`NetsukeManifest` with `deny_unknown_fields` semantics preserved; compatibility
+consequences are covered in RFC 0007.
+
+## 7. Mock engine
+
+`src/testing/mocks.rs` owns double state:
+
+```rust
+pub struct DoubleRegistry {
+ doubles: IndexMap,
+ journal: Journal,
+}
+
+pub struct Double {
+ pub kind: DoubleKind, // Stub | Mock | Spy
+ pub ordered: bool,
+ pub lenient: bool,
+ pub entries: Vec, // first-match-wins
+}
+
+pub struct CallEntry {
+ pub matchers: Vec,
+ pub response: Response, // Returns(Value) | Raises(..)
+ pub times: Option,
+ pub matched: Cell,
+}
+```
+
+Dispatch acquires the registry lock, appends the invocation to the journal,
+selects a response, and _releases the lock before producing it_. Selection scans
+`entries` for the first matcher-accepting entry with remaining `times` budget.
+Ordering changes both which entries are eligible and what a mismatch means:
+
+- Unordered (the default) considers every entry in declaration order and
+ takes the first that accepts the arguments and has budget left.
+- `ordered: true` considers only the next unconsumed entry. An entry
+ without `times` is never consumed, so it stays the candidate for every
+ subsequent call and later entries remain unreachable behind it — an unbounded
+ entry in an ordered double is therefore a terminal entry, and the parser
+ warns when one precedes another. An entry with `times: N` is consumed after
+ its Nth match, and the next entry becomes the candidate.
+
+A mismatch never silently skips ahead in an ordered double: if the candidate
+entry rejects the arguments, dispatch fails rather than searching later
+entries, because declaration order is precisely what the author asked to pin.
+Unordered doubles fall through to later entries and fail only when none
+accepts. Tests cover repeated calls against an unbounded entry, an unbounded
+entry followed by another, and a mismatch under both ordering modes. A `Mock`
+with no accepting entry yields a MiniJinja error carrying a structured payload,
+which the runner converts into the unmatched-call report with its suggested
+YAML stanza. A `Stub` falls back to its `default` or `Undefined`. A `Spy`
+resolves to a handle the runner captured when it constructed the effective
+callable — the runner holds these handles itself rather than retrieving
+previously registered functions from the MiniJinja environment, which offers no
+such retrieval.
+
+Releasing the lock before invoking a spy's delegate is load-bearing, not an
+optimization. A spied callable may call another double — a spied macro whose
+body calls a mocked `glob` is the ordinary case — and that nested dispatch
+re-enters the same registry. Holding the lock across the delegate would
+deadlock the case. Dispatch therefore returns a resolved action while unlocked,
+and the nested call takes the lock in its own turn. A test covering a spy that
+invokes a second double guards this.
+
+Journal entries record the arguments and the origin of the response. Where a
+`CallEntry` answered the call, that origin is a `(double, entry_index)` pair
+rather than a Rust reference or a cloned response value: a borrowed
+`&CallEntry` would make the journal self-referential within `DoubleRegistry`,
+whereas the index pair is stable across the case, keeps response-value
+deduplication a separate concern (the value still lives once in its
+`CallEntry`), and survives the registry being locked and unlocked around each
+dispatch.
+
+Not every response comes from an entry, so the origin is an enum rather than a
+bare pair. A `Stub` that matched nothing answers from its `default` or with
+`Undefined`, and a `Spy` answers from the captured real callable; neither has
+an entry index to name. The journal therefore records
+`ResponseOrigin::Entry { double, entry_index }`, `::StubDefault`, or
+`::Delegate`, and every call appends exactly one entry whichever branch
+answered it. Routing all three through the same append keeps `call_count` and
+the ordered call list complete — an assertion on a spied or defaulted call
+would otherwise silently see nothing — and gives the serialized form an
+explicit discriminant instead of a sentinel index. The per-double journal
+ceiling from the UX design is enforced at append time; breaching it turns the
+case into an error naming the double.
+
+Registry state is per case and lives behind an `Arc>` captured by the
+overlay closures; nothing is process-global, so parallel cases cannot observe
+each other (invariant I1, §12). Overlay dispatch runs under `catch_unwind`: a
+panic inside matcher evaluation or value conversion becomes a case error, and
+the registry lock recovers from poisoning so end-of-case verification can still
+report the journalled calls.
+
+End-of-case verification walks the registry. A `Mock` entry that never matched
+a call is an unmet expectation and fails the case. `times` plays no part in
+that judgement: it is a maximum-call budget, so an entry declared `times: 3`
+that matched once or twice is satisfied, and only an entry that matched zero
+times is unmet. Nothing fails merely because a budget went unspent. Doubles
+with zero journal entries raise the unnecessary-double warning unless `lenient`.
+
+Matcher evaluation is a closed enum (`Exact(Value)`, `Any`, `IsA(TypeName)`,
+`Regex(compiled)`, `Contains(Value)`, `StartsWith(String)`,
+`Not(Box)`) compiled at parse time so an invalid regex is a suite
+error, not a mid-run surprise. `Exact` backs both the bare-argument form and the
+`eq:` escape hatch; the parser lowers both to it.
+
+## 8. Fixture engine
+
+`src/testing/fixtures.rs` resolves the case's requested fixtures into a
+dependency graph (`uses` edges), topologically sorts it — a cycle is a suite
+error naming the cycle — and executes setup actions in order. A topological
+sort leaves independent fixtures mutually unordered, so the sort breaks ties
+deterministically rather than by hash iteration order. The tie-break applies
+only among _ready_ fixtures — those whose dependencies are already satisfied —
+so it never reorders a fixture ahead of something it uses: dependency
+precedence decides first, and the tie-break only chooses between candidates
+that are equally eligible. Among those, fixtures the step requested come first
+in request order, then fixtures pulled in solely as `uses` dependencies in
+declaration order within their file. Two fixtures that depend on nothing
+therefore always set up in the same sequence, and reverse-order teardown
+follows from the resulting setup order. A test with several independent
+fixtures asserts both the setup and the teardown sequence. Each case owns one
+sandbox: a temporary directory opened as a `cap-std` `Dir`, within which
+`tmpdir`, `mkdir`, `write`, `copy`, and `remove` operate by relative path.
+Absolute paths and `..` traversal are rejected at action-evaluation time,
+keeping fixtures inside the capability boundary that ADR-010 established for
+globbing.
+
+Subject-manifest paths pass through the same boundary before the loader sees
+them. `open_manifest_workspace` accepts any path and opens its parent with
+ambient authority, which is right for `netsuke build -f ` but too wide
+for a test: it would let a case read anything the invoking user can read. The
+test runner therefore resolves and validates each author-supplied subject path
+— the action's `manifest` argument, `given.subject`, and the case's `subject` —
+after template evaluation and before opening the workspace, rejecting absolute
+paths and any path escaping the case sandbox, including through symlinked
+components that already exist. The enclosing project's Netsukefile is the one
+approved path outside the sandbox, admitted read-only. This is a test-mode
+restriction layered above the shared loader, not a change to the build path's
+behaviour. Fixture `env` actions write into the case-level environment map
+owned by `TestContext`, before `given.env` is applied over them.
+
+All case sandboxes live under one per-run root named
+`netsuke-test--`. The runner installs an interrupt handler that
+stops scheduling, terminates and reaps live children, tears down completed
+fixtures, removes the run root unless `--keep`, and exits with the interrupted
+code. Every selected case still reaches the report exactly once (I9), which
+requires assigning a status to two groups the normal path never produces: a
+case whose child the parent terminated is errored, with an interruption
+diagnostic and whatever journal arrived before the signal; a case that never
+started is skipped. The scheduler owner applies the same sorted file and
+declaration ordering it uses for a completed run before rendering, so an
+interrupted run's report is deterministic rather than ordered by how far the
+run happened to get. An interruption report test covers a run containing all
+three groups — finished, terminated, and never started. A SIGKILL of the parent
+still a SIGKILL still leaks, so the recognizable naming scheme plus age-based
+reaping of stale run roots at the start of the next run makes leaks
+self-healing. A case whose sandbox cannot be provisioned is errored and
+isolated; only failure to create the run root itself aborts the run.
+
+Teardown obligations (UX design §9) are implemented with a completion stack:
+each fixture pushes onto the stack only after its setup finishes, and case
+cleanup pops the stack unconditionally — after assertion failures, action
+errors, and fixture-setup failures alike. A teardown error does not stop the
+unwind: remaining entries still pop, the errors aggregate into the report, and
+the sandbox is retained. `--keep` skips sandbox deletion for failing cases and
+prints the retained path.
+
+Fixture `exports` are Jinja templates evaluated against the fixture's local
+bindings (for example the `tmpdir` name) and exposed to `let` and assertions as
+`fixtures..`.
+
+## 9. Actions and the case runner
+
+`src/testing/actions.rs` implements the three pipeline actions as thin
+compositions of public library functions:
+
+- `load_manifest` → `manifest::from_str_named`-equivalent entry with
+ `ManifestLoadOptions` (§4.3);
+- `build_graph` → `BuildGraph::from_manifest`
+ (`src/ir/from_manifest.rs:49`) over the loaded manifest;
+- `generate_ninja` → `ninja_gen::generate` over the built graph.
+
+`BuildGraph::from_manifest` lowers path placeholders for
+`RecipeShell::host_default()`, so quoting in the resulting command text differs
+between a POSIX host and a Windows one. The test runner keeps that default
+rather than pinning a shell: invariant I4 requires a case to lower exactly as
+`netsuke build` would on the same machine, and pinning would break that on
+Windows. The consequence belongs in the dialect's contract rather than in a
+footnote — an assertion over raw `result.ninja` text containing quoted paths is
+host-sensitive, whereas the graph view's structural helpers are not. Authors
+testing recipe text across platforms should assert through the view, and a
+future dialect addition may expose `from_manifest_for_shell` so a case can pin
+a shell deliberately.
+
+Within a step, the actions share one pipeline pass: `build_graph` and
+`generate_ninja` extend the step's memoized artefacts rather than re-running
+the loader, which keeps a three-action `when` at one template evaluation and
+makes journal counts independent of the action list (the UX design §10 makes
+this the observable contract). A later step's action starts a fresh pass.
+
+Each action produces an `ActionResult { action, ok, views, error }`. The case
+runner keeps them in a `results: Vec` that accumulates across the
+whole case in execution order, never resetting between steps. `result` is sugar
+for the last element. Assertions index the history positionally — `results[0]`,
+`results | length` — so a step that runs `load_manifest` then `generate_ninja`
+can compare the two stages, and a later step can still read an earlier step's
+outcome. Indices are stable because actions only ever append.
+
+An action failure with `expect_failure` in the following `then` is a normal
+comparison; without it, the failure fails the case at the point of the first
+assertion that needs a missing view (or immediately when the step has no
+`then`).
+
+The scheduler runs cases in parallel up to `--jobs` (defaulting to available
+parallelism), one case per worker. Every piece of mutable scheduling state has
+exactly one owner, the _scheduler owner_: the pending-case queue, fail-fast
+state, which cases are claimed, and the conversion of unclaimed cases into
+skipped results. It is also the sole writer of the report. Nothing else in the
+run may read or modify that state.
+
+Workers own no scheduling state. A worker executes only the case the scheduler
+owner assigns it, supervises one child at a time (§9.1), and sends back a
+finished, immutable `CaseResult` — whether the child produced it or the
+supervisor synthesized it after a timeout. Workers never test fail-fast state
+and never claim a case for themselves.
+
+That single ownership is what makes the concurrency tractable. Results arrive
+from many workers at once, but the scheduler owner processes each one in a
+single ordered state transition:
+
+1. Receive a `CaseResult` from a worker.
+2. Classify it as passed, failed, or errored.
+3. If `--fail-fast` is in force and this is the first failed or errored result,
+ activate fail-fast.
+4. If fail-fast is not active, assign the next pending case to the now-idle
+ worker.
+5. Otherwise mark every remaining unclaimed case as skipped.
+
+Because steps 3 and 4 happen inside one transition, no assignment can slip
+between observing a failure and acting on it: the ordering is a property of the
+transition, not of timing. Cases already in flight are untouched and run to
+completion, so their teardown and journal handling are unaffected; only
+unclaimed cases become skipped, which keeps the report totalling the full
+selection (I9).
+
+Sole ownership of the report has the same effect on output. The scheduler owner
+buffers results and restores sorted file order, then declaration order within
+each file, before rendering, so cases may finish in any order while human and
+JSON output stay byte-stable. A test that completes cases deliberately out of
+order and diffs both renderings guards this.
+
+Case execution runs under `catch_unwind` inside the child; a panic that escapes
+it becomes an abnormal child exit, which the supervisor records as an errored
+case. Either way the worker survives and every selected case reaches the report
+(invariant I9). Process isolation strengthens this: a child that aborts
+outright can no longer take the run down with it.
+
+### 9.1. Enforcing the per-case timeout
+
+`--timeout` is a hard per-case wall-clock bound, not a best-effort one. Making
+it hard requires a cancellation boundary, because MiniJinja evaluation is not
+preemptible: a single large loop inside one target field can run indefinitely
+inside one `render_template` call, yielding to no checkpoint the runner
+controls. Cooperative deadline checks alone cannot bound that, so each case
+executes in an independently killable child process.
+
+The split is deliberately narrow. The parent keeps discovery, parsing,
+scheduling, timeout enforcement, child reaping, and all report rendering. The
+child executes exactly one case — fixtures, overlays, pipeline actions,
+assertions — and returns a `CaseResult`. Nothing about the compiler pipeline
+changes: the child runs the same loader, IR builder, and generator as before
+(C1), so this is an execution boundary, not a second evaluator.
+
+Timeout behaviour in the parent:
+
+1. Start the deadline before spawning, so spawn cost counts against the
+ case rather than being free.
+2. Wait with `wait_timeout`, exactly as `src/stdlib/command/execution.rs`
+ already bounds stdlib command helpers.
+3. On expiry, kill the child and reap it. `std::process::Child::kill` maps
+ to `SIGKILL` on Unix and `TerminateProcess` on Windows, so termination needs
+ no platform-specific code; CI covers both (`ubuntu-latest` and
+ `windows-latest`).
+4. Collect a partial result if the protocol delivered one complete frame
+ before termination; otherwise synthesize an errored `CaseResult` carrying a
+ timeout diagnostic.
+5. Attach whatever mock journal arrived, so a timed-out case still shows
+ which doubles it reached.
+
+The child streams to the parent over its stdout pipe using length-prefixed
+`serde_json` frames, versioned like the existing `json_envelope`
+(`SCHEMA_VERSION`) so parent and child cannot silently disagree. `serde_json`
+is already a dependency; no IPC crate, socket, or named pipe is introduced.
+Frames are bounded — a journal ceiling breach truncates rather than streaming
+without limit — and the parent treats a truncated final frame as "no complete
+result", falling to step 4.
+
+The stream carries more than one final result, because a terminated case would
+otherwise report an empty journal however much it had done. The child emits an
+incremental journal frame as calls accumulate, so the parent holds a checkpoint
+of everything recorded before the signal arrived. Step 5's "whatever mock
+journal arrived" is exactly this: the last checkpoint, not a best guess.
+Checkpoint frames are the same versioned envelope as the result frame and are
+equally bounded, and the parent discards them once a complete `CaseResult`
+supersedes them. A deterministic test kills a case after a known journal entry
+and asserts the parent's report contains that entry. The child's stderr is
+captured and folded into the case's diagnostics rather than leaking into the
+parent's stream, preserving stream purity (I8).
+
+Cooperative checkpoints remain, and are now the graceful path rather than the
+only one. The deadline is still checked at overlay dispatch, loader stage
+callbacks, each fixture setup and teardown action, between pipeline actions,
+and between assertions; macro depth and `foreach` expansion ceilings still
+apply. A case that reaches a checkpoint reports itself cleanly with a full
+journal and completes its own teardown. Termination is the backstop for the
+case that never reaches one. The design does not claim that a blocked
+in-process action is interrupted where it stands: it claims the process running
+it is killed.
+
+Cleanup ownership follows the same split:
+
+| Situation | Teardown performed by |
+| --------------------- | --------------------------------------------- |
+| Normal completion | child, before sending its result |
+| Cooperative timeout | child, after marking the case errored |
+| Forced termination | parent, over the case sandbox |
+| Child panic | parent, after observing abnormal exit |
+| Interruption (Ctrl-C) | parent, for every live child and the run root |
+
+_Table 2: Fixture teardown ownership._
+
+The parent does not replay per-fixture teardown for a child it killed, and so
+needs no hand-off of the completed-fixture list. It does not need one because
+every effect a fixture has is either inside the case sandbox or inside the
+child: filesystem actions write within the sandbox, and `env` actions mutate
+only the child's in-process map, which dies with it. Removing the sandbox
+therefore _is_ complete teardown for a terminated case, and it is idempotent —
+safe whether the child had torn down nothing, some, or everything before it
+died.
+
+This scopes I2 precisely. Reverse-order, exactly-once teardown is a guarantee
+about teardown the child performs; parent-side cleanup after forced termination
+is sandbox-level and makes no ordering claim, because there is no surviving
+ordered state to unwind. Termination-during-setup tests cover both hazards this
+creates: a fixture killed mid-setup, whose partial artefacts must still
+disappear, and a fixture whose teardown the child had already completed, where
+the parent's removal must not double- apply or error.
+
+Case sandboxes stay under the existing per-run root, so the parent can always
+finish cleanup a dead child left undone. A timed-out case retains its sandbox
+for inspection, on the same terms as `--keep`, and the path is printed. The
+parent reaps every child it spawns, including on the interrupt path, so no
+zombies survive the run.
+
+## 10. CLI integration
+
+`src/cli/parser.rs` gains `Commands::Test(TestArgs)` with the flags from the UX
+design §12. Like `GraphArgs`, the purely per-invocation flags are
+`#[serde(skip)]`ed out of OrthoConfig layering; candidates for config-file
+defaults (`jobs`, display policy) follow the existing precedence rules.
+`src/runner/dispatch.rs` routes the variant to `testing::run`, which owns
+discovery, scheduling, and process exit-code mapping (0, 1, 2, 3, and 130 per
+the UX design). Interruption keeps its own exit result and is not folded into
+the internal-runner-error class.
+
+New user-facing strings — report lines, diagnostics, warnings — get keys in
+`src/localization/keys.rs` and Fluent messages. The build-time completeness
+audit requires the keys in every registered locale (34 catalogues beyond
+`en-US`), so the string surface is a real delivery cost: keys are defined early
+in the phasing, not at the end, so translation lands in batches rather than as
+a release-blocking cliff. Two artefacts are deliberately locale-invariant
+machine output, not localized prose: the suggested YAML stanza for unmatched
+calls and the expression-with-values rendering in failure reports.
+
+## 11. Reporting
+
+`src/testing/report.rs` renders both formats from one `SuiteReport` structure.
+Human rendering streams per case through the design-token/display-policy
+machinery like other commands; only JSON buffers the whole run, emitting
+exactly one document carrying the `format_version` field (C5, UX design §13).
+`failed` and `errored` are distinct case states throughout, and each failure
+record carries the rendered expression-with-values text — truncated per the UX
+design's elision rule — so CI consumers get the same diagnostic a terminal user
+sees without unbounded report growth.
+
+## 12. Verification obligations
+
+Named invariants the implementation must discharge, with their verification
+methods. These are design commitments, not a test-type list.
+
+- **I1 — case isolation.** No double, journal entry, environment binding,
+ fixture export, or sandbox file from one case is observable from another,
+ including under `--jobs` parallelism. _Method:_ concurrent integration tests
+ that deliberately reuse double and fixture names across cases and assert
+ disjoint journals.
+- **I2 — teardown exactly once, reverse order.** Every fixture whose setup
+ completed tears down exactly once, in reverse setup order, on every exit path
+ the child controls: normal completion, assertion failure, action error,
+ fixture-setup failure, and cooperative timeout. Forced termination is
+ excluded by construction, because the child is gone and no ordered state
+ survives it; there the guarantee is weaker and stated separately —
+ parent-side cleanup deletes the case sandbox, which is idempotent and makes
+ no ordering claim (§9.1). _Method:_ `proptest` over randomly generated
+ fixture dependency graphs of at most 16 fixtures, with injected failures at
+ each lifecycle point, asserting the teardown sequence property; case counts
+ are bounded in the nextest profile so this suite cannot become the slowest
+ gate. Termination-during-setup tests cover the excluded path separately.
+- **I3 — strict mock determinism.** An unmatched call on a `Mock` fails
+ the action at the call site; the journal preserves call order, arguments, and
+ responses. _Method:_ unit tests per matcher and dispatch rule; `rstest`
+ parameterized cases over the matcher vocabulary.
+- **I4 — semantic fidelity.** For any manifest with no doubles declared
+ whose file observations are confined to the sandbox (fixture-provided files)
+ or fully doubled, `load_manifest`/`build_graph`/`generate_ninja` under
+ `netsuke test` produce results identical to the build path run over the same
+ tree. The scoping is forced by §5.5: the sandbox root means a manifest
+ observing the project tree legitimately differs under test. _Method:_
+ differential tests that run both paths over the example manifests inside one
+ tree and compare serialized outputs; `insta` snapshots of the generated Ninja.
+- **I5 — no build execution, no network, no ambient environment.** Under
+ test, no build command, Ninja invocation, or fixture shell command runs; no
+ socket is opened; and no host environment variable is read outside an
+ explicit opt-in. The per-case child process (§9.1) is the runner supervising
+ itself, not the manifest executing anything, and it inherits every
+ restriction in this list. _Method:_ the seams make these unrepresentable
+ (impure helpers registered as refusing stubs, deny-all policy, closed
+ `EnvReader`); negative tests assert every refusal diagnostic.
+- **I6 — schema strictness.** Unknown keys anywhere in the test dialect
+ fail with a located diagnostic. _Method:_ table-driven negative parser tests
+ covering each structure.
+- **I7 — build-path neutrality.** A manifest containing a `tests` block
+ behaves identically under `build`, `graph`, `generate`, and `clean` to the
+ same manifest without it. _Method:_ differential snapshot tests.
+- **I8 — report stream purity.** `--json` emits exactly one report
+ document on stdout with empty stderr whenever the run _completes_ — whether
+ every case passed, some failed or errored (exit 1), or the run was
+ interrupted (exit 130). Only a _command_ failure — invalid suite or selector,
+ zero selected cases without `--allow-empty` (exit 2), or an internal runner
+ error before a report can be assembled (exit 3) — suppresses the stdout
+ document and emits one diagnostic document on stderr instead. The distinction
+ matters because a completed run with failing cases is the primary thing
+ automation reads from stdout; emptying stdout there would defeat `--json`.
+ _Method:_ the existing stream-purity behavioural test pattern extended to
+ `test`, with cases for each exit class.
+- **I9 — conservation of cases.** Every selected case appears in the
+ report exactly once, as passed, failed, errored, or skipped — including under
+ child panic, forced termination, and interruption. _Method:_ scheduler tests
+ with injected panics and deadline breaches, asserting report totals against
+ the selection count. A deterministic scheduling test additionally drives
+ several results into the scheduler owner concurrently, including the first
+ failed result, and attempts a next-case assignment at the same moment. It
+ asserts that no case is assigned after the transition that recorded the first
+ fail-fast-triggering result, and that every unassigned case is reported as
+ skipped. Driving the transitions rather than sleeping keeps it reproducible.
+- **I10 — the timeout is enforced.** No case exceeds its deadline by more
+ than the termination and reaping window, whatever the manifest does.
+ _Method:_ a case whose manifest contains a deliberately non-cooperative
+ template expression — one large enough to run indefinitely inside a single
+ render with no checkpoint — asserting that the run terminates, the case is
+ errored with a timeout diagnostic, its partial journal survives, its sandbox
+ is retained, its fixtures are torn down, no child process outlives the run,
+ and `--json` still emits exactly one document. Cooperative expiry is covered
+ separately and deterministically through an injected clock: a fixture setup
+ action that passes a checkpoint after the deadline must error the case, keep
+ the partial journal, and tear down every fixture whose setup completed; a
+ teardown action that does the same must continue unwinding the remaining
+ stack, aggregate its errors, retain the sandbox, and report the case as
+ errored. Neither test depends on wall-clock delays.
+
+The combinatorial surface that carries the highest interaction risk is double
+kind × ordering × `times` × matcher type. I3's parameterized suite enumerates
+kind, ordering, and `times` exhaustively and pairs them with each matcher type
+individually. Full four-way enumeration is not run, but structural separation
+is not offered as the reason: matching and consumption meet in entry selection,
+so the interaction is real and the suite covers it directly with named cases —
+
+- a first entry whose `times` budget is exhausted, so a later entry with a
+ broader matcher must take the call;
+- an `ordered` double whose next entry rejects the arguments, pinning
+ whether selection falls through or fails;
+- a catch-all entry after a specific one, in both declaration orders,
+ confirming first-match-wins rather than best-match;
+- a `times`-bounded entry that is never exhausted, confirming a maximum
+ does not become a minimum (UX design §8.2).
+
+These are the cases where a bug would otherwise hide behind the independence
+claim.
+
+New `tests/*.rs` files land as real Cargo targets; the existing
+integration-test wiring contract test enforces this automatically.
+
+## 13. Module layout
+
+```plaintext
+src/testing/
+ mod.rs — public run() entry, suite orchestration
+ ast.rs — test-suite AST
+ discovery.rs — tests root resolution, include/exclude, imports
+ parser.rs — YAML parsing, test_* partitioning, validation
+ eval.rs — expression evaluation, expression/template split
+ context.rs — TestContext, sandbox, result views
+ fixtures.rs — fixture graph, setup/teardown
+ actions.rs — pipeline actions
+ supervisor.rs — child spawn, deadline, kill, reap
+ protocol.rs — versioned length-prefixed CaseResult frames
+ mocks.rs — DoubleRegistry, matchers, journal
+ assertions.rs — assertion normalization and evaluation
+ report.rs — SuiteReport, human and JSON rendering
+ errors.rs — thiserror diagnostics for the subsystem
+```
+
+Existing modules touched: `src/manifest/mod.rs` (options entry point,
+`StdlibRegistration::Test`, overlay registration), `src/manifest/query.rs`
+(test-mode loader entry beside the query entry), `src/stdlib/register.rs`
+(test-mode registration), `src/stdlib/time/` (clock seam), `src/stdlib/config/`
+(clock in `StdlibConfig`), `src/ast/mod.rs` (optional `tests` field),
+`src/cli/parser.rs` and `src/runner/dispatch.rs` (command wiring),
+`src/localization/keys.rs` (strings). Errors are semantic `thiserror` enums per
+module, composed into the runner's reporting. The supervisor reuses the
+`wait_timeout`-then-kill-then-reap pattern already proven in
+`src/stdlib/command/execution.rs`, and `protocol.rs` follows the
+`src/json_envelope.rs` versioning convention rather than inventing its own. The
+subsystem follows whichever diagnostic direction (miette versus anyhow) the
+in-flight migration settles on, and must not add new dependencies on the
+deprecated path.
+
+## 14. Phasing
+
+The minimum viable implementation, in dependency order:
+
+1. Overlay spike, clock seam, and `ManifestLoadOptions` refactor (no
+ behaviour change; I4/I7 differential tests land here). The spike comes first
+ because it gates the overlay architecture: it pins MiniJinja `add_function`
+ replacement semantics for shadowing, and the runner-side handle capture that
+ spies depend on (§7). If shadowing fails, the fallback is filtering the
+ macro out of `register_manifest_macros` via `TemplateOverlays`, which the
+ options structure already permits.
+2. Test-suite AST, parser, and discovery (I6). Localization keys for the
+ dialect's diagnostics are defined from this phase onwards (§10).
+3. Mock engine and overlays for functions and macro substitution (I1, I3).
+4. Fixture engine and sandbox (I2).
+5. Actions, result views, and the case supervisor with its frame protocol
+ (I9, I10).
+6. Assertion evaluation and the failure taxonomy.
+7. CLI wiring, remaining localization, and reporting (I5, I8).
+8. Author-facing documentation: a users' guide chapter on writing and
+ running manifest tests, indexed from `contents.md`.
+
+Deferred work is enumerated in the UX design §15; nothing in this architecture
+forecloses it. Roadmap phase 7 tracks these deliverables as numbered tasks.
+
+## 15. Synchronization
+
+This document must be kept in step with the decisions and documents that govern
+it. When any of the following change, this document is updated in the same
+change set or the divergence is called out explicitly here until it is
+reconciled:
+
+- The [UX design](netsuke-test-framework-ux-design.md), which is normative
+ for dialect semantics; implementation detail here must not contradict it.
+- [RFC 0007](rfcs/0007-netsukefile-testing-framework.md), which positions
+ and scopes the feature.
+- [ADR-008](adr-008-environment-seam-taxonomy.md), which governs the
+ environment injection seams §5 relies on.
+- [ADR-010](adr-010-scope-glob-capability-to-literal-prefix.md), which
+ governs the glob capability scoping the mock engine and sandbox rely on.
+- [Roadmap phase 7](roadmap.md), which tracks the phasing above (§14) as
+ numbered deliverables.
+
+If an accepted ADR changes, the ADR wins: this document is either updated to
+match or the divergence is recorded here until it is.
diff --git a/docs/netsuke-test-framework-ux-design.md b/docs/netsuke-test-framework-ux-design.md
new file mode 100644
index 000000000..61365f6ab
--- /dev/null
+++ b/docs/netsuke-test-framework-ux-design.md
@@ -0,0 +1,1057 @@
+# Netsuke test framework UX and semantic design
+
+## Front matter
+
+- **Status:** Draft.
+- **Scope:** The user-facing surface of the Netsukefile testing framework: the
+ test tree, the YAML test dialect, the `given`/`when`/`then` semantics, the
+ mocking model, fixtures, the `netsuke test` command, and reporting. The
+ implementation architecture lives in the companion
+ [technical design](netsuke-test-framework-technical-design.md).
+- **Primary audience:** Netsukefile authors writing tests, and reviewers
+ evaluating the test dialect before implementation.
+- **Governing documents:**
+ [RFC 0007](rfcs/0007-netsukefile-testing-framework.md) proposes and positions
+ this feature; [netsuke-design.md](netsuke-design.md) defines the manifest
+ language and compiler pipeline the tests exercise;
+ [ADR-003](adr-003-actions-foreach-when-scope.md) governs manifest control
+ keys; [ADR-008](adr-008-environment-seam-taxonomy.md) governs environment
+ injection. Where this document conflicts with an accepted ADR, the ADR takes
+ precedence.
+
+## 1. Problem statement
+
+Netsuke compiles a YAML-plus-Jinja manifest into a typed abstract syntax tree
+(AST), an intermediate representation (IR) build graph, and a Ninja file. A
+Netsukefile of any sophistication contains real logic: `foreach` expansion,
+`when` conditions, macros, environment probes, globbing, and command
+availability branches. Today the only way to check that logic is to run the
+build and inspect the result by hand, which is slow, environment-dependent, and
+impossible to automate for the negative cases (a target that must _not_ be
+generated, a manifest that must fail with a specific diagnostic).
+
+The framework described here gives Netsukefile authors a first-class
+`netsuke test` command and a YAML test dialect. Tests evaluate manifests
+through the same compiler pipeline as `netsuke build`, with declared mocks
+substituted at named seams, and assert against structured results: the rendered
+manifest, the IR graph, the generated Ninja text, and recorded mock calls.
+Tests are deterministic by default: no network, no wall-clock dependency, no
+mutation of the project root, and no leaked mocks.
+
+### 1.1. Design intent
+
+Three commitments shape every decision below:
+
+- **Same compiler, declared substitutions.** Tests run the real loader, AST,
+ IR builder, and Ninja generator. Mocks are declarative substitutions at named
+ seams, never a re-implementation of manifest semantics. This is the lesson of
+ Act, whose emulation of GitHub Actions drifts permanently from the real
+ service;[^1] Netsuke avoids the problem structurally because the test runner
+ and the build share one implementation.
+- **Plan-mode is the unit-testing story.** OpenTofu and Terraform's test
+ framework distinguishes `command = plan` (validate logic, create nothing) from
+ `command = apply`.[^2] Netsuke adopts the same split: the default test
+ phases stop at generated Ninja text and never execute build commands.
+ Execution is a deferred, explicitly gated escalation.
+- **Failure output is a feature.** Open Policy Agent (OPA) prints the
+ failing expression with the actual value of every variable substituted;[^3]
+ shellmock prints the configuration stanza that would have matched an
+ unexpected call.[^4] Netsuke test reports adopt both behaviours.
+
+## 2. Glossary
+
+- **Test file:** a YAML file in the test tree containing one or more test
+ cases and optional shared declarations.
+- **Test case:** a named entry whose key starts with `test_`, holding a
+ description, tags, and an ordered list of steps.
+- **Step:** one `given`/`when`/`then` group within a test case. Each step
+ contains at least one of the three sections.
+- **Subject manifest:** the Netsukefile a test case evaluates.
+- **Fixture:** a named lifecycle object with setup actions, exported values,
+ and teardown actions, requested by name from a step's `given`.
+- **Double:** a declared substitution for a callable in the subject
+ manifest's template environment. Doubles come in three kinds: _stub_ (canned
+ answer, never verified), _mock_ (canned answer, verified against declared
+ expectations), and _spy_ (records calls and passes through to the real
+ implementation).
+- **Journal:** the chronological record of every call made to any double
+ during a test case.
+- **Support file:** a test-tree file whose name starts with `_`. It may
+ declare `vars`, `macros`, and `fixtures` for import but contains no test
+ cases.
+
+## 3. Test tree and discovery
+
+### 3.1. Location and configuration
+
+Tests live in a `netsuke-tests` directory beside the Netsukefile by default. An
+optional top-level `tests` block in the Netsukefile reconfigures discovery:
+
+```yaml
+netsuke_version: "1.2.0"
+
+tests:
+ root: netsuke-tests
+ include:
+ - "**/*.yml"
+ - "**/*.yaml"
+ exclude:
+ - "**/_*.yml"
+ - "**/_*.yaml"
+```
+
+The values above are also the defaults when the block is absent. The `tests`
+block is tool configuration, not build data: its values are not visible to
+manifest templates, and `tests.root` accepts no Jinja, because discovery must
+work before the subject manifest's template environment exists.
+
+Files matching an `exclude` pattern that start with `_` are support files (§2);
+they are loaded only when imported. Any other excluded file is ignored entirely.
+
+### 3.2. Empty runs fail
+
+A `netsuke test` invocation that discovers no test files, or whose filters
+select zero cases, exits with the usage-error code rather than reporting
+success. OPA added `--fail-on-empty` after silently green pipelines shipped
+with a glob that matched nothing;[^3] Netsuke makes that behaviour the default
+and provides `--allow-empty` for intentionally empty suites.
+
+## 4. Test file structure
+
+A test file is a YAML mapping with a fixed set of known keys plus dynamic
+`test_*` keys:
+
+```yaml
+netsuke_test_version: "1.0" # required in every test file
+
+imports: # optional: support files, paths relative to
+ - _fixtures.yml # this file, confined to the test tree
+
+vars: {} # optional suite-local test variables
+
+macros: [] # optional, same shape as Netsukefile macros
+
+fixtures: {} # optional fixture definitions (§9)
+
+test_compile_target: # one or more test cases
+ ...
+```
+
+Macros are declared exactly as in a Netsukefile — `signature` and `body` — so
+authors carry one mental model between manifests and tests.
+
+`netsuke_test_version` is a `MAJOR.MINOR` string, not a full semantic version.
+The runner accepts a file when the major version matches a supported major and
+the minor version is at most the supported minor; anything else is rejected
+with a located diagnostic and the usage-error exit code, though `--list` still
+enumerates such files. Because the dialect denies unknown keys everywhere,
+every addition — a new matcher, a new assertion form — is a minor-version
+event, and older runners reject newer files by design rather than misreading
+them. Breaking changes to existing semantics require a major-version bump.
+
+Unknown top-level keys that do not start with `test_` are rejected with a
+diagnostic naming the nearest known key. This catches `fixture:` for
+`fixtures:` at parse time rather than as a silently ignored block. The same
+rule applies inside every nested structure: the test dialect has no open-ended
+maps except where values are explicitly user-named (`vars`, `fixtures`,
+`test_*`, `let`, mock names).
+
+## 5. Test cases and steps
+
+The smallest useful test needs no fixtures, no doubles, and no `given` at all:
+
+```yaml
+netsuke_test_version: "1.0"
+
+test_manifest_compiles:
+ steps:
+ - when: generate_ninja
+ then:
+ - result.ok
+ - result.graph.has_target("build/main.o")
+```
+
+A fuller case adds a description, tags, and context:
+
+```yaml
+test_generates_object_targets:
+ description: foreach expands one object target per discovered source.
+ tags: [manifest, foreach]
+ subject:
+ manifest: ../Netsukefile # optional; defaults per §10
+ steps:
+ - given:
+ fixtures: [tiny_c_project]
+ let:
+ glob: mock(args=["src/*.c"], returns=["src/main.c"])
+ when: generate_ninja
+ then:
+ - result.ok
+ - contains(result.ninja, "build build/main.o:")
+```
+
+Rules:
+
+- `steps` must contain at least one item.
+- Each step must contain at least one of `given`, `when`, or `then`.
+- Steps execute in order. Context established by `given` persists across
+ later steps of the same case; nothing persists between cases.
+- `description` is optional but reported when present; `tags` drive the
+ `--tag`/`--skip-tag` filters.
+- An optional case-level `timeout` (seconds) overrides the run-level
+ per-case timeout (§12).
+
+Test case names must match `test_[A-Za-z0-9_]+` and are reported as
+`::`, following the naming-convention discovery that every surveyed
+framework converged on (`*.tftest.hcl`, `*_test.go`, `test_` rules).[^2][^3]
+
+## 6. Expression semantics
+
+Test expressions use MiniJinja expression syntax — the same engine that
+evaluates manifest `when` conditions — so authors reuse the manifest language's
+filters and functions.
+
+The dialect distinguishes _expression fields_ from _template fields_:
+
+- **Expression fields** (`let` values, scalar `then` entries, assertion
+ operands) are bare expressions. `sources | length` is valid;
+ `"{{ build_dir }}/main.o"` is rejected with a diagnostic explaining the
+ distinction.
+- **Template fields** (fixture file contents, paths in filesystem actions)
+ are Jinja templates in which `{{ ... }}` interpolation is expected.
+
+This split prevents the two-Jinja-dialects-in-one-file ambiguity: a field is
+always one or the other, never context-dependent. The complete classification:
+
+| Field | Class |
+| ------------------------------------------------------------------------- | ---------- |
+| `let` values | expression |
+| scalar `then` entries; `equals.actual`, `contains.value`, `matches.value` | expression |
+| `equals.expected`, `contains.needle`, `matches.regex` | literal |
+| `env.set` values, `clock.now`, mock `returns`/`raises` values | literal |
+| fixture `setup`/`teardown` paths and `write.text` | template |
+| fixture `exports` values | template |
+| `given.fs` paths and contents | template |
+| action arguments (for example `manifest:`) | template |
+| `subject.manifest` | template |
+
+_Table 1: Field classification. Literal fields are plain YAML values with no
+evaluation of either kind._
+
+Scope rule: `let` bindings are visible only to test expressions. A binding
+whose value is a double declaration additionally installs that name into the
+subject manifest's template environment; a plain binding never is. The names
+`mock`, `stub`, `spy`, and `substitute` are reserved callables in expression
+fields.
+
+`let` bindings evaluate in document order and may reference earlier bindings
+and fixture exports:
+
+```yaml
+given:
+ fixtures: [tiny_c_project]
+ let:
+ manifest_path: fixtures.tiny_c_project.manifest
+ source_count: 2
+ label: "'objects: ' ~ source_count"
+```
+
+## 7. `given` semantics
+
+`given` prepares the hermetic context for the step. It never invokes the
+compiler pipeline. Supported sections, applied in this order:
+
+1. `fixtures` — resolve and set up requested fixtures (§9).
+2. `env` — set and unset environment values seen by the subject manifest's
+ `env()` function. The host process environment is never mutated; values flow
+ through Netsuke's injected environment reader. Fixture `env` actions
+ contribute to the same case-level map first; `given.env` wins on conflict.
+3. `fs` — structured filesystem operations (`mkdir`, `write`, `copy`,
+ `remove`) inside the test sandbox.
+4. `let` — evaluate bindings in document order (§6). A binding whose value
+ is a `mock(...)`, `stub(...)`, `spy(...)`, or `substitute(...)` call
+ declares a double (§8) rather than a plain value.
+5. `mocks` — the structured block form of double declarations (§8.2).
+6. `clock` — fix the value returned by the stdlib `now()` function.
+7. `subject` — override the subject manifest for this and later steps.
+
+```yaml
+given:
+ env:
+ set:
+ CC: clang
+ unset:
+ - RUSTFLAGS
+ clock:
+ now: "2026-06-08T12:00:00Z"
+ let:
+ glob: mock(args=["src/*.c"], returns=["src/main.c"])
+```
+
+Network access is denied in tests regardless of `given`: an unmocked `fetch()`
+call fails the action with a diagnostic explaining how to declare a stub for
+the URL. Determinism is the default; ambient reality is opt-in per seam.
+
+## 8. The mocking model
+
+### 8.1. Doubles: stub, mock, spy
+
+The dialect names its three kinds of double explicitly, following the taxonomy
+shared by flexmock and cmd-mox:[^5][^6]
+
+- `stub(...)` — returns a canned value; calls are journalled but never
+ verified. Unmatched calls on a stub return the declared `default`, or
+ MiniJinja `Undefined` when none is declared.
+- `mock(...)` — returns a canned value and is verified: every declared
+ expectation must be satisfied by the end of the case, and any call that
+ matches no declared expectation fails the action immediately.
+- `spy(...)` — journals every call and passes through to the _effective_
+ implementation under test configuration: the sandbox-rooted `glob` and file
+ tests, the fixed clock for `now()`, the real manifest macro for a substituted
+ name. Spying `fetch` is a suite error because the effective implementation
+ under the deny-all network policy can only fail; declare a stub or mock
+ instead.
+
+Shorthand forms in `let` desugar to the structured form:
+
+```yaml
+let:
+ glob: mock(args=["src/*.c"], returns=["src/main.c"])
+ cc: stub(returns="clang")
+ now: spy()
+```
+
+The strictness ladder is deliberate: a loose stub is the one-line default for
+don't-care collaborators, and full expectation machinery is graduated opt-in.
+Mockito's community documented over-mocking as the primary failure mode of
+expressive mock frameworks;[^7] the dialect keeps the terse form tersest.
+
+### 8.2. Structured declarations and call configurations
+
+The structured `mocks` block is the full-fidelity form:
+
+```yaml
+given:
+ mocks:
+ fetch:
+ kind: mock
+ calls:
+ - args: ["https://example.test/toolchain.json"]
+ returns: '{"compiler": "clang"}'
+ - args: [{ starts_with: "https://mirror." }]
+ returns: '{"compiler": "gcc"}'
+ times: 2
+ - raises:
+ message: unexpected fetch
+ cc_name:
+ kind: stub
+ default: clang
+```
+
+Declaring the same double name twice in one case — in `let` shorthand and the
+`mocks` block, or across steps — is a suite error, not a merge.
+
+Semantics:
+
+- `calls` is a first-match-wins configuration list: each incoming call takes
+ the first entry whose matchers accept it, so specific entries precede
+ catch-all entries. This is shellmock's model,[^4] and it is more robust than
+ positional call scripts ("the nth call must be exactly this"), the
+ record-replay rigidity that made pymox-style tests brittle.[^8]
+- Matching is **unordered by default**. `ordered: true` on a double opts its
+ entries into declaration-order matching. Every surveyed library that enforced
+ global ordering by default is remembered for brittle tests; every modern one
+ makes ordering opt-in.[^5][^7]
+- `times: N` is a maximum, not a quota. An entry may match up to N calls.
+ Once those are spent, the next call that would have matched it falls through
+ to later entries instead, and fails dispatch if none accepts. Fewer than N
+ matches — including none — is not itself a failure, so `times` never doubles
+ as a minimum-call assertion. Entries without `times` match any number of
+ calls. To require that a call happened, assert on the journal
+ (`mocks..call_count`) or use a `mock`, whose declared entries must all
+ be satisfied by end of case.
+- `returns` supplies a YAML value returned as the MiniJinja value;
+ `raises` supplies a structured template error instead.
+
+### 8.3. Argument matchers
+
+Arguments match by exact equality unless a matcher object is used. The matcher
+vocabulary is closed:
+
+| Matcher | Meaning |
+| --------------------- | ----------------------------------------------------- |
+| `eq: ` | accepts exactly this value (the literal escape hatch) |
+| `any: true` | accepts any value |
+| `is_a: string` | accepts values of the named type |
+| `regex: "^src/"` | accepts strings matching the pattern |
+| `contains: ".c"` | accepts strings or lists containing the needle |
+| `starts_with: "src/"` | accepts strings with the prefix |
+| `not: ` | negates the wrapped matcher |
+
+_Table 2: Argument matcher vocabulary._
+
+A bare argument matches by exact equality; `eq:` exists so a literal one-key
+mapping that happens to spell a matcher name can still be matched. Equality is
+structural over the YAML-to-template value conversion: integers and floats
+compare numerically, strings never equal numbers, and sequences and mappings
+compare element-wise. `is_a` accepts exactly `string`, `integer`, `float`,
+`number`, `boolean`, `list`, `map`, and `none`.
+
+Predicate functions and dynamic response handlers are deliberately absent: they
+are imperative logic and do not survive translation into a data dialect. A case
+that needs computed behaviour should restructure, or wait for the deferred
+fixture-script escape hatch.
+
+### 8.4. Verification and the journal
+
+At the end of each test case the runner verifies:
+
+- every `mock` expectation was satisfied (unmet expectations fail the
+ case), and
+- every declared double was used at least once. An unused double is
+ reported as an _unnecessary double_ warning — Mockito's
+ `UnnecessaryStubbingException` insight, softened to a warning with
+ `lenient: true` as the per-double opt-out.[^7]
+
+Every call to every double lands in the case's journal, addressed as
+`mocks.` for every kind — the colloquial name is kept because it is what
+authors reach for, and inventing a `doubles.` namespace would trade familiarity
+for taxonomy. The journal is bounded: a double whose call count exceeds the
+per-double ceiling (default 10,000) turns the case into an error naming the
+runaway, rather than exhausting memory. Assertions read the journal:
+
+```yaml
+then:
+ - mocks.glob.call_count == 1
+ - mocks.glob.calls[0].args == ["src/*.c"]
+```
+
+When a strict mock receives an unmatched call, the failure report prints the
+observed call and the YAML entry that would have accepted it:
+
+```plaintext
+FAIL compile.yml::test_compile_target
+ mock 'fetch' received an unmatched call:
+ fetch("https://example.test/versions.json")
+ no configured entry matched. A matching entry would be:
+ - args: ["https://example.test/versions.json"]
+ returns:
+```
+
+### 8.5. Macro substitution
+
+`substitute("name")` swaps a manifest macro (or installs a new callable) with a
+stand-in macro declared in the test file:
+
+```yaml
+macros:
+ - signature: "stand_in_compile(src, obj)"
+ body: |
+ STUB {{ src }} -> {{ obj }}
+
+test_compile_uses_macro:
+ steps:
+ - given:
+ let:
+ compile_cmd: substitute("stand_in_compile")
+ when: generate_ninja
+ then:
+ - contains(result.ninja, "STUB")
+ - substitutes.compile_cmd.call_count == 1
+```
+
+The stand-in must exist in the test file or an imported support file.
+Substituted macros journal their calls under `substitutes.`. Substitution
+scope is one test case.
+
+### 8.6. What can be mocked
+
+| Seam | Mechanism | Example |
+| ----------------------- | -------------------------- | --------------------------------------------- |
+| Template functions | `mock`/`stub`/`spy` double | `glob`, `which`, `fetch`, `command_available` |
+| Environment variables | `given.env` | `env("CC")` |
+| Clock | `given.clock` | `now()` |
+| Manifest macros | `substitute(...)` | `compile_cmd(...)` |
+| Filesystem observations | fixtures and `given.fs` | file tests, `glob` against real sandbox files |
+
+_Table 3: Mockable seams and their mechanisms._
+
+Filters and Jinja tests (`"clang" | which`, `path is file`) are not mockable in
+the first version; filesystem fixtures cover most file-test cases with real
+files, which is both simpler and higher fidelity.
+
+## 9. Fixtures
+
+Fixtures are lifecycle objects: ordered setup actions, exported values, and
+ordered teardown actions.
+
+```yaml
+fixtures:
+ tiny_c_project:
+ description: Minimal C project with one source and a Netsukefile.
+ setup:
+ - tmpdir: project
+ - mkdir: "{{ project }}/src"
+ - write:
+ path: "{{ project }}/src/main.c"
+ text: |
+ int main(void) { return 0; }
+ - write:
+ path: "{{ project }}/Netsukefile"
+ text: |
+ netsuke_version: "1.2.0"
+ targets:
+ - name: build/main.o
+ command: "cc -c src/main.c -o build/main.o"
+ sources: src/main.c
+ exports:
+ root: "{{ project }}"
+ manifest: "{{ project }}/Netsukefile"
+```
+
+Fields: `description`, `uses` (fixture dependencies), `params` (defaults,
+overridable at request time), `setup`, `exports`, `teardown`. The action
+vocabulary is `tmpdir`, `mkdir`, `write`, `copy`, `remove`, and `env`. All
+paths resolve inside the per-case sandbox; a fixture cannot touch the project
+root or the host filesystem.
+
+Lifecycle guarantees:
+
+- Fixtures set up in dependency order; each at most once per case.
+- Teardown runs in reverse setup order, for every fixture whose setup
+ completed, regardless of later setup failures, action failures, or assertion
+ failures.
+- A failing teardown never masks the case result: the remaining stack
+ still unwinds, every teardown error is aggregated into the report, the case
+ is marked errored, and its sandbox is retained as if `--keep` had been passed.
+- All fixtures are case-scoped. File- and session-scoped fixtures are
+ deferred until the isolation model has tests of its own; Terraform's
+ shared-state `state_key` sharp edges and Molecule's driver sprawl both
+ counsel starting with the trivially safe default.[^2][^9]
+
+An arbitrary-command `run` action is deferred with execution generally (§10).
+`--keep` preserves the sandbox of failing cases for inspection, mirroring
+Molecule's `--destroy=never` escape hatch.[^9]
+
+## 10. `when` semantics
+
+`when` invokes named pipeline actions against the subject manifest. Scalar,
+list-of-scalars, and object forms are accepted:
+
+```yaml
+when: generate_ninja
+
+when:
+ - load_manifest
+ - build_graph
+
+when:
+ - generate_ninja:
+ manifest: "{{ fixtures.tiny_c_project.manifest }}"
+```
+
+Actions, in pipeline order:
+
+| Action | Runs | Result carries |
+| ---------------- | ------------------------------------------ | ----------------- |
+| `load_manifest` | ingest, parse, expand, deserialize, render | `result.manifest` |
+| `build_graph` | `load_manifest` + IR lowering | `result.graph` |
+| `generate_ninja` | `build_graph` + Ninja generation | `result.ninja` |
+
+_Table 4: Pipeline actions._
+
+Later actions imply the earlier stages, so most cases write exactly one `when`.
+Within a step, the pipeline runs once: each action in the list extends the
+previous action's artefacts rather than re-running the loader, so a `when` of
+all three actions evaluates the manifest's templates exactly once and journal
+counts are independent of how many actions name the stages. Each action replaces
+`result` and appends to `results`, so a multi-action step can compare stages.
+An action in a _later step_ starts a fresh pipeline pass, and its template
+evaluations journal again.
+
+The subject manifest resolves in precedence order: the action's `manifest`
+argument, the step's `given.subject`, the case's `subject`, then the
+Netsukefile of the enclosing project. A subject manifest's own `tests` block is
+inert during test execution: discovery is driven solely by the project whose
+`netsuke test` invocation is running, and the runner never recurses.
+
+Every subject path an author writes is confined. Two roots are approved: the
+per-case sandbox, which holds fixture-created manifests, and the enclosing
+project's own Netsukefile, a read-only exception so that testing the manifest
+beside the test tree keeps working. Paths are validated after template
+evaluation and before the manifest is opened, because the value only exists
+once its interpolation has run. The rules are the same for all three
+author-supplied sources — the action's `manifest` argument, `given.subject`,
+and the case's `subject`:
+
+- Absolute paths are rejected, so naming `/etc/Netsukefile` fails with a
+ located diagnostic rather than reading it.
+- Relative paths resolve against the case sandbox and must stay inside
+ it. `../../outside/Netsukefile` is rejected, as is a path whose existing
+ components resolve through a symlink leaving the root.
+- Ordinary fixture paths keep working because a fixture writes its
+ manifest inside the sandbox and exports a path within it.
+- The enclosing project's Netsukefile is readable without being writable.
+ Approving it as a subject grants read access only; nothing in a test can
+ write to the project root.
+
+Confinement is what makes the sandbox guarantee true of the subject manifest as
+well as of fixture files. Without it a test could read any file the invoking
+user can read, which is wider than testing a build manifest requires.
+
+Build execution (`execute`) is designed but deferred: the command surface
+reserves `--allow-execute`, and no action in the first version spawns Ninja or
+any build command. Fidelity note, following Act's practice of publishing its
+gaps:[^1] until `execute` ships, `netsuke test` validates everything up to and
+including the generated Ninja text, and nothing about the behaviour of the
+commands within it.
+
+## 11. `then` semantics
+
+### 11.1. Assertion forms
+
+Scalar entries are MiniJinja boolean expressions; object entries are structured
+assertions with richer diffs:
+
+```yaml
+then:
+ - result.ok
+ - result.graph.targets | length == 3
+ - equals:
+ actual: result.graph.edge_count
+ expected: 4
+ - contains:
+ value: result.ninja
+ needle: "default app"
+ - matches:
+ value: result.error.message
+ regex: "missing.*rule"
+```
+
+Assertion helper functions available in expressions: `contains`, `starts_with`,
+`ends_with`, `matches`, `file_exists`, `file_contains`.
+
+### 11.2. Result model
+
+```yaml
+result:
+ action: generate_ninja
+ ok: true
+ manifest:
+ graph:
+ ninja: ""
+ error:
+ code: null
+ message: null
+```
+
+`result.graph` is a stable, additive-only assertion surface, deliberately
+distinct from any internal graph type. Its fields: `targets` (a sorted list of
+target views, each with `name`, `sources`, `deps`, `order_only_deps`,
+`dependency_order`, `phony`, and `description`), `rules` (sorted rule names),
+`default_targets`, and `edge_count`. `description` is the target's discovery
+metadata — the text `netsuke help targets` renders — not the rule description
+Ninja reports during execution; a test asserting on progress output must read
+the rule instead. `dependency_order` is `parallel` or `serial`, so a manifest
+that serializes its dependencies can be asserted on directly rather than by
+pattern-matching the generated Ninja. Its helper methods: `has_target`,
+`has_rule`, `has_edge`, and `target(name)`. Fields and helpers are never
+removed or repurposed within a dialect major version. The helpers exist so
+authors never parse raw Ninja for structural questions:
+
+```yaml
+then:
+ - result.graph.has_target("build/main.o")
+ - result.graph.has_rule("compile")
+ - result.graph.target("build/main.o").sources == ["src/main.c"]
+```
+
+### 11.3. Failure taxonomy: FAIL versus ERROR
+
+An assertion that evaluates to false is a **failure**. An assertion whose
+evaluation raises — an undefined name, a type mismatch — is an **error**,
+reported distinctly, following OPA's FAIL/ERROR/SKIP taxonomy.[^3] A typo'd
+`result.grpah` must not masquerade as an ordinary red assertion.
+
+On failure, the report prints the expression with the actual value of each
+referenced name substituted:
+
+```plaintext
+ then[1]: result.graph.targets | length == 3
+ result.graph.targets | length = 2
+```
+
+### 11.4. Expecting failure
+
+Negative tests name the diagnostic they expect rather than asserting bare
+failure, so a different bug cannot satisfy the test:
+
+```yaml
+when: load_manifest
+then:
+ - expect_failure:
+ code: netsuke::manifest::parse
+ message_contains: "unknown field"
+```
+
+`expect_failure` passes when the preceding action failed with a diagnostic
+matching every provided field, and fails when the action succeeded or failed
+differently. `code` matches exactly; `message_contains` is a case-sensitive
+substring test. Diagnostic codes become public contract the moment a test
+matches on them, so `code` matching ships only once the diagnostic-stack
+migration settles and the code namespace is declared stable; until then,
+`message_contains` carries negative tests. Terraform's `expect_failures`
+confusion — expected failures interacting badly with phase defaults — is
+avoided by attaching the expectation to an explicit `then` after an explicit
+`when`.[^2]
+
+## 12. The `netsuke test` command
+
+```plaintext
+netsuke test [FILTER...]
+
+Arguments:
+ FILTER Case selectors: file paths, file::case names, or
+ substring patterns
+
+Options:
+ --tests-dir Override tests.root
+ --list List discovered cases without running them
+ --tag Run only cases with this tag (repeatable)
+ --skip-tag Exclude cases with this tag (repeatable)
+ --fail-fast Stop after the first failing case
+ --timeout Per-case wall-clock budget (default 60)
+ --keep Preserve sandboxes of failing cases
+ --allow-empty Succeed when zero cases are selected
+```
+
+`--json` and `--jobs` are the existing global flags, not new per-command
+options; `test` consumes them with their established semantics.
+
+Under `--jobs > 1`, `--fail-fast` stops scheduling rather than cancelling work
+in flight: cases already running continue to completion, so their fixture
+teardown and journal handling are unaffected by the stop. No new case is
+started once a failure is observed. Cases that never start are reported as
+`skipped`. A suite with more selected cases than `--jobs` therefore exercises
+both halves of this rule in one run: the cases already dispatched finish
+normally, and the remainder are skipped rather than run. Both the human summary
+and the JSON `summary` counts (§13) therefore add up to the full selected-case
+count, not the number executed: every selected case appears exactly once,
+whether run or skipped.
+
+A case that exceeds its timeout is reported as errored, with whatever journal
+it produced attached, its fixtures torn down, and its sandbox retained for
+inspection. The deadline is absolute: each case runs in its own child process,
+so a case that stops cooperating — a runaway template expression that never
+yields — is terminated rather than waited on. A pathological manifest cannot
+hang the run. This requires a platform that supports spawning and terminating
+child processes; Linux and Windows are covered by continuous integration.
+
+Exit codes:
+
+| Code | Meaning |
+| ---- | ---------------------------------------------------------------------- |
+| 0 | all selected cases passed |
+| 1 | at least one case failed or errored |
+| 2 | invalid suite, invalid selector, or zero cases without `--allow-empty` |
+| 3 | internal runner error |
+| 130 | interrupted |
+
+_Table 5: `netsuke test` exit codes._
+
+The command follows the established display policy flags (`--color`, `--emoji`,
+`--progress`, `--accessibility`) and the stream-purity contract, which turns on
+whether the _run_ completed rather than whether every case passed. A completed
+run — all passed, some failed or errored, or interrupted — writes exactly one
+JSON document to stdout and nothing to stderr; failing cases are reported
+inside that document because they are what automation reads. A command failure
+that produces no report (invalid suite or selector, an empty selection without
+`--allow-empty`, or an internal runner error) instead leaves stdout empty and
+writes one diagnostic document to stderr. All human-facing strings are
+localized like the rest of the command-line interface (CLI) surface.
+
+Interruption (Ctrl-C) stops scheduling, then terminates every case still
+running and waits for each child to exit. With no child still alive, the parent
+performs the cleanup those children can no longer do themselves, reaps every
+child to collect its status, applies the usual `--keep` decision to the run
+sandbox — removing it, or retaining it and printing the path — and exits 130. In
+`--json` mode the run still emits exactly one document, marked
+`"interrupted": true`. Every selected case still appears exactly once: a case
+the parent terminated mid-run is errored, carrying an interruption diagnostic
+and whatever journal it had produced, and a case that never started is skipped.
+Waiting for exit before cleanup matters on Windows, where a still-terminating
+child can hold sandbox handles open; ordering the shutdown this way is what
+stops an interrupted run leaving orphaned children or half-removed sandboxes
+behind.
+
+A case whose sandbox cannot be provisioned is errored; the run aborts with exit
+3 only when the run root itself cannot be created.
+
+## 13. Reporting
+
+Human output:
+
+```plaintext
+netsuke test
+
+PASS compile.yml::test_generates_object_targets
+FAIL compile.yml::test_substituted_macro_receives_source
+
+ then[1]: substitutes.compile_cmd.calls[0].args[0] == "src/main.c"
+ substitutes.compile_cmd.calls[0].args[0] = "src/lib.c"
+
+2 cases: 1 passed, 1 failed
+```
+
+The JSON document mirrors the human report with stable fields:
+
+```json
+{
+ "format_version": 1,
+ "summary": { "total": 2, "passed": 1, "failed": 1, "errored": 0,
+ "skipped": 0 },
+ "cases": [
+ {
+ "id": "compile.yml::test_generates_object_targets",
+ "status": "passed",
+ "duration_ms": 12
+ },
+ {
+ "id": "compile.yml::test_substituted_macro_receives_source",
+ "status": "failed",
+ "duration_ms": 9,
+ "failures": [
+ {
+ "assertion": "then[1]",
+ "expression": "substitutes.compile_cmd.calls[0].args[0] == \"src/main.c\"",
+ "rendered": "substitutes.compile_cmd.calls[0].args[0] = \"src/lib.c\""
+ }
+ ]
+ }
+ ]
+}
+```
+
+`status` is one of `passed`, `failed`, `errored`, or `skipped`; `failed` and
+`errored` are distinct end to end (§11.3), and both map to exit code 1.
+`format_version` increments on any non-additive report change. Substituted
+values in `rendered` fields are truncated beyond a few kibibytes with an
+explicit elision marker, in both human and JSON output, so one failing
+assertion over a large Ninja file cannot balloon the report. JUnit XML output
+is a deferred addition; Terraform's file-to-testsuite, run-to-testcase mapping
+is the template to follow when it lands.[^2]
+
+## 14. Worked example
+
+### 14.1. Quick start
+
+A test tree needs nothing beyond a Netsukefile and one test file in the default
+`netsuke-tests` directory:
+
+```plaintext
+project/
+├── Netsukefile
+└── netsuke-tests/
+ └── hello.yml
+```
+
+The subject manifest declares one target with a literal command and no sources:
+
+```yaml
+netsuke_version: "1.2.0"
+
+targets:
+ - name: build/hello.txt
+ command: echo hello > build/hello.txt
+```
+
+The test file declares one case with one step that generates Ninja and asserts
+the target exists:
+
+```yaml
+netsuke_test_version: "1.0"
+
+test_hello_target_is_generated:
+ steps:
+ - when: generate_ninja
+ then:
+ - result.ok
+ - result.graph.has_target("build/hello.txt")
+```
+
+Run it from the project root:
+
+```plaintext
+netsuke test
+```
+
+A passing run reports one case:
+
+```plaintext
+netsuke test
+
+PASS hello.yml::test_hello_target_is_generated
+
+1 case: 1 passed
+```
+
+This case needs no external tools, no compiler, no network access, and no real
+filesystem fixtures: `generate_ninja` runs entirely against the in-memory
+pipeline described in §10.
+
+### 14.2. Worked example
+
+Subject `Netsukefile`:
+
+```yaml
+netsuke_version: "1.2.0"
+
+tests:
+ root: netsuke-tests
+
+macros:
+ - signature: "compile_cmd(src, obj)"
+ body: |
+ {{ env('CC') }} -c {{ src }} -o {{ obj }}
+
+targets:
+ - foreach: glob('src/*.c')
+ when: item | basename != 'skip.c'
+ name: "build/{{ item | basename | with_suffix('.o') }}"
+ command: "{{ compile_cmd(item, 'build/' ~ (item | basename | with_suffix('.o'))) }}"
+ sources: "{{ item }}"
+
+defaults:
+ - build/main.o
+```
+
+Test file `netsuke-tests/compile.yml`:
+
+```yaml
+netsuke_test_version: "1.0"
+
+macros:
+ - signature: "stand_in_compile(src, obj)"
+ body: |
+ STUB {{ src }} -> {{ obj }}
+
+test_skips_filtered_sources:
+ description: foreach expands sources; when filters skip.c; the compile
+ macro can be substituted.
+ tags: [manifest, foreach]
+ steps:
+ - given:
+ env:
+ set:
+ CC: clang
+ let:
+ glob: mock(args=["src/*.c"],
+ returns=["src/main.c", "src/skip.c"])
+ compile_cmd: substitute("stand_in_compile")
+ when: generate_ninja
+ then:
+ - result.ok
+ - result.graph.has_target("build/main.o")
+ - not result.graph.has_target("build/skip.o")
+ - contains(result.ninja, "STUB src/main.c -> build/main.o")
+ - mocks.glob.call_count == 1
+ - substitutes.compile_cmd.call_count == 1
+```
+
+The case verifies `foreach` expansion, `when` filtering, environment-driven
+command construction, and macro wiring — without a compiler installed, without
+touching the real filesystem, and identically on every machine.
+
+## 15. Non-goals and deferred features
+
+Non-goals:
+
+- Replacing Netsuke's own Rust test suites. This framework tests
+ Netsukefiles as user artefacts; `cargo nextest` continues to test Netsuke the
+ implementation.
+- General-purpose scripting. The dialect is declarative by design; logic
+ that does not fit belongs in the manifest under test or in a future execution
+ phase.
+
+Deferred, in likely delivery order:
+
+1. Build execution (`execute` action, `--allow-execute`) and fixture shell
+ commands (`--allow-fixture-scripts`).
+2. Filter and Jinja-test doubles.
+3. File- and session-scoped fixtures.
+4. Data-driven case tables (parameterized matrices), following OPA's named
+ subcase reporting.[^3]
+5. Snapshot assertions against generated Ninja.
+6. JUnit XML output; an idempotence check asserting that regenerating from
+ an unchanged manifest yields byte-identical Ninja.
+
+## 16. Risks and trade-offs
+
+- The dialect is declarative and closed: predicate functions and dynamic
+ response handlers are deliberately absent (§8.3). A case that needs computed
+ behaviour has no escape hatch until fixture scripts land (§15).
+- Strictness defaults trade ceremony for early failure: an unmatched call
+ on a `mock` fails the action immediately, and an unused double warns by
+ default (§8.4). Authors pay for this with `lenient: true` opt-outs on
+ legitimately unused doubles.
+- Plan-mode-only means `netsuke test` validates everything up to and
+ including the generated Ninja text, and nothing about the behaviour of the
+ commands within it, until `execute` ships (§10).
+- Tests are sandbox-rooted: fixtures cannot touch the project root or the
+ host filesystem (§9). A manifest that legitimately reads the project tree
+ therefore behaves differently under test than under a real build.
+- Every dialect addition — a new matcher, a new assertion form — is a
+ minor-version event, and older runners reject newer files by design (§4).
+ This is deliberate rigidity, traded for the guarantee that a runner never
+ silently misreads a newer test file.
+
+## 17. Rejected alternatives
+
+RFC 0007 evaluates these alternatives in full; this section names them and the
+conclusion only.
+
+- **An instrumented general-purpose-language harness.** Rejected in favour
+ of a closed declarative dialect: a general-purpose language reopens the drift
+ risk that same-compiler, declared-substitution design exists to close (§1.1).
+- **Assertions embedded in the Netsukefile.** Rejected because it mixes
+ build data with test data and couples the manifest's shape to its own
+ verification, contrary to the separation this framework establishes between
+ subject manifest and test file (§4).
+- **Snapshot-only testing.** Rejected as the sole assertion style, though
+ snapshot assertions against generated Ninja remain a deferred addition (§15):
+ structured assertions against `result.graph` give better failure output (§11)
+ than diffing raw Ninja text.
+
+[^1]: Act documents its unsupported-functionality list and positions itself
+ as fast pre-flight rather than a substitute oracle:
+ .
+
+[^2]: Terraform/OpenTofu test framework: run blocks, `command = plan`,
+ mocking, and `expect_failures` semantics:
+ and
+ .
+
+[^3]: OPA policy testing: `test_` discovery, `with` substitution,
+ `--var-values`, and `--fail-on-empty`:
+ .
+
+[^4]: shellmock: first-match configuration lists, call journal, and
+ suggested configurations for unexpected calls:
+ .
+
+[^5]: flexmock: stub/mock/spy taxonomy, opt-in ordering, teardown-time
+ verification: .
+
+[^6]: cmd-mox: stub/mock/spy controller API, invocation journal, and
+ record-replay-verify lifecycle: .
+
+[^7]: Mockito: act-then-assert stubbing, strict stubbing and
+ `UnnecessaryStubbingException`, and over-mocking guidance:
+ .
+
+[^8]: pymox record-replay model and its rigidity:
+ .
+
+[^9]: Ansible Molecule: phase sequence, fast inner-loop subcommands, and
+ `--destroy=never`:
+ .
diff --git a/docs/rfcs/0007-netsukefile-testing-framework.md b/docs/rfcs/0007-netsukefile-testing-framework.md
new file mode 100644
index 000000000..e78a3db5e
--- /dev/null
+++ b/docs/rfcs/0007-netsukefile-testing-framework.md
@@ -0,0 +1,204 @@
+# RFC 0007: Netsukefile testing framework
+
+## Preamble
+
+- **RFC number:** 0007
+- **Status:** Proposed
+- **Created:** 2026-08-17
+
+## Summary
+
+Add a first-class testing framework for Netsukefiles: a `netsuke test` command,
+a YAML test dialect with `given`/`when`/`then` steps, a declarative mocking
+model for template functions, environment, clock, and macros, and hermetic
+fixtures. Tests evaluate manifests through the same compiler pipeline as
+`netsuke build` and assert against the rendered manifest, the intermediate
+representation (IR) graph, and the generated Ninja text — deterministically,
+without executing build commands.
+
+The proposal is specified in two companion documents:
+[UX and semantic design](../netsuke-test-framework-ux-design.md) (the test
+dialect and command surface) and
+[technical design](../netsuke-test-framework-technical-design.md) (the
+implementation architecture).
+
+## Problem
+
+Netsukefiles contain real logic — `foreach` expansion, `when` conditions,
+macros, environment probes, globbing, `command_available` branches — and that
+logic has no verification story. Authors validate manifests by running builds
+and eyeballing output. This fails in four ways:
+
+- Negative properties (a target that must not exist, a manifest that must
+ fail with a specific diagnostic) cannot be checked at all.
+- Behaviour that depends on the environment (installed tools, environment
+ variables, the clock, the network) cannot be pinned, so checks are not
+ reproducible across machines or continuous integration (CI).
+- Refactoring a non-trivial manifest is unprotected: nothing catches a
+ `when` condition that silently stops matching.
+- Agents and CI systems have no machine-checkable contract for manifest
+ behaviour, which undercuts the roadmap's agent-consistency thesis.
+
+## Current state
+
+The compiler pipeline is already shaped for this feature. Manifest loading is a
+staged, injectable library path: `from_path_with_policy_and_env` accepts a
+network policy, an injected environment reader, and a stage callback.
+`BuildGraph::from_manifest` and `ninja_gen::generate` are public, composable
+functions over plain data. Deterministic projections exist for graph output,
+and Ninja generation is already snapshot-stable.
+
+The `netsuke help targets` work narrowed the gap further. It introduced a
+`StdlibRegistration` enum that selects the standard-library boundary per load
+mode, a `ManifestQuery` mode that disables the impure helpers with located
+diagnostics, a `disabled_env_reader`, and `src/manifest/query.rs` as the owner
+of capability-scoped non-build loading. The test runner is a third mode of that
+same shape, so it extends the established pattern instead of introducing a
+parallel one; the technical design records the consequences.
+
+What is missing: a clock seam for `now()` (it calls the system clock directly),
+a mechanism to substitute manifest macros, any test dialect, discovery, mock
+engine, fixture lifecycle, or `test` subcommand. The manifest schema rejects
+unknown top-level keys, so the proposed `tests` configuration block is a schema
+addition with compatibility consequences (see below).
+
+## Goals and non-goals
+
+- Goals:
+ - Deterministic, machine-independent verification of manifest-time
+ behaviour, including negative cases with named diagnostics.
+ - A declarative mocking model at named seams (template functions,
+ environment, clock, macros) with strict-by-default verification and a
+ call journal.
+ - Hermetic per-case fixtures with guaranteed teardown.
+ - A `netsuke test` command with human and single-document JSON output,
+ consistent with the CLI vocabulary and stream-purity contracts.
+- Non-goals:
+ - Executing builds or fixture shell commands (designed but deferred
+ behind explicit allow flags).
+ - Replacing Netsuke's own Rust test suites.
+ - A general-purpose scripting language for tests.
+
+## Proposed design
+
+A `netsuke-tests/` tree of YAML test files, discovered via an optional `tests`
+block in the Netsukefile. Each file holds named `test_*` cases; each case is a
+sequence of steps with `given` (fixtures, environment, doubles, clock), `when`
+(pipeline actions: `load_manifest`, `build_graph`, `generate_ninja`), and
+`then` (expression and structured assertions over result views and the mock
+journal). Doubles follow a stub/mock/spy taxonomy with first-match-wins call
+configuration, a closed matcher vocabulary, and opt-in ordering. Macro
+substitution swaps a manifest macro for a stand-in declared in the test file.
+
+Implementation reuses the existing pipeline behind a new options-carrying
+loader entry point that registers test overlays after stdlib and manifest
+macros and before `foreach` expansion. Each case runs in a killable child
+process so `--timeout` is a hard bound rather than a cooperative courtesy —
+MiniJinja evaluation cannot be preempted in-process — while discovery,
+scheduling, and report rendering stay in the parent. That boundary reuses the
+`wait_timeout`-then-kill-then-reap pattern already serving the stdlib command
+helpers, and carries results over length-prefixed `serde_json` frames versioned
+like the existing JSON envelope, so it adds no new dependency. The same stream
+carries incremental journal checkpoints, so a case killed on the deadline still
+reports the calls it had already made rather than an empty journal. Two seams
+are added (clock provider; macro substitution overlay); network mocking needs
+no transport seam because the deny-all policy plus function-level doubles make
+the real network code unreachable under test.
+
+Positioning within the product: phase 3 of the roadmap makes Netsuke
+predictable for humans and automation; phase 4 verifies the compiler itself;
+phase 5 compounds value across repeated invocations. This proposal extends the
+verification story from the compiler (phase 4) to the user's manifests, and
+gives phase 5's agent-facing surface a contract mechanism: a Netsukefile with a
+test suite is a manifest whose intended behaviour an agent can check before and
+after editing it. The design deliberately mirrors the prior art the ecosystem
+has converged on — OpenTofu's plan-mode-by-default unit testing, Open Policy
+Agent's FAIL/ERROR taxonomy and failure ergonomics, and declarative
+substitution at named addresses — so the dialect feels familiar to
+practitioners of those tools.
+
+## Compatibility and migration
+
+- **Manifest schema.** `tests` becomes an optional top-level key, admitted
+ from the `netsuke_version` of the release that ships discovery configuration.
+ Older binaries reject a manifest containing the block with their ordinary
+ unknown-field diagnostic, not a version message; this is accepted and
+ documented because the failure is immediate, located, and names the offending
+ key. No existing manifest changes behaviour: manifests without the block are
+ unaffected, and the build path ignores the block entirely (build-path
+ neutrality is a named invariant in the technical design).
+- **CLI vocabulary.** `test` joins the canonical top-level command list.
+ No existing command changes.
+- **Test dialect versioning.** Test files carry `netsuke_test_version`, a
+ `MAJOR.MINOR` string with the acceptance policy defined in the UX design §4:
+ same major, minor at most the supported minor; every dialect addition is a
+ minor-version event. The dialect evolves independently of the manifest schema.
+
+## Alternatives considered
+
+### Option A: instrumented Python (or Rust) test harness
+
+Write manifest tests in a general-purpose language against an instrumented
+runtime, as Terratest does for infrastructure. Rejected as the primary story:
+Netsukefile semantics live in the Rust pipeline, and an out-of-process harness
+either re-implements them (permanent drift, the Act problem) or shells out to
+the binary and loses seam-level mocking. Terratest's own rationale — real
+end-to-end orchestration needs a real language — applies to *execution*
+testing, which this proposal defers, not to manifest-time verification, which
+is pure evaluation. A black-box smoke-test harness remains possible on top of
+`netsuke test --json` later.
+
+### Option B: assertions embedded in the Netsukefile
+
+Add `check`-style blocks to the manifest itself, as Terraform embeds custom
+conditions. Rejected: it mixes test concerns into the artefact under test (the
+leakage Act users suffer with `if: ${{ !env.ACT }}`), cannot express mocking or
+fixtures, and bloats a schema that agents and humans read constantly.
+
+### Option C: deterministic overrides with external assertions
+
+Ship only the seam work (clock provider, loader options, overlays) and surface
+it as `netsuke generate --overrides overrides.yml`, with assertions left to
+whatever harness the user already runs: `insta` or golden files over the
+deterministic Ninja output, `jq` over graph JSON, shell test frameworks. This
+is the strongest alternative: it delivers determinism with roughly a fifth of
+the new surface, needs no manifest schema change, and the overrides file
+doubles as a reproducible-build debugging aid. Rejected as the end state
+because it forfeits exactly the properties this RFC exists for — mock
+verification and the call journal, hermetic fixtures, named-diagnostic negative
+tests, and a single machine-checkable contract an agent can run without
+assembling a bespoke harness. It is, however, adopted as sequencing: the first
+implementation phase is precisely this substrate, and the phase gate below
+keeps the option open if the dialect proves unnecessary.
+
+### Option D: external snapshot testing only
+
+Golden-file tests over `netsuke generate` output driven by shell or CI
+scripting. Rejected as insufficient: snapshots cannot mock environment or
+network, cannot name expected diagnostics, and produce whole-file diffs instead
+of targeted assertions. Snapshot assertions are instead a deferred addition
+inside the framework.
+
+## Open questions
+
+- Should the dialect's deferred `execute` action reuse the run-ledger
+ machinery (roadmap 5.2) for recording test executions, or keep test runs out
+ of the ledger?
+- Whether `netsuke context --json` (roadmap 5.1) should enumerate the test
+ dialect schema alongside the manifest schema, and in what form.
+- The diagnostic-stack direction (miette versus anyhow) is in flight; the
+ framework binds to whichever lands, and its error surface should be reviewed
+ once that migration settles.
+
+## Recommendation
+
+Adopt the two companion designs; roadmap phase 7 tracks the delivery: overlay
+spike, seams, and loader options first (no behaviour change), then parser and
+mock engine, then fixtures, actions, CLI wiring, and an author-facing users'
+guide chapter. One gate is deliberate: after the seam phase lands, dogfood it
+by running the differential fidelity suite over the repository's own example
+manifests before the parser and mock-engine phases start — evidence that the
+substrate is sound, and a natural exit to Option C if the dialect's demand
+assumptions fail. The framework closes the verification gap for manifest
+authors, and its deferred execution mode has a designed, gated path when demand
+arrives.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index df6a04ff4..b801bb419 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -26,6 +26,9 @@ Each phase validates a product hypothesis:
template standard library makes declarative build manifests markedly easier
to write without weakening determinism or the capability boundary, including
proportionate quality-gate selection from a deterministic Git changeset.
+- Phase 7 validates that Netsukefile authors adopt manifest-time testing when
+ it is deterministic, mock-friendly, and runs through the same compiler as the
+ build.
Each phase carries one hypothesis, and Phase 6 is the capability track for
template standard-library work. Phases 3 to 5 predate that separation: each
@@ -74,7 +77,7 @@ These command and flag spellings are the public grammar assumed by this
roadmap. Examples must use this list unless a task explicitly extends it.
- Top-level commands: `build`, `check`, `clean`, `generate`, `graph`,
- `context`, `skill-path`, `runs`, `profile`, and `feedback`.
+ `context`, `skill-path`, `runs`, `profile`, `feedback`, and `test`.
- Resource verbs: `list`, `get`, `save`, `delete`, `add`, `send`, and `prune`.
- Structured output: `--json`.
- Non-interactive execution: `--no-input`.
@@ -672,7 +675,9 @@ configuration, inspect run history, route artefacts, and report friction.
- [ ] Verify successful JSON mode writes exactly one stdout document and
empty stderr.
- [ ] Verify failing JSON mode writes empty stdout and exactly one stderr
- diagnostic document.
+ diagnostic document. For `netsuke test` this means a command failure,
+ not a completed run reporting failed cases; see invariant I8 and
+ `6.6.2`.
- [ ] Depend on OrthoConfig `7.2.1`, `7.2.5`, and `8.1.1`.
- [ ] 5.5.3. Add error-remediation and exit-code tests.
@@ -1239,3 +1244,211 @@ untracked-file discovery, or per-linter file selection.
- Success: the documented example omits its Rust target for a Python-only
changeset, includes it for every Rust path class, and passes the repository
documentation and behavioural gates.
+
+## 7. Netsukefile testing framework
+
+Hypothesis: Netsukefile authors adopt manifest-time testing when it is
+deterministic, mock-friendly, and runs through the same compiler pipeline as
+the build.
+
+Objective: deliver the `netsuke test` command and YAML test dialect specified in
+[RFC 0007](rfcs/0007-netsukefile-testing-framework.md), the
+[UX and semantic design](netsuke-test-framework-ux-design.md), and the
+[technical design](netsuke-test-framework-technical-design.md).
+
+### 7.1. Seams and loader options
+
+- [ ] 7.1.1. Add the clock provider seam to the stdlib time module. See
+ [technical design §5.2](netsuke-test-framework-technical-design.md).
+ - [ ] Register `now()` through an injected `ClockProvider` closure held in
+ `StdlibConfig`.
+ - [ ] Preserve current behaviour when no provider is supplied.
+ - [ ] Test an injected provider value, repeated `now()` calls returning
+ it, and the ambient fallback when no provider is configured, all
+ registered through `StdlibConfig`.
+ - [ ] Record the seam classification per
+ [ADR-008](adr-008-environment-seam-taxonomy.md).
+
+- [ ] 7.1.2. Introduce the options-carrying manifest loader entry point. See
+ [technical design §4.3](netsuke-test-framework-technical-design.md).
+ - [ ] Add `ManifestLoadOptions` and `TemplateOverlays`, with existing
+ entry points as thin wrappers.
+ - [ ] Extend `StdlibRegistration` with a `Test` mode beside `Full` and
+ `ManifestQuery` rather than adding a parallel boundary mechanism.
+ - [ ] Reuse the `manifest_query_operation_error` diagnostic shape and
+ `disabled_env_reader` established by `netsuke help targets`.
+ - [ ] Register overlays after stdlib and manifest macros and before
+ `foreach` expansion.
+ - [ ] Add differential tests showing the build path is unchanged
+ (invariants I4 and I7).
+
+- [ ] 7.1.3. Spike MiniJinja overlay shadowing for macro substitution.
+ Requires: 7.1.2. See
+ [technical design §5.4](netsuke-test-framework-technical-design.md).
+ - [ ] Pin `add_function` replacement semantics with a test.
+ - [ ] Rewrite the `MACRO_IMPORTS_GLOBAL` prelude for substituted names;
+ `add_function` alone is shadowed by the generated
+ `{% from ... import %}` statement at render time.
+ - [ ] Pin runner-side handle capture for spy passthrough.
+ - [ ] Fall back to filtered macro registration if shadowing fails.
+
+- [ ] 7.1.4. Dogfood the seams before dialect work begins. Requires:
+ 7.1.1, 7.1.2, 7.1.3.
+ - [ ] Run the differential fidelity suite over the repository's example
+ manifests.
+ - [ ] Record the evidence in the RFC before starting 7.2.
+
+### 7.2. Test dialect parsing and discovery
+
+- [ ] 7.2.1. Add the optional `tests` block to the manifest schema. See
+ [UX design §3](netsuke-test-framework-ux-design.md).
+ - [ ] Keep `deny_unknown_fields` semantics for the block itself.
+ - [ ] Verify build-path neutrality with differential snapshots.
+ - [ ] Document the minimum-version consequence for older parsers.
+
+- [ ] 7.2.2. Implement the test-suite AST and parser. See
+ [technical design §6](netsuke-test-framework-technical-design.md).
+ - [ ] Partition known keys from dynamic `test_*` keys.
+ - [ ] Enforce the closed-schema and nearest-known-key diagnostics.
+ - [ ] Enforce the expression/template field split at parse time.
+ - [ ] Enforce the `netsuke_test_version` contract from RFC 0007: accept
+ the supported major and a minor at most the supported minor, with
+ tests for missing, malformed, unsupported-major, and newer-minor
+ values.
+
+- [ ] 7.2.3. Implement discovery and imports. Requires: 7.2.1, 7.2.2.
+ - [ ] Resolve `tests.root`, include and exclude patterns, and support
+ files.
+ - [ ] Confine imports to the test tree.
+ - [ ] Fail empty selections without `--allow-empty`.
+
+### 7.3. Mock engine
+
+- [ ] 7.3.1. Implement doubles, matchers, and the journal. Requires: 7.1.2.
+ See [UX design §8](netsuke-test-framework-ux-design.md) and
+ [technical design §7](netsuke-test-framework-technical-design.md).
+ - [ ] Implement stub, mock, and spy kinds with first-match-wins entries.
+ - [ ] Compile the closed matcher vocabulary at parse time.
+ - [ ] Journal every call with per-case isolation (invariants I1 and I3).
+
+- [ ] 7.3.2. Implement verification and reporting hooks. Requires: 7.3.1.
+ - [ ] Fail unmet mock expectations at end of case.
+ - [ ] Warn on unused doubles with the `lenient` opt-out.
+ - [ ] Render unmatched-call reports with suggested YAML entries.
+
+- [ ] 7.3.3. Implement macro substitution doubles. Requires: 7.1.3, 7.3.1.
+ - [ ] Register journalling wrappers over compiled stand-in macros.
+ - [ ] Journal calls under `substitutes.`.
+
+### 7.4. Fixture engine
+
+- [ ] 7.4.1. Add sandbox-rooted `glob()` and file-test adapters for the
+ test registration. Requires: 7.1.2. See
+ [technical design §5.5](netsuke-test-framework-technical-design.md).
+ - [ ] Resolve relative glob patterns against the case sandbox rather
+ than the process working directory.
+ - [ ] Resolve file-test paths through the sandbox handle instead of
+ `open_ambient_dir`, rejecting escapes.
+ - [ ] Leave the build path's ADR-010 behaviour unchanged.
+
+- [ ] 7.4.2. Implement the fixture lifecycle. See
+ [UX design §9](netsuke-test-framework-ux-design.md) and
+ [technical design §8](netsuke-test-framework-technical-design.md).
+ - [ ] Resolve `uses` dependencies with a topological sort.
+ - [ ] Run structured filesystem actions inside a `cap-std` sandbox.
+ - [ ] Guarantee reverse-order teardown on every exit path
+ (invariant I2, property-tested).
+
+- [ ] 7.4.3. Implement sandbox retention. Requires: 7.4.2.
+ - [ ] Support `--keep` for failing cases and print retained paths.
+
+### 7.5. Actions, assertions, and result views
+
+- [ ] 7.5.1. Implement pipeline actions. Requires: 7.1.2. See
+ [technical design §9](netsuke-test-framework-technical-design.md).
+ - [ ] Compose `load_manifest`, `build_graph`, and `generate_ninja` from
+ public library functions.
+ - [ ] Accumulate `results` across the case in execution order so
+ assertions can compare stages.
+ - [ ] Deny network, commands, and ambient environment under test
+ (invariant I5).
+
+- [ ] 7.5.2. Implement the case supervisor and frame protocol. Requires:
+ 7.4.3, 7.5.1. See
+ [technical design §9.1](netsuke-test-framework-technical-design.md).
+ - [ ] Run each case in a killable child process, keeping discovery,
+ scheduling, and reporting in the parent.
+ - [ ] Enforce the deadline with `wait_timeout`, then kill and reap,
+ reusing the pattern in `src/stdlib/command/execution.rs`.
+ - [ ] Carry `CaseResult` over length-prefixed `serde_json` frames
+ versioned like `src/json_envelope.rs`; add no new dependency.
+ - [ ] Synthesize an errored result with a timeout diagnostic when no
+ complete frame arrived, preserving any partial journal.
+ - [ ] Assign teardown ownership per the design's table, retain
+ timed-out sandboxes, and reap every child including on interrupt.
+ - [ ] Test a deliberately non-cooperative template expression,
+ termination, timeout reporting, fixture cleanup, child reaping, and
+ single-document `--json` output (invariant I10).
+
+- [ ] 7.5.3. Confine subject-manifest paths. Requires: 7.5.1. See
+ [UX design §10](netsuke-test-framework-ux-design.md) and
+ [technical design §8](netsuke-test-framework-technical-design.md).
+ - [ ] Resolve and validate the action `manifest` argument,
+ `given.subject`, and case-level `subject` after template evaluation
+ and before `open_manifest_workspace`.
+ - [ ] Reject absolute paths and sandbox escapes, including through
+ existing symlinked components.
+ - [ ] Admit the enclosing project's Netsukefile read-only, without
+ granting write access to the project root.
+ - [ ] Keep valid relative fixture paths working.
+ - [ ] Test every path source — the action `manifest` argument,
+ `given.subject`, and case-level `subject` — against an absolute path
+ and a traversal path such as `../../outside/Netsukefile`.
+ - [ ] Test a symlink escape, gated on the platform supporting symbolic
+ links.
+ - [ ] Test that a valid relative fixture manifest still resolves, and
+ that the enclosing-project Netsukefile is admitted read-only.
+
+- [ ] 7.5.4. Implement result views. Requires: 7.5.1.
+ - [ ] Expose manifest, graph, and Ninja views with the documented helper
+ surface.
+ - [ ] Keep views stable across internal IR changes.
+
+- [ ] 7.5.5. Implement assertion evaluation. Requires: 7.5.4.
+ - [ ] Normalize scalar and structured assertions.
+ - [ ] Distinguish failures from errors end to end.
+ - [ ] Implement `expect_failure` with named diagnostics.
+ - [ ] Render failing expressions with substituted actual values.
+
+### 7.6. Command, localization, and reporting
+
+- [ ] 7.6.1. Wire the `test` subcommand.
+ Requires: 7.2.3, 7.4.3, 7.5.2, 7.5.3, 7.5.5. See
+ [UX design §12](netsuke-test-framework-ux-design.md).
+ - [ ] Add filters, tags, `--list`, `--fail-fast`, `--timeout`, `--keep`,
+ and `--allow-empty`; consume the global `--json` and `--jobs`.
+ - [ ] Map exit codes 0 to 3 and 130 as specified.
+ - [ ] Implement per-case timeouts, interrupt handling, and
+ case-conservation reporting (invariant I9).
+
+- [ ] 7.6.2. Localize and report. Requires: 7.6.1.
+ - [ ] Add Fluent keys for report lines, diagnostics, and warnings.
+ - [ ] Emit one JSON document per run under the stream-purity contract
+ (invariant I8).
+
+- [ ] 7.6.3. Document the framework. Requires: 7.6.1.
+ - [ ] Add a users' guide chapter for authoring and running tests.
+ - [ ] Document the `--accessibility` output contract for `test` in the
+ users' guide, covering how case results, failure diagnostics, and the
+ run summary render under `--accessibility on`.
+ - [ ] Record the accessibility findings for `test` output in the
+ accessibility documentation, cross-referencing `3.8.3`.
+ - [ ] Update `contents.md`, the quickstart, and `context --json` follow-on
+ notes.
+ - [ ] Validate the documentation updates: check the users' guide and
+ accessibility entries against the shipped output, and keep the
+ documented contract in step with the display-policy behaviour.
+
+**Success criterion:** a Netsukefile author can write the worked example from
+[UX design §14.2](netsuke-test-framework-ux-design.md) and run it to a green
+result on a machine with no compiler, no network, and a fixed clock.