diff --git a/.config/nextest.toml b/.config/nextest.toml
index 0226a5fd3..8f92dba9d 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -99,6 +99,15 @@ success-output = "immediate"
# budget and the test's own allowance meet.
slow-timeout = { period = "60s", terminate-after = 10 }
+[[profile.default.overrides]]
+# The RFC 0006 coverage contract (obligation COV-4 in
+# docs/execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md).
+# This test's whole point is the line it prints: how many capability groups are
+# still unwritten. A half-finished split passes every other coverage check, so
+# a stall is only visible if the count reaches the terminal on a green run.
+filter = 'test(/^coverage_map_status_is_reported($|::)/)'
+success-output = "immediate"
+
[profile.ci]
# The CI-only profile. It exists solely to carry the whole-run budget, and
# inherits everything else from `default`, including that profile's
diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml
index 9db8c9f1d..b63111cc9 100644
--- a/.github/workflows/release-dry-run.yml
+++ b/.github/workflows/release-dry-run.yml
@@ -16,9 +16,9 @@ concurrency:
jobs:
release:
uses: ./.github/workflows/release.yml
- with:
- dry-run: true
# This workflow runs on pull requests, so it forwards no secrets. The
# release workflow reads only `secrets.GITHUB_TOKEN`, which a called
# workflow receives without forwarding, so `secrets: inherit` would
# only widen what a pull request can reach.
+ with:
+ dry-run: true
diff --git a/.gitignore b/.gitignore
index 3f04de14f..1821450a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ __pycache__/
.coverage
.uv-cache/
.uv-tools/
+uv.lock
.pytest_cache/
.ruff_cache/
.typos-oxendict-base.json
diff --git a/Makefile b/Makefile
index 33e203bf3..e4452262a 100644
--- a/Makefile
+++ b/Makefile
@@ -230,6 +230,10 @@ test: test-nextest doctest ## Run every Rust test with warnings treated as error
test-nextest: check-build-tools ## Run all non-doctest Rust tests through cargo-nextest
$(GATE_RUSTFLAGS) $(CARGO) nextest run --workspace --all-targets --all-features $(NEXTEST_BUILD_JOBS) $(NEXTEST_TEST_JOBS)
+.PHONY: test-rfc-stdlib-coverage
+test-rfc-stdlib-coverage: check-build-tools ## Check the RFC 0006 split and its parsers
+ $(GATE_RUSTFLAGS) $(CARGO) nextest run --test rfc_stdlib_coverage_tests --all-features $(NEXTEST_BUILD_JOBS) $(NEXTEST_TEST_JOBS)
+
doctest: check-build-tools ## Run doctests, which cargo-nextest cannot execute
$(GATE_RUSTFLAGS) $(CARGO) test --workspace --doc --all-features $(BUILD_JOBS)
diff --git a/docs/adr-040-focused-child-rfcs-for-survey-rfcs.md b/docs/adr-040-focused-child-rfcs-for-survey-rfcs.md
new file mode 100644
index 000000000..a280a3ce7
--- /dev/null
+++ b/docs/adr-040-focused-child-rfcs-for-survey-rfcs.md
@@ -0,0 +1,677 @@
+# Architectural decision record (ADR) 040: Focused child RFCs for survey RFCs
+
+## Status
+
+Accepted.
+
+## Date
+
+2026-09-11
+
+## Context and problem statement
+
+RFC 0006 is a survey RFC: it enumerates every ansible-core candidate it
+considered and records a disposition for each — accept, defer, or reject. Its
+section 7 is the candidate matrix, section 8 specifies the accepted helpers,
+and sections 9 and 10 record the deferred and rejected remainder. The survey
+accepted 57 new helpers, plus three options on existing ones.
+
+A survey RFC of that shape reaches a size at which its accepted set can no
+longer be specified to the standard a normative contract needs. RFC 0006
+section 6 states the cross-cutting contract every accepted helper must
+discharge — capability scoping, diagnostics, localization, purity, tests, and
+documentation — and closes by saying that a child which does not satisfy every
+clause is not complete. Discharging that contract for 60 helpers inside a
+2,100-line survey is not reviewable. The per-helper obligations are the part a
+reviewer must read closely, and they sit in the part of the document least
+likely to be read closely, because the surrounding survey is a catalogue.
+
+The difficulty is not specific to RFC 0006. Any survey RFC with a large
+accepted set has the same shape, and the same absence of a mechanical check
+that its accepted set is fully allocated. Nothing in the build would notice a
+helper that the survey accepted, no child RFC owns, and no roadmap task
+schedules.
+
+## Decision drivers
+
+- **Reviewability.** A reviewer should be able to hold one capability group's
+ obligations in mind at once, without the rest of the catalogue competing for
+ attention.
+- **Traceability.** Every accepted helper must resolve to one owner and one
+ scheduled task, and no candidate the survey rejected may reappear as a
+ registered helper under a rejected spelling.
+- **Reversibility.** The split must be abandonable part-way. The survey's own
+ sections 7 to 10 must keep their meaning whether one child RFC exists or
+ eight do.
+- **Derivation over transcription.** The enforcement must read the documents
+ rather than restate them, so a document edit and a test edit cannot disagree.
+- **Narrow scope.** The convention must not be mistaken for a general
+ amendment mechanism, because the corpus already has one.
+
+## Requirements
+
+### Functional requirements
+
+- The survey RFC's accepted set is partitioned into capability groups, and each
+ helper in that set is owned by exactly one focused child RFC and delivered by
+ exactly one roadmap step. Groups and children are not in one-to-one
+ correspondence: a child may own more than one group where a single roadmap
+ step delivers them, and a group may be divided between two children where the
+ roadmap's boundary falls inside it. What is excluded is a helper claimed
+ twice, or claimed by nobody.
+- Each child RFC carries the survey's cross-cutting contract, discharged
+ per clause, and a registry of the helpers it introduces.
+- The survey RFC records the allocation in a coverage map, one row per child,
+ naming the section 8 subsections that child owns.
+- A rejected or deferred candidate is absent from every child registry.
+
+### Technical requirements
+
+- The allocation is checked by a repository test that reads the survey RFC,
+ the child RFCs, and the roadmap, and derives from them every fact it asserts:
+ the accepted and deny sets, each helper's namespace and registration kind,
+ the table 11 counts, the section 6.1 purity aggregate, section 6's clause
+ list, each child's registry, and the roadmap steps. The test does carry a
+ small number of constants, and they are deliberately not facts about the
+ survey but anchors for reading it — which subsection numbers hold candidate
+ tables, the three renamed helpers, the three optioned helpers, and the
+ proposed-helper count used to cross-check the derived one. An anchor is prose
+ the parser has to be told where to look for; a fact is prose the parser is
+ asked to reproduce. Only the latter can go stale in a way a reviewer would
+ miss, and the amendment procedure below is what keeps the former aligned.
+- The test names the file and line of a violation and states the violated
+ obligation.
+- The survey RFC's own disposition sections are not moved, so an abandoned
+ split leaves every existing cross-reference and contract intact.
+
+## Options considered
+
+### Option A: focused child RFCs with a coverage map
+
+One child RFC per capability group, each owning a contiguous slice of the
+survey's section 8 where the group boundaries allow it, with the allocation
+recorded in the survey and enforced by a derived test. The survey keeps its
+candidate matrix, its dispositions, and its clause list; it gains only the map.
+
+### Option B: registries in place, no child RFCs
+
+Keep the accepted set in the survey RFC and add a registry table per capability
+group inside section 8, roughly 140 lines in total. No RFC number is spent and
+nothing is irreversible, but the per-helper obligations still live in the
+survey, and a normative contract discharge — which is what a reviewer signs off
+— never receives its own review.
+
+### Option C: unnumbered per-group design documents
+
+Write one design document per group on the pattern the repository already uses
+for roadmap step 6.11. Cheaper than Option A and fully reversible, but a
+document that is not an RFC cannot carry a normative contract discharge, and
+the commissioned task called for RFCs.
+
+| Topic | Option A | Option B | Option C |
+| -------------------- | ------------- | ------------ | --------------- |
+| Reviewable unit | One group | Whole survey | One group |
+| RFC numbers spent | Eight | None | None |
+| Reversible part-way | Yes | Yes | Yes |
+| Contract discharged | Per child RFC | In place | In a design doc |
+| Mechanically checked | Yes | Yes | Yes |
+
+_Table 1: Comparison of the split options._
+
+## Decision outcome / proposed direction
+
+A survey RFC whose accepted set is too large to specify in place is split into
+focused child RFCs, each owning one or more capability groups, and records the
+allocation in a coverage map in its own delivery section. Option A is adopted
+for RFC 0006.
+
+The convention has four parts:
+
+1. **The survey keeps its dispositions.** Sections 7 to 10, the clause list in
+ section 6, and the numbering are unchanged. The split adds a coverage map;
+ it does not move a specification.
+2. **One child per capability group.** Each child RFC owns a slice of the
+ survey's accepted-helper sections, and carries that slice's registry, its
+ per-clause discharge, and its per-helper obligations. Usually that slice is
+ one whole group; sometimes several groups travel together because one
+ roadmap step delivers them, and sometimes one group is divided between two
+ children because the roadmap's boundary falls inside it. A slice is sized so
+ a reviewer can read the whole child in one sitting.
+3. **The map is the allocation.** One row per child, naming the section 8
+ subsections owned, the existing helpers gaining an option, the roadmap step
+ that delivers the group, and whether the child has been written. The rows
+ partition the accepted set exactly.
+4. **A derived test is the guard rail.** The test reads the survey's section 7
+ disposition column, section 8, section 14's map, each child registry, and
+ the roadmap, and fails on a dropped, double-owned, or forbidden name.
+
+The scope is deliberately narrow. The convention applies to **survey RFCs**:
+documents that enumerate a large candidate set with a per-candidate
+disposition. It does not apply to design documents, and it does not replace the
+`Amends` convention that RFCs 0009 to 0011 use for normative amendments to RFC
+0001. A normative change to an existing RFC is still an amendment to that RFC,
+not a new child of it.
+
+### The child RFC template
+
+A child RFC follows the repository's RFC sections in the order the
+[documentation style guide](documentation-style-guide.md) requires, and adds
+section 5, which is where the cross-cutting contract is discharged. Copy the
+skeleton below literally. Three of its headings are parsed by name, so a child
+that renames one fails before any of its content is read; each carries a note
+naming the constant that reads it.
+
+```markdown
+# RFC 00NN:
+
+## Preamble
+
+- **RFC number:** 00NN
+- **Status:** Proposed
+- **Created:** YYYY-MM-DD
+- **Parent RFC:** RFC 0006, Ansible-inspired template standard-library
+ expansion
+- **Roadmap step:** 6.N
+- **Originating issue:** [#596](https://github.com/leynos/netsuke/issues/596)
+ (closed)
+- **Release target:** v0.1.x or later; must not widen the v0.1.0 hardening
+ release defined by [#594](https://github.com/leynos/netsuke/issues/594)
+
+## 1. Summary
+
+
+
+## 2. Problem
+
+
+
+## 3. Goals and non-goals
+
+- Goals:
+ -
+- Non-goals:
+ -
+
+## 4. Capability set
+
+
+
+## 5. Cross-cutting contract conformance
+
+### 5.1. Registry
+
+
+
+### 5.2. Manifest-query availability
+
+### 5.3. Determinism
+
+### 5.4. Capability boundary
+
+### 5.5. Platform contract
+
+### 5.6. Type and error contract
+
+### 5.7. Canonical value equality
+
+### 5.8. Resource bounds
+
+### 5.9. Diagnostics and localization
+
+### 5.10. Naming and alias policy
+
+### 5.11. Documentation and testing obligations
+
+### Clause discharge
+
+
+
+| Clause | Discharge |
+| ------ | --------- |
+| `6.1` | <...> |
+| `6.11` | <...> |
+
+## 6. Dependencies
+
+
+
+## 7. Delivery
+
+
+
+## 8. Open questions
+
+
+
+## 9. Recommendation
+
+
+```
+
+### The registry row shape
+
+Section 5.1's table has exactly these five columns. The cells are parsed, not
+prose, and each column has one accepted vocabulary:
+
+| Helper | Namespace | Registration | Purity class | Manifest query |
+| ----------- | --------- | ------------ | ------------ | -------------- |
+| `path_join` | Filter | New | Pure | Yes |
+| `abs` | Test | New | Pure | Yes |
+| `basename` | Filter | Option added | Pure | Yes |
+
+_Table 2: The registry row shape._
+
+- **Helper** — backticked; a name registered twice in one registry is an error.
+- **Namespace** — `Filter`, `Test`, or `Function`.
+- **Registration** — `New`, or `Option added` for one of the three existing
+ helpers gaining a behaviour-preserving option. The distinction is
+ load-bearing rather than descriptive: the purity aggregate counts `New` rows
+ only, because RFC 0006 section 6.1's 52/4/1 counts the 57 proposed helpers,
+ and an `Option added` row is an existing helper being extended.
+- **Purity class** — one of RFC 0006 table 2's six values.
+- **Manifest query** — `Yes` or `No`, matching the column of the same name in
+ RFC 0006 table 2 rather than introducing a second vocabulary for the same
+ fact. The cell is cross-checked against the purity class: `Yes` is admissible
+ only for a pure helper, because clause 6.2 admits only pure helpers to the
+ manifest-query environment and registers the non-pure ones as always-failing
+ stubs rather than omitting them. A non-pure row therefore reads `No` and
+ still resolves in both environments; the `No` is the stub's disposition, not
+ its absence.
+
+### The worked section
+
+The template above is a shape. This is that shape filled in, for RFC 0013, the
+group roadmap step 6.2 delivers: the first child written from it, and so the
+one that settles what a discharge reads like before seven more inherit whatever
+it settles. It is a specimen, not the artefact. When RFC 0013 is written it
+carries this section, and that copy is the normative one.
+
+It is reproduced filled in rather than described, because the two things a
+reviewer has to judge — whether a clause subsection says something the clause
+does not, and whether the discharge table resolves all eleven clauses — are
+visible only in a completed copy. One formatting note carries over with it:
+`markdownlint` caps a fenced block at 120 columns and `mdtablefix` does not
+reflow one, so the tables below are sized to fit rather than laid out for the
+page. A child RFC is real Markdown rather than a fence, so it inherits neither
+limit, and its tables may be as wide as they read best.
+
+```markdown
+## 5. Cross-cutting contract conformance
+
+This section discharges RFC 0006 section 6 for the five helpers section 4
+lists. Where a clause's consequence follows from RFC 0006 alone and is the same
+for every group, the subsection says so in one line; where this group forces a
+decision, the subsection states the decision.
+
+### 5.1. Registry
+
+| Helper | Namespace | Registration | Purity class | Manifest query |
+| --------------- | --------- | ------------ | ------------ | -------------- |
+| `from_json` | Filter | New | Pure | Yes |
+| `from_yaml` | Filter | New | Pure | Yes |
+| `from_yaml_all` | Filter | New | Pure | Yes |
+| `to_yaml` | Filter | New | Pure | Yes |
+| `to_nice_json` | Filter | New | Pure | Yes |
+
+All five rows are `New`: this group introduces five helpers and extends none.
+All five are pure, so all five fall inside RFC 0006 section 6.1's 52, and this
+RFC accounts for 5 of them.
+
+### 5.2. Manifest-query availability
+
+All five are pure, so `from_json`, `from_yaml`, `from_yaml_all`, `to_yaml`, and
+`to_nice_json` all register in `register_query_helpers`, and none registers in
+`register_disabled_query_helpers` as a stub. The consequence is that
+`netsuke help targets` gains five working helpers and no new always-failing
+stub. One caveat, carried rather than resolved here: if RFC 0006 section 16
+question 1 is answered by registering `to_nice_yaml` solely to raise a
+diagnostic naming `to_yaml(indent=4)`, that registration introduces no accepted
+helper and so is not a section 5.1 row; it is decided in section 8.
+
+### 5.3. Determinism
+
+Two group-specific obligations follow from clause 6.3, and both are testable
+without reading prose.
+
+- **Key order is an output, not a side effect.** `from_json` preserves object
+ order because `serde_json` is built with `preserve_order`; `from_yaml`
+ preserves mapping order; `to_yaml` and `to_nice_json` emit insertion order
+ unless `sort_keys=true`. A round trip therefore returns a mapping in the
+ order it went in, and the tests assert order, not merely equality.
+- **Trailing-newline behaviour is a contract, not a convention.** `to_yaml`
+ ends with exactly one trailing newline and `to_nice_json` with none, so one
+ composes as a whole document and the other composes inside a larger one. The
+ asymmetry is deliberate and is pinned by a test at each end.
+
+### 5.4. Capability boundary
+
+No additional obligation beyond RFC 0006 section 6.4. The clause's substantive
+rules all address helpers that reach outside their arguments, and no helper in
+this group does: none takes a `cap_std` handle, none takes an injected reader,
+and none can violate the clause's trapdoor rule about a filesystem predicate
+reporting `false` for an out-of-scope path.
+
+### 5.5. Platform contract
+
+No additional obligation beyond RFC 0006 section 6.5. The clause's obligations
+attach to helpers whose behaviour varies by platform or that parse another
+platform's syntax: no helper here takes a `dialect` argument, none can fail
+with a platform diagnostic, and all five emit LF on every platform.
+
+### 5.6. Type and error contract
+
+RFC 0006 section 8.1 specifies the kinds each helper accepts. What follows is
+the conditions under which a kind, a key, or an option value is rejected, each
+carrying a code from section 5.9. Per helper, because the three parsing
+helpers and the two serializers do not share a rejection set.
+
+- `from_json` accepts a string. It rejects `wrong_kind`, `syntax`,
+ `duplicate_key`, `depth_exceeded`, and `length_exceeded`.
+- `from_yaml` accepts a string. It rejects every `from_json` condition, and adds
+ `unsupported_key` for a sequence or mapping key, `special_tag`, `merge_key`,
+ `alias_budget`, and `document_count` for a stream that is not exactly one
+ document.
+- `from_yaml_all` accepts a string and applies every per-document condition from
+ `from_yaml` to each document. `document_count` is the one condition it does
+ **not** inherit: RFC 0006 section 8.1 makes a stream of zero documents an
+ empty sequence, not an error, and multi-document input is the whole point of
+ the helper. The input-length and node budgets apply to the whole stream
+ rather than to each document.
+- `to_yaml` accepts any value except undefined. It rejects `undefined_input`
+ and `indent_out_of_range` outright, plus `unsupported_key` when
+ `sort_keys=true` meets a mapping key with no canonical JSON form,
+ `unsupported_kind` for a value that has none, and `output_too_large` when
+ the rendered output would exceed the ceiling section 5.8 enforces.
+- `to_nice_json` accepts any value except undefined, and rejects the same five
+ conditions as `to_yaml`, with the difference that section 8.1 states its key
+ rule directly: integer and boolean keys are rendered in canonical string
+ form and every other key kind is rejected rather than coerced. Distinct source
+ keys that render to one JSON key are rejected with `duplicate_key`.
+
+Three decisions this group adds:
+
+- **`none` is accepted; undefined is not.** Clause 6.6 makes undefined an error
+ and makes `none` a value. Every helper here follows that split, and the line
+ between them is where a manifest author is most likely to be surprised, so
+ each of the five documents it explicitly rather than leaving the clause to
+ imply it.
+- **A duplicate key is rejected with a position.** `from_json` and `from_yaml`
+ name the duplicated key and the offset of its second occurrence. This is the
+ one place the group adds detection the underlying parser does not perform,
+ and the reason is that last-key-wins is a silent data-loss trapdoor in a
+ build manifest rather than a tolerable convenience.
+- **The two `indent` ranges differ on purpose.** `to_yaml` accepts 1 to 8 and
+ `to_nice_json` accepts 0 to 8. Zero is meaningful for JSON, where it means
+ compact output, and meaningless for block YAML, where it would mean no
+ indentation at all. Both reject an unknown value with an error enumerating
+ the accepted range, satisfying clause 6.6's enumerated-option rule and
+ roadmap task 3.15.5.
+
+### 5.7. Canonical value equality
+
+Clause 6.7 governs serializer key sorting and canonical round-trip claims.
+The obligation here is to say which of the clause's
+exclusions this group can meet, and to fix how the round trips are stated.
+
+- `sort_keys=true` sorts mapping keys by their RFC 8785 canonical key, so two
+ mappings that differ only in insertion order serialize identically. That is
+ clause 6.7's relation applied to a key-ordering decision, and it is why the
+ option can promise deterministic output at all.
+- The clause excludes undefined, callables, and the `now()` timestamp object,
+ because none has a canonical JSON form. Undefined is already rejected on
+ input by clause 6.6; the other two are rejected on output as
+ `unsupported_kind`, because a manifest can hold a callable or the result of
+ `now()` and pass it to a serializer, and clause 6.7 makes that a typed error
+ naming the value kind rather than a silent rendering.
+- The group defines **no second equality relation** for round-trip testing.
+ Both `value | to_yaml | from_yaml` and
+ `value | to_nice_json | from_json` are asserted equal under clause 6.7 only
+ for values in its canonical-JSON domain: JSON-compatible values whose mapping
+ keys are strings at every nesting level. The serializer may accept other
+ values, but their conversion does not promise canonical equality.
+- **Integer and boolean JSON keys convert lossily.** A single integer key `1`
+ serializes as the JSON property `"1"`; a single boolean key `true` serializes
+ as `"true"`. `from_json` reads either as a string key, so neither input is
+ within the JSON round-trip guarantee. The same applies when a mapping with
+ such keys is nested inside a sequence. String-keyed mappings, including
+ nested mappings, remain within the guarantee.
+- **Rendered-key collisions remain errors.** A mapping containing integer key
+ `1` alongside string key `"1"`, or boolean key `true` alongside string key
+ `"true"`, is rejected with `duplicate_key`, naming both source keys. The
+ check is on rendered keys because that is where the collision occurs.
+
+### 5.8. Resource bounds
+
+The bounds are RFC 0006 table 3's, applied through checked comparison before
+allocation. What this group adds is where each one is enforced, because the JSON
+and YAML parsers do not share a code path.
+
+| Helper | Bounds enforced |
+| --------------- | ---------------------------------------------------- |
+| `from_json` | input 8 MiB; nesting depth 128 |
+| `from_yaml` | input 8 MiB; depth 128; alias expansion 100000 nodes |
+| `from_yaml_all` | the same three, over the whole stream |
+| `to_yaml` | output 8 MiB; checked before the result is returned |
+| `to_nice_json` | output 8 MiB; checked before the result is returned |
+
+Three consequences this group decides:
+
+- **`from_yaml_all`'s budget is stream-wide.** A stream of many small documents
+ is bounded in total, so a caller cannot evade the input ceiling by splitting
+ an expansion bomb across document boundaries. The diagnostic reports the
+ stream total rather than a per-document figure, which is what makes the bound
+ auditable after the fact.
+- **The alias budget is the one bound that can fail the implementation slice
+ rather than the input.** Clause 6.8 requires the expansion to be rejected
+ before allocating, and if `serde-saphyr` cannot bound alias expansion then
+ this RFC rejects aliases outright and records that in the guide, per RFC 0006
+ section 8.1 and roadmap task 6.2.2. That choice is carried as section 8's
+ open question 4 and is not resolved here.
+- **The serializers bound their output because a shared value is not a bounded
+ input.** An earlier draft of this group read "none" into both rows, on the
+ reasoning that a serializer allocates proportionally to nothing but the value
+ it is handed. That reasoning is wrong, and the runtime shows how: MiniJinja
+ values are reference-counted, so a value built by repeated doubling is a graph
+ whose *logical* size is exponential in its construction depth, while its
+ in-memory footprint stays linear. One recursive macro with twenty doublings
+ emits `{{ v | to_nice_json }}` as **4,194,301** bytes — 2× per level, from a
+ four-line template with no large input anywhere. Clause 6.8 names
+ "materialized output" precisely for this and requires the rejection *before*
+ allocating, so counting bytes as they are written is not enough. Each
+ serializer counts first: a pass walks the value computing the output length
+ with checked arithmetic, abandoning the walk the moment the running total
+ passes the ceiling, so a doubled value stops after 8 MiB of *logical* nodes
+ instead of expanding. Only a value that fits is then written. The failure is
+ `output_too_large`, at the same 8 MiB ceiling the parsers apply to input.
+
+### 5.9. Diagnostics and localization
+
+The group defines one private domain error enum, `InterchangeError`, and
+exactly one `impl From for minijinja::Error`, per clause 6.9.
+Every message is a Fluent key and every error carries a machine code. The codes
+are this group's contribution to clause 6.9's policy, so they are enumerated
+rather than described.
+
+| Condition | Code |
+| ------------- | ----------------------------------------------- |
+| not a string | `netsuke::jinja::interchange::wrong_kind` |
+| syntax | `netsuke::jinja::interchange::syntax` |
+| duplicate key | `netsuke::jinja::interchange::duplicate_key` |
+| key kind | `netsuke::jinja::interchange::unsupported_key` |
+| special tag | `netsuke::jinja::interchange::special_tag` |
+| merge key | `netsuke::jinja::interchange::merge_key` |
+| alias budget | `netsuke::jinja::interchange::alias_budget` |
+| document count | `netsuke::jinja::interchange::document_count` |
+| depth | `netsuke::jinja::interchange::depth_exceeded` |
+| length | `netsuke::jinja::interchange::length_exceeded` |
+| undefined | `netsuke::jinja::interchange::undefined_input` |
+| indent | `netsuke::jinja::interchange::indent_out_of_range` |
+| value kind | `netsuke::jinja::interchange::unsupported_kind` |
+| output length | `netsuke::jinja::interchange::output_too_large` |
+
+Each code's Fluent key is the code's reason in upper snake case under
+`STDLIB_INTERCHANGE_`, so `wrong_kind` pairs with
+`STDLIB_INTERCHANGE_WRONG_KIND` and `indent_out_of_range` with
+`STDLIB_INTERCHANGE_INDENT_OUT_OF_RANGE`, per clause 6.9's
+`keys::STDLIB__` form.
+
+The module segment is `interchange` rather than `json` or `yaml`, because one
+enum serves both parsers and both serializers and the code names the capability
+group, not the syntax. All five helpers — `from_json`, `from_yaml`,
+`from_yaml_all`, `to_yaml`, and `to_nice_json` — reach their errors through this
+enum: every `Error::new` call in the group's leaf functions is replaced by a
+variant of it, so a caller can tell an interchange failure from a manifest
+diagnostic by the code alone. Clause 6.9 rejects ad hoc construction at this
+scale, and a group with fourteen conditions is the case it names.
+
+### 5.10. Naming and alias policy
+
+No additional obligation beyond RFC 0006 section 6.10. The clause registers one
+name per capability and this group adds five, none an alias. One of the clause's
+own examples is still live here rather than settled: `to_nice_yaml` is rejected
+by RFC 0006 section 10.2 as redundant with `to_yaml(indent=...)`, and whether
+that rejection is expressed as outright absence or as a diagnostic-raising
+registration is RFC 0006 section 16 question 1, carried unresolved to section 8
+and decided at roadmap task 6.2.3.
+
+### 5.11. Documentation and testing obligations
+
+No additional obligation beyond RFC 0006 section 6.11. The clause's seven
+obligations apply unmodified. One group-specific note rather than a
+restatement: the two round-trip properties required by clause 6.11.4 use the
+canonical-JSON domain stated in section 5.7. The serializer's lossy integer
+and boolean key cases and collision errors are separate acceptance tests. The
+serialization-determinism property it names is the same proposition as section
+5.3's key-order requirement, tested from the other side.
+
+### Clause discharge
+
+| Clause | Discharge |
+| ------ | ------------------------------------------------------------- |
+| `6.1` | Five pure `New` helpers; the five section 5.1 rows are 5 of 52. |
+| `6.2` | All five pure, so all register in `register_query_helpers`, none stubbed. |
+| `6.3` | Mapping order in and out; one trailing newline for `to_yaml`, none for `to_nice_json`. |
+| `6.4` | No filesystem, environment, or subprocess access; no handle taken. |
+| `6.5` | No `dialect` argument; all five emit LF everywhere. |
+| `6.6` | Undefined rejected, `none` accepted; duplicates rejected positionally; both `indent` ranges enumerated. |
+| `6.7` | `sort_keys` sorts by canonical key; round trips use canonical-JSON domain; converted JSON keys are lossy. |
+| `6.8` | Table 3's bounds, stream-wide for `from_yaml_all`; serializers pre-check output length. |
+| `6.9` | One enum, one `From` impl, fourteen `netsuke::jinja::interchange::*` codes. |
+| `6.10` | Five new names, no alias family, none reused across namespaces. |
+| `6.11` | The clause's seven obligations, plus the two round trips and the determinism property. |
+```
+
+## Goals and non-goals
+
+### Goals
+
+- Make the survey's accepted set fully allocated and mechanically checked.
+- Give each capability group one reviewable, normative document.
+- Keep the split reversible at every milestone before the last.
+
+### Non-goals
+
+- Changing any disposition the survey recorded.
+- Generalizing the convention beyond survey RFCs.
+- Replacing the amendment convention for normative changes to existing RFCs.
+- Scheduling the work outside the roadmap. Delivery is tracked by roadmap
+ tasks, not by per-child issues.
+
+## Amendment procedure
+
+When a helper is added, removed, or renamed after the split, edit the
+touchpoints below in this order. The order matters only in that each step's
+subject must exist before the next step can cite it; the coverage test then
+fails until all of them agree.
+
+1. **The survey's section 7 row** — record or change the disposition, and cite
+ the section 8 subsection that specifies the helper.
+2. **The survey's section 8 subsection** — add, remove, or rename the helper so
+ the specification matches.
+3. **The survey's section 14 coverage map** — the owning row's `Owns` clause
+ must still claim the helper. A new section 8 subsection needs a row to own
+ it, and a ninth capability group needs a new child RFC number and a new row.
+4. **The owning child RFC's registry** — its section 5.1 row carries the
+ helper, namespace, registration kind, purity class, and manifest query.
+5. **The roadmap task** in the owning step — name or rename the helper there,
+ so the capability remains scheduled.
+
+Three further touchpoints are not per-helper, and each is reached by a change
+of a different kind. They are listed separately because a helper edit does not
+touch them and an edit that does is easy to forget:
+
+1. **The survey's section 3.2 namespace lists** — a helper added to a namespace
+ the survey does not already list there belongs in the corresponding list.
+ The test derives each helper's namespace from section 7, so a section 3.2
+ list is not what it reads; the lists are what a _reviewer_ reads, and a
+ helper absent from both is a helper the survey never introduces.
+2. **The survey's section 6.1 purity statement and table 11 counts** — both are
+ prose counts written in number words, and both are derived rather than
+ transcribed by the test. Changing a helper's purity class therefore means
+ editing the sentence that states how many helpers are pure, and adding or
+ removing an accepted helper means editing table 11's totals. The test fails
+ when they disagree, so this step is enforced rather than merely advised.
+3. **The test's own anchors** — the subsection numbers, the renamed and
+ optioned helper lists, and the proposed-helper count named in the technical
+ requirements above. These are the only places a survey fact is written down
+ twice. A change that moves a candidate table to a different subsection, or
+ that changes which helpers are renamed or optioned, must edit the anchor and
+ the survey together; a change that edits only one of them stops the suite
+ reading the survey at all, which is a louder failure than a wrong count.
+
+Removing a helper without removing its registry row fails the ownership check,
+which reports a name the survey no longer accepts. Renaming one moves the old
+spelling into the deny set, because the deny set is the complement of the
+accepted set; a child registry that reintroduces the old spelling then fails
+the forbidden-name check. Neither failure needs a new rule: both follow from
+the derivation.
+
+## Known risks and limitations
+
+- **The survey is not ratified.** RFC 0006 is `Proposed`, and no RFC in the
+ corpus has been ratified. Freezing its dispositions into a test may prove
+ premature. The mitigation is structural: because section 8 does not move, a
+ later change costs a registry row and a map row rather than a document
+ rewrite.
+- **Eight consecutive numbers are spent.** Allocation is irreversible once
+ merged. Numbers are therefore allocated lazily, one per child at the commit
+ that creates it, and a reservation recorded in a branch reserves nothing.
+- **The `Owns` grammar is small by design.** It expresses "every helper in
+ this section", "all but one", and "only this one", with clauses joined by
+ semicolons. A capability group that needs a different split requires a new
+ clause form and a parser change, which is the intended cost of keeping the
+ grammar reviewable.
+- **The split is disproportionate for a small accepted set.** The convention
+ earns its cost only when the accepted set cannot be specified in place; for a
+ handful of helpers, Option B is cheaper and is the fallback.
+
+## Architectural rationale
+
+The repository's documents are the source of truth for their own contracts, and
+its tests derive what they check rather than transcribing it. This decision
+applies both: the survey remains the only place a disposition is recorded, and
+the child RFCs remain the only place a group's obligations are specified, with
+the coverage map as the single join between them. Splitting by capability
+rather than by delivery slice keeps each child aligned with one roadmap step
+and one purity profile, so a child's contract is homogeneous enough to review
+as a unit.
+
+## Implementation references
+
+- Survey and coverage map:
+ [RFC 0006 section 14.13](rfcs/0006-ansible-inspired-template-standard-library.md)
+- Coverage contract test: `tests/rfc_stdlib_coverage_tests.rs` and the
+ `tests/rfc_stdlib_coverage/` module tree
+- Delivery tracking: [Netsuke roadmap section 6](roadmap.md)
+- Child RFCs 0013 to 0020, reserved by RFC 0006 section 14.13
diff --git a/docs/contents.md b/docs/contents.md
index 323b54933..0cd15077b 100644
--- a/docs/contents.md
+++ b/docs/contents.md
@@ -75,6 +75,9 @@ operator, user, and contributor references are easier to find.
Survey of the ansible-core Jinja standard library, with an explicit
disposition for every candidate helper and Netsuke-native contracts for the
accepted set.
+- [rfcs/0013-structured-data-interchange-helpers.md](rfcs/0013-structured-data-interchange-helpers.md):
+ First focused child of RFC 0006: the JSON and YAML interchange helpers and
+ the cross-cutting contract discharged for them.
- [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.
@@ -232,6 +235,10 @@ operator, user, and contributor references are easier to find.
Runtime annotation introspection declared unsupported for the workflow
contract tests, with the loader gate's stale `TYPE_CHECKING` claim corrected
and a revisit gate that reopens on a real consumer.
+- [ADR-040](adr-040-focused-child-rfcs-for-survey-rfcs.md): Splitting a survey
+ RFC into focused child RFCs, each owning one or more capability groups, with
+ the accepted set partitioned by a coverage map and guarded by a
+ derivation-based coverage test.
- [ADR-041](adr-041-canonical-recipe-shell-quoting-surface.md): `shell_quote`
and `shell_join` as the canonical recipe quoting surface, with two dialects
and a host-dependent default.
diff --git a/docs/developers-guide.md b/docs/developers-guide.md
index e36eb5c29..9c9dd03ed 100644
--- a/docs/developers-guide.md
+++ b/docs/developers-guide.md
@@ -4567,6 +4567,58 @@ provisions `pytest`, `pyyaml`, `hypothesis`, and `cmd-mox==0.2.0` through
`uv run --with`, so `uv` is the only prerequisite and no virtual environment
needs creating by hand.
+### RFC 0006 standard-library coverage contract
+
+`tests/rfc_stdlib_coverage_tests.rs` runs seven repository checks:
+
+| Check | Responsibility |
+| --------------------------------------------- | --------------------------------------------------- |
+| `every_accepted_helper_has_exactly_one_owner` | Partition accepted helpers across child RFCs. |
+| `no_forbidden_helper_is_registered` | Keep deferred and rejected candidates out. |
+| `totals_and_purity_aggregate_agree` | Reconcile accepted totals and purity counts. |
+| `coverage_map_status_is_reported` | Report written and unwritten capability groups. |
+| `inter_document_links_resolve` | Resolve relative RFC links to files in the tree. |
+| `every_capability_has_a_roadmap_task` | Match owned helpers to tasks in the assigned step. |
+| `every_child_discharges_every_clause` | Check child RFC clause coverage and section 5 text. |
+
+The private modules in `tests/rfc_stdlib_coverage/` parse the source documents,
+derive helper sets, and validate the partition. `survey`, `section7`,
+`section8`, `totals`, and `assertions` read RFC 0006; `map`, `registries`, and
+`clauses` read its coverage map and each child RFC; `roadmap` reads
+`docs/roadmap.md`; `links` checks relative targets across the RFC corpus.
+`document` and `markdown` provide the shared structural and lexical parsing
+used by those readers. The contract therefore depends on the Markdown in RFC
+0006, child RFCs, and the roadmap staying in the supported shapes.
+
+Partial coverage can pass: unwritten child groups are allowed while the split
+is in progress, and the status check reports rather than rejects that state.
+The output count is the liveness signal; it must reach zero before the split is
+complete. `.config/nextest.toml` gives
+`test(/^coverage_map_status_is_reported($|::)/)` the
+`success-output = "immediate"` override so this count appears during the suite,
+as soon as this test finishes, including on a successful run.
+
+Run `make test-rfc-stdlib-coverage` for the focused Rust contract binary,
+`make test-workflow-contracts` for the workflow contracts, and `make test` for
+the full Rust suite. The focused target uses the repository's standard Rust
+gate flags and Nextest configuration. The parser tests live beside the private
+functions they exercise; filesystem cases use isolated temporary fixtures via
+the test-only `Repo` constructor, which is private to that module tree. Those
+fixtures are not a general-purpose Markdown parsing API. The link property's
+private depth helper belongs only to its reference model: it checks traversal
+before the model renders the normalized path. The Nextest contract's private
+policy helper checks only an override already selected by the exact filter; its
+caller owns loading and selection. Neither helper is a shared parser or a
+runtime interface.
+
+The parser intentionally handles a narrow subset: ATX headings, simple
+pipe-delimited table rows, and fenced blocks with up to three leading spaces.
+It does not parse full CommonMark or recognize fences nested in list items.
+Relative links are extracted from inline Markdown link syntax and checked for
+target-file existence; fragment anchors are not validated. See
+[ADR-040](adr-040-focused-child-rfcs-for-survey-rfcs.md) for the split and its
+coverage contract rationale.
+
### Configuration-precedence regression tests
The config-precedence ladder and display-policy domain are covered by three
diff --git a/docs/execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md b/docs/execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md
new file mode 100644
index 000000000..eae6f223f
--- /dev/null
+++ b/docs/execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md
@@ -0,0 +1,4312 @@
+# 6.1.1. Split the RFC 0006 accepted set into focused child RFCs
+
+This ExecPlan (execution plan) is a living document. The sections `Constraints`,
+`Tolerances (exception triggers)`, `Risks`, `Progress`,
+`Surprises & discoveries`, `Decision log`, `Outcomes & retrospective`,
+`Conformance basis`, and `Verification plan` must be kept up to date as work
+proceeds.
+
+Status: IN PROGRESS
+
+## Purpose / big picture
+
+[RFC 0006](../rfcs/0006-ansible-inspired-template-standard-library.md) surveys
+every filter, test, and function exposed by `ansible-core` 2.21.3, records an
+accept, defer, or reject disposition for each, and specifies fifty-seven new
+Netsuke helpers across ten capability groups in 2132 lines. Its section 14 says
+it is deliberately not one implementation change, and its section 17 recommends
+scheduling the capability groups as focused children after v0.1.0 final.
+
+Two things are missing, and only two.
+
+First, there is no per-helper record of the obligations that RFC 0006 section 6
+imposes. Section 6.1 requires every helper to carry a purity label "recorded in
+its documentation entry and asserted by a test", and gives an aggregate —
+fifty-two pure, four filesystem-observing, one environment-observing — but **no
+table anywhere assigns a class to a named helper**. The same is true of the
+resource bounds in table 3, the diagnostic codes in section 6.9, and the
+manifest-query disposition in section 6.2. An implementer of a capability group
+must currently re-derive all four from prose, for every helper, and no reviewer
+can check the derivation.
+
+Second, there is no mechanical way to answer the question the roadmap asks: is
+every accepted capability covered exactly once, and is every deferred or
+rejected candidate covered not at all? That is a bijection over the accepted
+set, which is sixty names — the fifty-seven new helpers plus the three existing
+helpers that gain a behaviour-preserving option — and a bijection over sixty
+names is not reliably checked by reading.
+
+After this change each of roadmap phase 6's eight capability steps has a
+focused child RFC that discharges the cross-cutting contract for its own
+helpers, headed by a five-column registry table naming every helper in the
+group with its namespace, registration kind, purity class, and manifest-query
+availability. That registry is simultaneously the artefact section 6.1 asks for
+and the machine-readable anchor for the coverage check. RFC 0006 keeps its
+survey and its per-helper contracts unchanged and gains a coverage map.
+
+The observable result is documentation plus one executable contract. A reader
+can run `make test` and see a test binary derive the accepted set from RFC
+0006's own section 7 disposition tables, derive the forbidden set the same way,
+and assert that each accepted helper appears in exactly one child RFC's
+registry and no forbidden name appears in any. Dropping a helper, listing it
+twice, assigning it to the wrong child, or reintroducing a rejected Ansible
+spelling all fail by name.
+
+This plan is approval-gated. It must be reviewed and explicitly approved before
+implementation begins.
+
+## Scope: settled
+
+The roadmap and this plan now agree, so there is no divergence left to weigh.
+This section records how that was settled, because the earlier drafts turned on
+it.
+
+`docs/roadmap.md:744` used to read "Split the RFC 0006 accepted set into
+focused child issues", with success measured as "exactly one open child issue".
+The commissioned task asked instead for focused **child RFCs and accompanying
+roadmap tasks**, and the reviewer has confirmed that reading and directed that
+work be tracked in committed documentation wherever possible. Task 6.1.1 has
+therefore been rewritten in place, and this plan implements that wording.
+
+Three consequences, all now resolved rather than open.
+
+**No separate issue tracker.** Delivery is tracked by the roadmap checkboxes in
+steps 6.2 to 6.9, which are committed documentation and are reviewed with the
+code. No child RFC gets a GitHub issue, and the amended task says so.
+
+**The burn-down survives.** The word *open* in the old wording was doing real
+work: an issue closes when its capability lands, giving a live burn-down, and
+an RFC never closes. That property is preserved by the roadmap checkboxes,
+which already decompose every capability group into tasks. It is not lost, only
+relocated — into the file the reviewer asked to track work in.
+
+**"Exactly one" applies to the RFC, not to the task.** Every one of the 60
+accepted helpers is already named in a task under its owning step, but some are
+named by more than one: `product` appears in both 6.4.2 and 6.4.5. The amended
+success criterion therefore reads "exactly one child RFC and at least one
+accompanying roadmap task". `COV-6` checks the roadmap half mechanically.
+
+The originating issue [#596](https://github.com/leynos/netsuke/issues/596) was
+closed on 2026-08-28 with this very split among its unfulfilled acceptance
+criteria, so roadmap 6.1.1 is the surviving carrier of the obligation. Child
+RFCs cite #596 as their **originating** issue, not as a tracking issue, so no
+child claims an open tracker it does not have.
+
+## Constraints
+
+Hard invariants. Violating one requires escalation, not a workaround.
+
+- Do not implement this plan until the user explicitly approves it.
+- **RFC 0006 section 8 is not moved, gutted, or reduced.** Its per-helper
+ contracts stay exactly where 39 roadmap task bullets already cite them. Child
+ RFCs are additive. See decision `D3`.
+- This work is documentation-only apart from one new test binary and its
+ wiring. Do not add or change any Netsuke helper, template registration,
+ locale key, command-line flag, configuration field, or Cargo dependency.
+- Do not renumber, rename, or delete RFCs 0001 to 0012.
+ `docs/documentation-style-guide.md:231` forbids renumbering after publication.
+- Do not change any accept, defer, or reject disposition in RFC 0006 sections
+ 7, 9, or 10. This task partitions the accepted set; it does not relitigate
+ it. A disposition that appears wrong goes in `Surprises & discoveries` and is
+ escalated.
+- Do not resolve any of RFC 0006's seven section 16 open questions. Each is
+ assigned to a delivery step and must be settled by that step's implementer.
+ Carry each into the child RFC that owns it, unresolved.
+- Every child RFC records a release target of v0.1.x or later and must not
+ widen the v0.1.0 hardening release defined by
+ [#594](https://github.com/leynos/netsuke/issues/594). Note that #594 is
+ closed but the latest tag is `v0.1.0-beta3`, so v0.1.0 final has not shipped
+ and the constraint is live.
+- No child RFC may register a capability that RFC 0006 section 9 defers or
+ section 10 rejects **on principle or as a redundant alias**. Names rejected
+ because Netsuke or MiniJinja already provides the capability are a different
+ class; see decision `D5`.
+- Documentation prose follows `docs/documentation-style-guide.md` and uses
+ en-GB-oxendict spelling. Body prose wraps at 80 columns; code blocks at 120;
+ tables and headings are not wrapped. Every fence carries a language: `bash`,
+ `sh`, `plaintext`, `text`, or `rust`. An unlabelled fence is `MD040`, and a
+ bare fence under an indented list item also trips `MD031`; the red-control
+ transcripts were written as indented `text` fences first and failed both.
+- Markdown must be `mdtablefix`-canonical. Run `make fmt` before
+ `make check-fmt`. Never write a backtick inside a backticked span in prose;
+ `mdtablefix --wrap` corrupts it. The first draft of this plan proved that.
+- Run validation commands sequentially, never in parallel, capturing output
+ with `tee` under `/tmp`.
+- Commit only after gates pass, using `git commit -F`, not `git commit -m`.
+- Use the shared default Cargo cache. Do not create an isolated one.
+
+## Tolerances (exception triggers)
+
+- **Aggregate volume.** If the eight child RFCs together exceed 2400 lines,
+ stop and escalate. Per-file limits alone cannot catch a set that is
+ individually reasonable and collectively disproportionate.
+- **Per-file volume.** If any child RFC exceeds 400 lines, stop and escalate.
+ A child carries no per-helper contract, so a larger one means section 5 has
+ become restatement.
+- **Vacuity.** If any section 5 subsection cannot state a group-specific
+ consequence — a bound, a registry row, a diagnostic code, a purity
+ assignment, a named error condition — and cannot honestly say "no additional
+ obligation beyond RFC 0006 section 6.N", stop and escalate. See `D6`.
+- **Effort stop-loss.** If any single child-RFC milestone needs more than four
+ gate cycles, stop and reassess against `Alternatives considered`.
+- **Gate false positives.** If `rfc_stdlib_coverage_tests` fails on a change
+ that touches no section 5 registry table and no RFC 0006 section 7 table,
+ stop and rescope the parser. Nobody may add an ignore attribute to this test.
+- **Number collision.** Re-enumerate remote branches before every child-RFC
+ commit, not once. If a number is taken, stop and escalate.
+- **Scope.** If implementation requires changing a file outside the set in
+ `Interfaces and dependencies`, stop and escalate.
+- **Dependencies.** If the coverage test needs a crate not already available,
+ stop and escalate.
+- **Interface.** If the coverage test would require changing any `src/` file,
+ stop and escalate.
+- **Ambiguity.** If two child RFCs both have a defensible claim on a helper,
+ stop and present options rather than choosing silently.
+- **Iterations.** If `make markdownlint` or `make check-fmt` still fails after
+ three focused attempts on one file, stop and escalate with the log path.
+
+## Risks
+
+- Risk: the section 5 subsections are structurally perfect and semantically
+ empty — each a restatement of the clause it discharges. A structural test
+ would certify the emptiness, because heading presence is exactly the property
+ vacuous prose has. Severity: high. Likelihood: high. Mitigation: this is the
+ plan's principal risk and the reason for three controls. `D6` cuts section 5
+ to five mandatory substantive clauses and permits a single declarative line
+ for the rest. The anti-vacuity rule gives a reviewer a one-line test.
+ `CONF-1` requires each subsection to name at least one helper the RFC owns
+ and to contain no Ansible-deference phrase. And `EP-M3` is a hard go/no-go:
+ if section 5 for the group `EP-M3` delivers — RFC 0013, the first, not the
+ smallest — tells a reviewer nothing they did not already know from RFC 0006
+ section 6, the remaining seven are not written.
+
+- Risk: the split stalls half-finished, and nothing is red because the coverage
+ invariant is satisfied by both the finished and the abandoned state.
+ Severity: high. Likelihood: medium. Mitigation: because section 8 is not moved
+ (`D3`), a half-finished split leaves every roadmap citation working and
+ every RFC 0006 contract intact — the abandoned state is incomplete, not
+ incoherent. The coverage map carries a status column and `COV-4` reports the
+ unwritten count in the passing test output, so a stall announces itself on
+ every run.
+
+- Risk: an RFC number is taken by a concurrent branch. The repository's
+ measured base rate is not low: `docs/` contains `adr-003` twice, `adr-004`
+ three times, and `adr-014` twice — three collisions that all merged.
+ Severity: medium. Likelihood: medium. Mitigation: `D2` allocates lazily, one
+ number per child at the commit that creates it, and lands the reservation
+ rows in RFC 0006 as the first mergeable commit. A reservation on a branch
+ reserves nothing. Remote heads are re-enumerated before every child commit
+ and once more before merge.
+
+- Risk: a rejected Ansible alias is reintroduced while writing a child RFC,
+ because the alias reads naturally. Severity: medium. Likelihood: medium.
+ Mitigation: `COV-2`, whose forbidden set is **derived** from RFC 0006 section
+ 7's disposition column rather than typed by hand. The first draft hand-wrote
+ that list and omitted sixteen names, including `is_file` — the sibling of the
+ very alias this risk names. That is the evidence for deriving.
+
+- Risk: RFC 0006 is `Status: Proposed` and has never been ratified; no RFC in
+ the corpus ever has. Freezing its dispositions and encoding its totals in a
+ gate may prove premature. Severity: medium. Likelihood: medium. Mitigation:
+ nothing here changes a disposition, and because section 8 is not moved, a
+ later change costs a registry row and a coverage-map row rather than a
+ document rewrite. `ADR-040` carries the amendment procedure named in `D9`.
+
+- Risk: the reviewer concludes the split is not worth its cost.
+ Severity: medium. Likelihood: medium. Mitigation: `Alternatives considered`
+ states the fallback plainly, and `EP-M0` and `EP-M3` are both go/no-go points
+ before most cost is incurred.
+
+## Progress
+
+- [x] (2026-10-01) **Replay the review corrections onto current main.**
+ The published head `1f36a7ff6054e35dfdf22396988c72f7667606b8` became
+ conflicting. Rebase all 63 branch commits from the exclusive boundary
+ `7677c3886c0bd1cad470c3d1acd62041ec0ca0ab` onto fetched target
+ `563261b059cf985f0df0590f86ceb71baf18d192`; the replay ends at
+ `84de9442ff1c3b84083e6bebb67835e11e07ffa0`. The first branch commit's direct
+ parent and the earlier rebase receipt confirm that boundary; no merge commit
+ is replayed. Native text merge with `zdiff3` is used, with no selected custom
+ driver.
+
+ Both conflicts are in `docs/contents.md`: retain the branch's ADR-040 entry
+ and its later wording correction alongside main's ADR-041 entry. Range-diff
+ shows only those two context changes and the generated spelling hunk main
+ already contains. All 130 target-only paths and 45 branch-only paths remain
+ byte-identical to their corresponding source trees. The changed-line
+ sequences for the four overlapping Markdown files are unchanged; the fifth
+ overlapping file, `typos.toml`, already matches both sides. Main's delivered
+ shell-quoting amendment and this branch's JSON-domain amendment both survive.
+ The ignored `uv.lock` was preserved outside the worktree and restored
+ byte-identically after its historical add/remove sequence.
+
+ Main also changes workflows and the Ruff baseline, so previous green gates
+ cover historical candidates. Repeat the complete sequential gate set on the
+ rebased tree before a push leased to the recorded published head;
+ current-head CI and CodeRabbit confirmation remain required before approval
+ and merge.
+
+ The rebased lint run exited zero but emitted a `comments-indentation` warning
+ at `release-dry-run.yml:21:5`. Treat that as a failed gate: move the existing
+ explanation before `with`, without changing workflow behaviour, then repeat
+ lint and the remaining gates. The focused coverage run passed all 272 tests;
+ workflow contracts passed 1,016 tests with three skipped.
+
+ **The warning-free rerun is green.** Sequential `make fmt`,
+ `make test-workflow-contracts`, `make check-fmt`, `make lint`,
+ `make typecheck`, `make markdownlint`, `make doc-coverage`, `make nixie`, and
+ `make test` all pass at candidate `84de9442` with tree fingerprint
+ `bc2152748dfbfd6e9ccde9b456680c5d8eae224d7898fab2c12e7c39726550fc`. Workflow
+ contracts: 1,016 passed, three skipped. Rust: 3,911 passed, six skipped
+ across 111 binaries; doctests: 129 passed, 32 ignored across three targets.
+ Documentation coverage: 98.84%. The focused RFC binary passed 272 tests
+ before the comment-only workflow fix, and passes inside the full suite too.
+ Both local CodeScene reports score 10 with no findings. The immediate count
+ still reports one written group and seven remaining; these corrections do not
+ finish milestones EP-M4 through EP-M11.
+
+ Canonical full-suite log:
+ `/tmp/test-5ffbb1df-4543-4fd2-8f84-30f81650519c-6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task-14.out`.
+ This receipt is the sole edit after that complete run; revalidate its
+ formatting, Markdown, focused coverage and ExecPlan status before committing.
+ Publication and hosted checks must cover the resulting commit separately.
+
+- [x] (2026-10-01) **Implement focused refactors for new CodeScene complexity
+ findings after the functional review commit.** At `e88d0d1a`, CodeRabbit
+ confirmed the four validation findings, JSON amendment, and aggregate-method
+ concern resolved, and accepted the five string-argument metric exceptions.
+ Three new CodeScene findings arose: complexity and nested condition blocks in
+ `reference_path`, and complexity in `immediate_output_offences`. The separate
+ atomic refactor splits independent depth validation from model rendering, and
+ override selection from policy-field checks. A repository sweep found no
+ equivalent helpers. Their ownership and permitted callers are recorded in the
+ coverage suite's developers-guide subsection. Preserve models, diagnostics,
+ test names, and parser semantics; add no suppression. Local CodeScene and the
+ complete sequential gate set passed after the refactor. Current-head CI and
+ CodeRabbit disposition evidence are required before merging.
+
+- [x] (2026-10-01) **Implement the coverage-validation review corrections and
+ fix the JSON key-type contradiction.** The starting local HEAD is
+ `1c35b1d076955cf773c28672089a5c6ddd7ee98c`; the reviewed remote baseline is
+ `2db228aabc60bde4f75d1d453ef78584706c51bc`. Inspect parser branches and
+ callers, add direct rejection tests and independent bounded properties, guard
+ the immediate-output override through the existing TOML loader, and document
+ the suite. Retain integer and boolean JSON key conversion as explicitly
+ lossy; restrict canonical round-trip equality to the canonical JSON domain,
+ including string keys at every nesting level. Amend the normative parent and
+ align the child and ADR specimen. These changes implement review corrections;
+ milestones EP-M4 to EP-M11 remain outstanding.
+
+ Tests use isolated temporary capability roots through a private test-only
+ `Repo::fixture` constructor. Synthetic `World` constructors belong only to
+ this test module tree. Private validators accept those fixtures while the
+ seven repository-check wrappers continue loading the same documents once. No
+ runtime helper is implemented and no tracked document is mutated by a test.
+ CodeGraph tools are unavailable in this session and Leta has no registered
+ workspace; direct source and caller inspection is the fallback.
+
+ The first focused run found a property-assertion format capture error; after
+ correction the binary passed all 272 tests, and workflow contracts passed 986
+ tests with 3 skipped. Clippy then rejected assertion panics in fallible
+ tests, unchecked indexing, shadowing, and string construction. Those patterns
+ were corrected without suppressions. A second lint pass found sixteen further
+ statement-terminator and string-collection findings, also corrected without
+ suppressions. Python docstring sections and assertion failure messages were
+ corrected as their successive lint stages exposed them. The first full Rust
+ run found the focused Makefile target missing from the existing
+ `NEXTEST_TARGETS` inventory; that inventory now includes it and checks its
+ worker bounds. All required local gates then passed sequentially through one
+ scrutineer, including the full Rust suite. The verification subsection
+ records the measured results. Review replies and merge remain conditional on
+ current-head CI evidence and explicit CodeRabbit confirmation; no further
+ review will be requested.
+
+- [x] (2026-09-08) Rewrite roadmap task 6.1.1 to the child-RFC wording and
+ record that delivery is tracked by roadmap checkboxes, not issues (`D8`).
+- [x] (2026-09-11) `EP-M0` Audit; confirm the partition and the derivation
+ rules. Go/no-go. Every derived count was confirmed except the forbidden-set
+ size, which the plan gave as 34; that number reconciles only as a row count.
+ Both stop conditions were raised and both are resolved: `D10` adopts the
+ complement rule and the deny set is 71, and the note column was **not** shown
+ to discriminate — the class split is not derivable and is not asserted, so
+ `D5` rule 3's remedy is deferred rather than needed. Audit results are in
+ `Surprises & discoveries`; the partition and the per-child registry contents
+ are confirmed unchanged, so `EP-M1` is unblocked. One further correction:
+ `D5` rule 2's claim that all three rename rows say `Reject` is wrong for
+ `hash`, whose row reads "Accept as `text_hash`".
+- [x] (2026-09-11) `EP-M1` stage A/B derivation, re-verified against RFC 0006
+ before the parser was written. Confirmed: 55 accept rows yielding 55 names
+ with no alias groups among them, 6 defer names, 67 reject names, an accepted
+ set of 60, and a deny set of 71. Every membership witness in `COV-2`'s
+ non-vacuity list holds (`is_file`, `is_dir`, `is_link`, `quote`, `fileglob`,
+ `lookup`, `win_dirname`, `expanduser` all denied; `basename`, `dirname`,
+ `abs`, `glob`, `shell_quote`, `splitdrive`, `text_hash` all permitted), and
+ `hash` is correctly neither — it is an existing helper RFC 0006 leaves
+ unchanged, so it leaves scope entirely rather than counting as accepted.
+ `EP-M0`'s class-split recovery was falsified: the recorded rule yields 8
+ alias / 24 exists / 18 principle, not table 11's 22/10/18. The *principle*
+ third is right and every principle row is backtick-free, but the 32-row
+ remainder splits 8/24 against the table's 10/22, the difference being exactly
+ the two rename rows `win_splitdrive` and `fileglob`. Recovering 22/10/18
+ requires special-casing those two out of *exists* while leaving `now` — also
+ a reject row naming an existing helper in backticked call form — inside it,
+ with nothing in the document to distinguish them. The split is therefore
+ **not derivable** and `COV-2` does not assert it; it asserts the parseable
+ totals plus that table 11's three class counts sum to the reject-row count.
+ Amended in place: `D10`'s tail, `COV-2`'s closing note, the audit table's
+ last row, and the `Surprises` bullet that had recorded the rule as working.
+- [x] (2026-09-11) `EP-M1` Land the coverage test, `ADR-040`, the RFC 0006
+ corrections and reservations, and the roadmap 6.1.1 rewrite. All seven
+ coverage checks are green on the unwritten map, `COV-4` reports 8 remaining,
+ and roadmap 6.1.1 was confirmed to already carry the `D8` wording. Three
+ parser defects were found and fixed on the way; see
+ `Surprises & discoveries`. Shipped on the task branch rather than as its own
+ pull request: the reviewer's instruction names one pull request, and PR #697
+ already carries the task title, so the milestones stack there and each lands
+ as its own commit. Five seeded-fault controls runnable before any child
+ exists (a deleted coverage-map table, a corrupted section 7 row, a dangling
+ link, a deleted roadmap bullet, and a bullet moved to the wrong step) each
+ fail naming the missing table, row, link, or helper, and the suite is green
+ again once they are reverted; transcripts are in `Verification plan`. The
+ coverage map's caption was renumbered from table 12 to table 16 in the
+ process, since table 12 already existed further up the document. The test
+ module tree was split so that no module exceeds Whitaker's 400-line limit; see
+ `Surprises & discoveries`.
+- [x] (2026-09-19) `EP-M1` second CodeRabbit pass, four findings, all actioned.
+ Two were latent defects rather than style: the parser was fence-blind, and
+ the purity aggregate did not apply the `New`-row scoping its own comment
+ described. Both are proven by evidence and covered by the fence control; see
+ `Surprises & discoveries`. The other two were the stale `child issue` quote in
+ `clauses.rs` and RFC 0006 table 1's `0006` row, which recorded the RFC's
+ `Status` in a column whose every other row records a merge state — the merge
+ claim was accurate, and the row now states the merge like its neighbours,
+ with a sentence under the table separating the two facts.
+- [x] (2026-09-19) `EP-M1` third pass: clear the three `lint-clippy` errors and
+ settle the template against the parser. All three were in `markdown.rs` — two
+ `doc_markdown` backticks and a `missing_const_for_fn` on `is_closing_run`.
+ The last cascaded: making it `const` made its caller `Delimiter::closes`
+ eligible in the same run, so the second `make lint` failed one frame further
+ up. Fixed both in one pass and confirmed the cascade stops at `mark`, which is
+ `&mut self` and calls the non-const `Delimiter::opening`. The cheap check
+ (`cargo clippy --test rfc_stdlib_coverage_tests`) is enough to find the next
+ frame without spending a full gate cycle on it. Separately, and more
+ importantly: the skeleton `EP-M2` is told to copy literally **failed the
+ parser `EP-M1` shipped**, on both the registry heading and the manifest-query
+ cell vocabulary; see `Surprises & discoveries`. Both are fixed in the
+ skeleton now, before `EP-M3` could spend the go/no-go on a document written
+ to the wrong contract.
+- [x] (2026-09-24) `EP-M2` the child-RFC template and one worked section 5,
+ both landed in `ADR-040`. The template left this plan for the ADR rather than
+ the developers' guide, and the worked section followed it: a template that a
+ parser reads must live where the child author is already sent, and that is
+ the ADR the convention is stated in. The worked section is RFC 0013's — the
+ group `EP-M3` spends the go/no-go on — filled in rather than described, so a
+ reviewer judges the pattern before eight RFC numbers depend on it. Both
+ parsed artefacts were validated mechanically against the shipped parser: the
+ registry heading matches `REGISTRY_HEADING`, all five rows parse to `New`/
+ `pure`/`yes`, and the discharge table's eleven ids equal section 6's clause
+ list in document order. Two consequences of writing it are recorded in
+ `Surprises & discoveries`: the fenced copy is width-bounded by `MD013` in a
+ way the real child is not, and the first draft's error-condition vocabulary
+ had invented a `non_string_key` where RFC 0006 section 8.1 says sequence and
+ mapping keys are rejected. This milestone's acceptance evidence is a
+ reviewer's, not a test's, and is stated in `EP-M2`'s own entry.
+- [x] (2026-09-24) `EP-M2` CodeRabbit pass, ten findings, all actioned. Two were
+ latent defects that would have fired at `EP-M3`, the go/no-go, rather than
+ here: the coverage-map link was resolved against `docs/rfcs` as though it
+ were a file, so `links::resolve` popped into `docs/` and every correct child
+ link would have been reported missing as `docs/0013-….md`; and `COV-1`
+ compared registry name sets only, leaving the namespace and registration
+ columns parsed but never checked, which is exactly what `COV-3`'s filter and
+ test totals count. `heading_depth` accepted a bare `#596` as a depth-1
+ heading, silently truncating any scan over prose that cites an issue at line
+ start; the corpus does not do that today, which is why no test noticed. The
+ remainder were documentation: RFC 0006 section 14.13 said `Status` carries
+ the child link where the parser reads the link from `Child RFC`, the plan's
+ type sketch still showed three reject variants and `Row` fields the shipped
+ code does not have, and three plan passages still asserted the note column
+ discriminates the reject classes, which `D10` had already recorded as
+ falsified. Each fix was proven by a probe rather than by inspection; see
+ `Surprises & discoveries`.
+- [x] (2026-09-24) `EP-M2` second CodeRabbit pass, thirteen finding records
+ collapsing to nine themes, all actioned. Three mattered beyond tidying.
+ `section7::apply_optioned` assigned `Namespace::Filter` to all three optioned
+ helpers while RFC 0006 section 3.2 lists `glob` under Functions, and the
+ namespace comparison added in the previous pass reads that value — so `COV-1`
+ would have failed RFC 0018, whose group owns `glob`, for being *correct*.
+ That is a defect this task introduced, not inherited. `CONF-1`'s mechanical
+ half was never implemented: the discharge *table* was parsed and compared,
+ but the section 5 subsections it points at were read by nothing, so the empty
+ stub, the generic discharge, and the Ansible-deference appeal were all
+ unchecked — the plan listed them as obligations and the code did not carry
+ them. And the link-target number was never compared against the reserving
+ row, so row `0013` could link to `0014-….md` and every check would resolve
+ the link, find the file, and pass while the registry filed those helpers
+ under the wrong RFC. The remainder were narrower: duplicate clause ids were
+ absorbed by a `BTreeSet` in two places, `child_number` accepted non-numeric
+ link text, the totals and purity aggregate were gated on all eight groups
+ being written so nothing could contradict them until the split was over,
+ ADR-040 claimed the test "transcribes none of them" when it carries four
+ anchor lists, RFC 0006 tied delivery to child-RFC closure where `D8` ties it
+ to roadmap tasks, and a stray `**` in this plan was followed by a newline and
+ so rendered literally. Every fix was proven by a probe. See
+ `Surprises & discoveries`.
+- [x] (2026-09-24) `EP-M2` third gate run at `4c631e18`, red on `make lint`, and
+ both findings were defects the second pass introduced rather than inherited.
+ `clippy::too_many_lines` rejected `map::parse` at 73 lines against this
+ repository's 70-line ceiling, and `clippy::iter_skip_next` rejected
+ `Section::subsections`'s `.skip(index + 1).next()`. Fixed at `c74a0993` by
+ splitting `parse` into `parse_row` plus one function per rule that carries
+ its own reasoning, and by indexing with `slice::get`. The ten coverage tests
+ pass unchanged. **The gate caught what the review would not have**:
+ CodeRabbit reads the diff's semantics, and neither finding is a semantic
+ defect — the second pass had already been reviewed twice and passed. This is
+ the reason the standing instruction is to make every gate green *before*
+ requesting a review rather than to use the review as a gate.
+- [x] Fourth gate run (2026-09-24), at `292bb9b3`. `make check-fmt` passed;
+ `make lint` failed in `lint-whitaker`, which is the third of `make lint`'s
+ five stages, so `lint-python` and `github-actions-lint` never ran. Two
+ findings, both `module_max_lines`: `Module checks spans 461 lines` and
+ `Module rfc_stdlib_coverage spans 448 lines`, against AGENTS.md's 400-line
+ cap. `lint-clippy` — the first stage — passes the same files, which is the
+ third time a targeted Rust gate has been green on a tree that `make lint`
+ rejects. Fixed at `7605c884` by splitting `tests/rfc_stdlib_coverage` into
+ three modules, none over 300 lines: `document` (the document layer, shared by
+ every parser), `partition` (`COV-1`, `COV-2`), and `progress` (`COV-3` to
+ `COV-6`, `CONF-1`). The ten tests pass unchanged and `COV-4` still reports
+ `0 of 8 capability groups written; 8 remaining`.
+- [x] Fifth gate run (2026-09-24), at `0ca2cb18`. `make check-fmt` failed on two
+ `cargo fmt` diffs left by hand-writing the new modules — a trailing blank
+ line in `document.rs` and a `pub use` rustfmt breaks across three lines in
+ `mod.rs`. Fixed at `09018769`. The commit that introduced them was verified
+ with clippy, nextest, and Whitaker, but `make check-fmt` was skipped: the
+ first stage of the documented gateway set was the one omitted, and it was
+ omitted because the change was "just a module move". Every gateway now passes
+ on this tree, and `make lint` passes **in full for the first time on this
+ branch** — all five stages, including `lint-python` and
+ `github-actions-lint`, which had never run because the two prior invocations
+ both stopped at `lint-whitaker`.
+- [x] Sixth gate run (2026-09-24), at `8852163e`, and the first full six-target
+ run on this branch. All six pass: `make check-fmt`, `make lint` (all five
+ stages), `make typecheck`, `make test` (2813 passed, 3 skipped; doctests 81 +
+ 2 + 32), `make markdownlint` (134 files), `make nixie`. The run also
+ confirmed the module split was behaviour-neutral: the
+ `rfc_stdlib_coverage_tests` set is byte-identical at ten tests, and a
+ normalized line-survival sweep of the pre-split `mod.rs` and `checks.rs`
+ found six lines without an exact post-split counterpart, all module-path and
+ import declarations the split necessarily rewrote.
+- [x] (2026-09-24) `EP-M2` CodeRabbit pass at `8852163e`: twelve findings, one
+ major and eleven minor/trivial, all triaged. Seven were applied as stated
+ (`map.rs`'s dead `title` field; `section7.rs`'s silent `or_insert_with`;
+ `assertions.rs`'s half-checked `hash` invariant; `map.rs`'s unvalidated
+ `Owns` arity; the crate doc's "nothing here transcribes an inventory"; the
+ plan's seven-to-ten test count and its two wrong `0014`/`0015` slugs;
+ `ADR-040`'s doubly-listed `duplicate_key`). Two were applied against a *false
+ premise* in the finding: `document.rs`'s start scan was made fence-aware
+ because the doc comment promised a property `position()` could not deliver,
+ not because a live bug existed — no document in the corpus has a heading
+ inside a fence, and every looked-up heading is unique — and `section7.rs`'s
+ optioned comment claimed `glob` "also reaches `accepted` as an accept row",
+ which is false: all three optioned helpers appear only in reject rows. One
+ was applied differently than asked: `section7.rs`'s hardcoded
+ `Namespace::Filter` was replaced by a namespace parsed from the section 7
+ tables, because the `Optioned` doc already argues a hardcoded namespace makes
+ a *correct* child fail. One was **partly refused**: see
+ `Surprises & discoveries` for the deference finding, whose requested
+ behaviour would have deleted the plan's own recorded seeded fault.
+ `clauses.rs` gained four unit tests, taking the binary from ten tests to
+ fourteen. Committed at `65665fcb`.
+- [x] Seventh gate run (2026-09-24), at `65665fcb`. Five of the six targets
+ pass: `make check-fmt`, `make lint` (all nine tool invocations across its
+ five stages, including both Whitaker passes), `make typecheck`,
+ `make markdownlint` (134 files, 0 errors), `make nixie`. `make test` did
+ **not** pass locally, on two tests that this branch's diff does not touch:
+ `locale_stub_ui_tests::harness_compiles_under_a_split_build_dir` and
+ `packaging_smoke_tests::packaged_manifest_retains_build_script_sources`, both
+ nextest `TIMEOUT` at the repo's 300s budget. Both spawn live nested
+ `cargo build`s into private target directories, and six other agents' cargo
+ processes were running against the shared cache at the time. The resolver
+ commits for this historical trap are **not** ancestors of this branch, so the
+ live-build form is still what runs here. Re-running the two files alone made
+ it worse (four timeouts, not two), which rules out this branch's own test
+ load as the cause. Environmental, not a regression: the two files are
+ unmodified by this diff, and the full run reached 2815 passed against 2 timed
+ out. **Superseded by the eighth entry**: "environmental" was the right
+ verdict and the wrong reason — both tests are over budget on this base by
+ construction, not by load.
+- [x] Eighth gate run (2026-09-24), at `f5300602`. Same shape as the seventh.
+ Five of six targets pass: `make check-fmt`; `make lint` (all five stages plus
+ both Whitaker invocations, 13s on a warm cache); `make typecheck`;
+ `make markdownlint` (135 files, 0 errors; spelling 34 passed, 92.37%
+ coverage); `make nixie`. `make test` fails again on exactly the two
+ live-build tests, both `TIMEOUT` at 300.015s — 2817 run, 2815 passed, 2 timed
+ out, 3 skipped, and **no** assertion failure of any kind. Because `make test`
+ is fail-fast, the `doctest` sub-target never ran at all: that is this run's
+ one evidence gap, and it is a gap the seventh run shared. The branch's own
+ `coverage_map_status_is_reported` passed in 0.223s, so the diff's assertions
+ are exercised and green. **Gap closed the same day**: `make doctest` was run
+ separately and passed, exit 0, `81 passed / 2 passed / 32 passed` across its
+ three targets with no failures. So `make test`'s two recognized sub-targets
+ are now both accounted for — `test-nextest` red only on the two
+ upstream-fixed live-build tests, and `doctest` green in full.
+- [x] (2026-09-24) **`make test` cannot pass on this base, and that is not a
+ load story.** `e2fc2083` and `33a293a7` are on `origin/main` but are *not*
+ ancestors of this branch, so main has already fixed this failure class — and
+ not by widening a budget. It added a `[test-groups.nested-cargo-builds]`
+ group with `max-threads = 1` and **removed**
+ `harness_compiles_under_a_split_build_dir` from the override filter
+ altogether, trading the always-cold nested workspace build for recorded
+ parser coverage. The seventh entry's "environmental, load-dependent" reading
+ was too generous to this host. Measured for the record: the harness test
+ passes in 524.170s under a lifted budget, and
+ `packaged_manifest_retains_build_script_sources` in 270.658s, of which
+ `cargo publish --dry-run` alone is 269.86s — against a 300s cap. The seventh
+ entry read "re-running the two files alone produced four timeouts, not two"
+ as evidence of load; it is better read as evidence that neither can pass here
+ at all, so narrowing the selection only removes the queueing that was hiding
+ it. **The rebase is the fix; no local workaround is warranted.**
+- [x] (2026-09-24) **No CI has ever run on this branch, and CodeRabbit has
+ reviewed nothing.** PR #697 reports `mergeable: CONFLICTING` and
+ `mergeStateStatus: DIRTY` against `origin/main` (55 behind, 23 ahead; the
+ conflicted paths are `.config/nextest.toml` and `docs/contents.md`). GitHub
+ cannot build the `refs/pull/NNN/merge` ref for a conflicted pull request, so
+ *every* `pull_request`-triggered workflow is suppressed — `ci.yml` and
+ `netsukefile-test.yml` among them. The last real CI run on this branch is
+ `3f02ee37`, its opening push on 2026-09-09. CodeRabbit is the sharper case:
+ its status context on the head reads `SUCCESS` while its only comment on the
+ pull request says "Draft PR not reviewed". So `EP-M3`'s "every gate green"
+ criterion could not have been met through CI, and the two controls this
+ milestone was counting on were not watching.
+- [x] (2026-09-24) This branch's `.config/nextest.toml` addition from
+ `3a207c13` is stale against main's convention. It uses
+ `filter = 'test(=coverage_map_status_is_reported)'`; main's file now
+ documents at length that the `test(=NAME)` form compares the whole name and
+ so silently matches *none* of a parameterized `#[rstest]`'s instances, and
+ mandates the anchored `test(/^NAME($|::)/)` instead. That filter is correct
+ today only because the test is unparameterized, which is precisely the latent
+ defect main's comment was written to prevent. The rebase should adopt the
+ anchored form.
+- [x] (2026-09-25) **`EP-M3` go/no-go: GO.** RFC 0013 was put to an independent
+ reviewer against the plan's own criterion, applied verbatim. Of the eleven
+ section 5 subsections, ten were judged SUBSTANTIVE and one VACUOUS. The
+ substantive ten are not re-wordings: five of them (5.3, 5.6, 5.7, 5.8, 5.9)
+ force decisions a reader of RFC 0006 section 6 could not have predicted — the
+ `indent` range asymmetry and its rationale, stream-wide budgets with the
+ stream total in the diagnostic, callables and `now()` rejected as
+ `unsupported_kind`, the `interchange` module segment rather than `json`/
+ `yaml`, the trailing-newline asymmetry pinned by a test at each end, and an
+ explicit refusal to invent a looser test relation for `sort_keys`. The three
+ "no bite" clauses (5.4, 5.5, 5.11) satisfy the criterion's own exception by
+ naming the specific clause feature that cannot fire, which is checkable. The
+ reviewer's strongest passage was 5.8's serializer bound: **"The serializers
+ enforce nothing, and that is a decision rather than an omission. Neither
+ allocates proportionally to anything but its input, so a bound would reject
+ documents a parser had already accepted. The row reads 'none' instead of
+ being left blank so that a reviewer sees the absence was chosen."** The seven
+ remaining child RFCs are therefore written.
+- [x] (2026-09-25) **Two defects the go/no-go found, both fixed in `e45c161e`.**
+ The one vacuous subsection was 5.10, which restated clause 6.10 and
+ re-reported an open question section 5.2 already carries; it now records the
+ real naming question this group creates. More seriously, 5.6 claimed
+ `from_yaml_all` "rejects every `from_yaml` condition", which contradicts RFC
+ 0006 section 8.1: an empty stream yields an empty sequence and multi-document
+ input is the helper's purpose, so `document_count` does not apply. That is a
+ rejection the implementer would have written and a test would then have had
+ to defeat. The same over-broad phrasing sat in the 5.8 bounds table. Both
+ corrected. **This is the go/no-go earning its keep**: a structural test
+ cannot see this class of error, because `CONF-1` only requires each
+ subsection to name a helper and avoid deference phrasing, and the wrong
+ sentence did both.
+- [x] (2026-09-25) Rebased onto `origin/main` (`397fb589`, 55 commits). Two
+ conflicts, both predicted by `git merge-tree`: `.config/nextest.toml` and
+ `docs/contents.md`. Both were resolved by taking main's version and
+ re-applying this branch's addition, rather than transcribing the conflict
+ hunks — main had reorganized the nextest overrides into `nested-cargo-builds`
+ groups, and this branch's hunk was the extraction of the Windows override
+ main removed. That addition now uses main's mandated anchored filter form,
+ `test(/^coverage_map_status_is_reported($|::)/)`, closing the
+ latent-unhooking defect recorded above. Verified after: `main` is an ancestor,
+ `origin/main..HEAD` is 25, `HEAD..origin/main` is 0, `e2fc2083` and
+ `33a293a7` are now ancestors, and the coverage contract passes 14/14 with
+ COV-4 still reporting "1 of 8 capability groups written; 7 remaining".
+- [x] (2026-09-25) Second rebase, onto `96aefc9c`, and the branch's own
+ contract test caught a defect the incoming commits brought with them. One
+ conflict, in RFC 0006 section 16 item 7, resolved by keeping both sides after
+ verifying each claim against ADR-008's "2026-09-11: Stdlib clock seam"
+ section and the coverage map's RFC 0020 row for group `8.10` — the clock seam
+ is answered by roadmap item 7.1.1, and RFC 0020 neither needs it nor depends
+ on it, so the two statements are compatible rather than contradictory. Then
+ `inter_document_links_resolve` failed on `96aefc9c`'s own
+ `docs/rfcs/0007-netsukefile-testing-framework.md:62`, which links to
+ `netsuke-test-framework-technical-design.md` without the `../` prefix its
+ line 22 correctly carries. From `docs/rfcs/` that resolves inside that
+ directory, where the file does not exist. Fixed at `64970ba8`. **The link fix
+ then failed `make check-fmt`**, `+5 -4` on that one file: the added `../`
+ pushed a wrapped line past the margin `mdtablefix` enforces, and the file was
+ re-wrapped in place. Re-verified after: check-fmt exit 0 (153 files
+ unchanged), `make markdownlint` exit 0 (0 errors), and the coverage contract
+ 14/14. Note the sequence, because it is the whole reason the instruction is
+ to gate locally rather than to trust the review: a link-only edit that a
+ reviewer would read as trivially correct was red on two separate
+ deterministic gates, and no review would have caught either.
+- [x] (2026-09-25) **The rebase restored the CI channel, and CI is green on the
+ same commit where local `make test` is red.** This is the milestone's most
+ important result, and it settles the acceptance question by a better route
+ than the one the plan assumed. PR #697 flipped from `CONFLICTING` to
+ `MERGEABLE` at the first check after the force-push, which lets GitHub build
+ `refs/pull/697/merge` again; runs `36069242789` (`CI`), `36069242572`
+ (`Netsukefile Build Test`) and `36069243039` (`Release Dry Run`) all fired on
+ `288526a2` and all concluded `success`. `Netsukefile Build Test` proves
+ green, because it ran the tests rather than only compiling them:
+ `Summary [ 415.659s] 3408 tests run: 3408 passed (3 slow), 5 skipped`,
+ alongside `Doc-tests netsuke` 87 passed and `Doc-tests test_support` 39
+ passed, both `0 failed`. **Zero failures and zero timeouts on CI.**
+- [x] (2026-09-25) Ninth gate run, at `288526a2`, and the first on a tree where
+ the branch's own diff is what runs. Five of six targets pass:
+ `make check-fmt` (1s), `make lint` (64s, **all five stages and both Whitaker
+ invocations**), `make typecheck` (7s), `make markdownlint` (24s, 153 files, 0
+ errors), `make nixie` (5s, 154 files). `make test` fails, and the failing
+ test is `command_env_ui_tests::cli_configuration_fixture_compiles` —
+ `TIMEOUT [300.008s]` against nextest's 300s cap — with 3392 passed and 15
+ cancelled behind it. This is the **third** member of the nested-Cargo timeout
+ class, already recorded as measured `PASS 329.774s` under a lifted budget, so
+ it is over cap on this host by construction, not by regression. Because
+ `make test` is fail-fast, `doctest` never ran; run separately it passes (71s,
+ 2 targets, 0 failed). Gate logs are the seven unsuffixed
+ `/tmp/-netsuke-.out` files.
+- [x] (2026-09-25) **The two gates disagree, and the disagreement is the
+ evidence.** `make test` is red locally on one nested-Cargo compile test; the
+ identical commit is green on CI with 3408/3408 and no timeout. Nothing in the
+ branch differs between the two — the same commit, the same test set — so the
+ difference is the host. The local run competed with other agents' clippy,
+ nextest, and publish jobs (load average 74.39 on 24 cores) and the test
+ drives a cold nested `cargo check` into a private `CARGO_TARGET_DIR` that
+ cannot reuse the gate build; CI ran at **415.659s for all 3408 tests**, which
+ is less than the local budget for this one test. Two consequences. The first
+ is acceptance: `EP-M3`'s "every gate green" is satisfied on the authority
+ that the criterion was always pointing at, and the local red is recorded
+ rather than hidden. The second is a correction to the plan's own reasoning —
+ see `Surprises & discoveries` for why "every gate green locally" was the
+ wrong formulation to have written.
+- [x] (2026-09-25) **Tenth gate run, red on three in-diff defects, all fixed.**
+ The run covered the uncommitted tree that carries the five CodeRabbit code
+ findings; `make typecheck`, `make nixie`, and `make test` passed, and
+ `cli_configuration_fixture_compiles` passed in 8.6s rather than timing out,
+ so the known 300s failure did not reproduce. Three gates failed, and every
+ failure was introduced by this branch rather than inherited:
+
+ - `make check-fmt`, on `clauses.rs`'s `DEFERENCE_PHRASES`. That const was
+ formatted at `HEAD`, and rewriting it to a two-line form rustfmt rejects is
+ this branch's doing. `make fmt` resolved it.
+ - `make lint`, on Whitaker's `module-max-lines`: `clauses.rs` grew from 354
+ lines at `HEAD` to **432**, past the 400 cap. Every sibling in that
+ directory is under 400, the next largest being `section7.rs` at 332. Fixed
+ by splitting the module at a seam rather than raising the cap.
+ - `make markdownlint`, on three MD013 lines this branch added: one in this
+ plan and two in RFC 0013. `make fmt` does not repair MD013, so all three
+ were rewrapped by hand.
+
+ The `clauses.rs` split extracted the appeal-to-Ansible predicate and its five
+ unit tests into a new `tests/rfc_stdlib_coverage/deference.rs`. The seam is
+ the *kind* of judgement: `clauses` grades document structure (an empty body,
+ a body naming no owned helper), while the third vacuity shape `CONF-1` names
+ is a judgement about prose. `clauses.rs` is now 258 lines and `deference.rs`
+ 202, and the directory's largest module is unchanged at 332. The module doc
+ of each records the move, following the precedent `mod.rs` set when
+ `markdown.rs` and `document.rs` were split out of it for the same reason.
+ Re-verified after the split: `rfc_stdlib_coverage_tests` 15/15 green,
+ including all five moved `deference` tests and `inter_document_links_resolve`.
+- [x] (2026-09-25) **`ADR-021` was a number collision, and it is now `ADR-040`
+ .**
+ Found while investigating a grep that showed two files sharing the number.
+ `main` published `adr-021-trust-aware-fetch-policy-merge.md` on 2026-09-09
+ (`3348cc0a`, "Prevent project configuration from widening trusted fetch
+ policy (#644) (#663)"); this branch minted
+ `adr-021-focused-child-rfcs-for-survey-rfcs.md` with its `Date` field reading
+ 2026-09-11 and committed it at `9730a880`. Both files are tracked at `HEAD`,
+ so main's 021 was already an ancestor when the branch's was committed. This
+ is the exact failure the plan warned about: the `EP-M0` note at line 1361
+ records "The highest existing ADR is 020; three earlier numbers collided, so
+ re-check before committing", dated 2026-09-08 — one day before main took 021.
+ The re-check did not happen, for any of the four subsequent commits that
+ touched the ADR.
+
+ Renumbered to **040**, the lowest free number above the ceiling. A fresh
+ remote sweep puts that ceiling at 039 (`jm5/kani-change-scoped-gate`);
+ `origin/main`'s own highest is 038. Eight edits, all mechanical and all
+ verified: the file moved by `git mv` (preserving history), its H1 renumbered
+ (it carried no internal self-references), the RFC 0006 section 14.13 link
+ repointed, the two execplan path references repointed, all 17 execplan
+ `ADR-021` mentions renumbered, and — the defect that made this visible — the
+ missing `docs/contents.md` entry added. That file was the only one of 39 ADR
+ files with no index entry, so files and entries now both read 39 and a Python
+ set-comparison confirms they agree exactly, with no dangling target.
+
+ Two guards held. `main`'s `adr-021-trust-aware-fetch-policy-merge.md` is
+ untouched, as are all three of its inbound citations (`docs/contents.md` and
+ two in `adr-026-manifest-environment-access-policy.md`, one of which is a
+ link-reference definition). And the 17 execplan mentions were checked before
+ the replace: grepping them for `fetch`, `trust`, `quarantin`, `network`, or
+ `policy` returns nothing, so no mention means main's ADR and a scoped global
+ replace was safe. The renumber is escalated rather than decided — see the
+ item below — but the remedy is preparable without touching `main`, so it is
+ prepared.
+- [x] (2026-09-27) **Third rebase, onto `aa764819`, and all four named gates
+ are green on the result.**
+ PR #697 had gone `CONFLICTING` against `main`, which suppresses CI entirely,
+ so the rebase was the unblocking step rather than tidying. `main` had
+ advanced 18 commits past the branch's base `96aefc9c`; the replay was 33
+ commits, linear, no merges, and stopped exactly once — at commit 6
+ (`.config/nextest.toml`), with `docs/contents.md` auto-merging. The conflict
+ surface predicted before the replay was four files and the actual surface was
+ three plus a no-op, so the prediction held.
+
+ The one real conflict is a **TOML trap worth recording**. Both sides had
+ appended a `[[profile.default.overrides]]` entry at the same anchor — `main`
+ adding the Kani mutation compile gate, this branch adding the `COV-4`
+ coverage-map override. Git matched the two independently written *header*
+ lines as common context, so it conflicted only on the bodies, leaving one
+ header serving main's body and this branch's header stranded below the
+ `=======`. Keeping both bodies under that single header would have produced
+ one TOML table with two `filter` keys: a duplicate-key parse error, not a
+ merge that merely looks wrong. The resolution is two separate array entries,
+ main's first. Provenance was checked rather than assumed: `typos.toml` was
+ expected to conflict and did not, because those 14 lines are tool-generated by
+ `make spelling` and both branches regenerated the same text, so the replay
+ correctly became a no-op there.
+
+ The resolution was verified before `git add`, not after. The composed file
+ parses under `tomllib` with five override entries; removing this branch's
+ block reproduces `main`'s version exactly; and the branch never touched the
+ `[profile.ci]` prose that `main` rewrote from 300s to 600s, so taking
+ `main`'s copy verbatim is correct rather than a silent overwrite. The
+ semantic audit then ran clean: all 146 target-only paths byte-identical at
+ the new head, all 23 branch-only paths byte-identical to the old head, zero
+ unexplained deletions against `main`, and no duplicated block outside
+ branch-authored files. `range-diff` shows 33 commits against 33 with
+ identical messages in identical order; the single `!` is commit 6 and is
+ exactly the intended union.
+
+ Gates, run sequentially on the rebased head `ada8b994`, each to completion:
+ `make check-fmt` exit 0; `make typecheck` exit 0; `make lint` exit 0 across
+ all five stages including both Whitaker invocations; `make test` exit 0,
+ **3491 run, 3491 passed, 0 failed, 6 skipped** in 127.6s, plus doctests. The
+ doctest gap that both earlier full runs shared is closed in this run rather
+ than separately. `COV-4` reports "coverage map: 1 of 8 capability groups
+ written; 7 remaining", which matches the plan's status: `EP-M0` to `EP-M3`
+ land, `EP-M4` to `EP-M10` do not.
+
+ Recovery material was created before the replay and is retained:
+ `refs/recovery/20260927T231849/{old-head,old-base,target,remote-before}`,
+ plus native `--binary` patches of both series under
+ `/tmp/rebase-697-recovery/`. Recorded identities: `old_base=96aefc9c`,
+ `old_head=0190f3aa`, `target=aa764819`, `new_head=ada8b994`.
+- [x] (2026-09-28) **CodeRabbit reviewed the pull request itself, at
+ `658b8157`: three findings, all actioned at `797178c9`.** Review `5332303125`
+ returned `CHANGES_REQUESTED` against the branch head — not a stale SHA, as
+ every earlier CodeRabbit signal on this branch had been — with three inline
+ comments (`4117169304`, `4117169313`, `4117169325`), one per touched file.
+ Nothing was ambiguous and nothing had to be refused; each was confirmed
+ against the artefact it describes before being accepted, and each fix is
+ committed with its confirmation rather than the reasoning left in a review
+ reply.
+
+ This is the first CodeRabbit response the plan records that came from the
+ *pull request* rather than from a locally run `coderabbit review --agent`,
+ and it arrived unprompted. It is therefore not numbered against the earlier
+ passes: those were milestone-scoped (`EP-M1` second pass, `EP-M2` third pass)
+ and this one is not tied to a milestone, so an ordinal would assert a lineage
+ the plan does not record.
+
+ `ADR-040`'s worked specimen still described `from_yaml_all` as rejecting every
+ `from_yaml` condition. That is the wording RFC 0013 §5.6 had already been
+ corrected away from, so the specimen and the artefact it previews had
+ disagreed since that correction. The ADR calls the specimen non-normative and
+ tells the reader that RFC 0013's copy is the normative one — but a reader
+ editing RFC 0013 may well copy from the ADR, and the sentence is the same
+ *claim*, not a summary of it, so a divergence is a defect in either
+ direction. It now matches RFC 0013 §5.6 byte for byte, verified by extracting
+ the bullet from both files.
+
+ The coverage map could reserve one RFC number twice, and no check would
+ notice. `Map::ownership` permits a repeated helper when both rows carry the
+ same number, which is exactly what a duplicate reservation produces, so the
+ one guard with a view of the whole map was blind to the shape. Downstream,
+ `registries::parse_all` keys on a number over `children.contains`, and both
+ the status and registry checks match rows with `find`/`any` — so one child
+ RFC would have silently represented two capability groups. `parse` now tracks
+ reserved numbers in a `BTreeSet` and rejects a repeat. The guard was proven
+ live both ways rather than only made to pass: with a row mutated `0015` →
+ `0014` it fails the run with the duplicate message, and with `map.rs`
+ reverted to `658b8157` that same mutation passes **all fifteen** checks, the
+ one-owner test among them. Both probes ran against the live document, and RFC
+ 0006 was restored to `HEAD` afterwards and confirmed by md5
+ (`90c5787133429ec0a8ef66c1d936b265`).
+
+ `Delimiter::opening` accepted any indentation before a fence. The module
+ claims `CommonMark` in the same doc comment, and `CommonMark` allows at most
+ three leading spaces; four or more is an indented code block. The cost of the
+ unbounded form is asymmetric. A line indented deeply enough to be ordinary
+ code content to every other Markdown tool would open a block here and swallow
+ every heading and table beneath it — the silent truncation `Fences` exists to
+ prevent — whereas the bound's own cost is a *loud* one: a fence nested in a
+ list item sits at the item's content column, four spaces for an ordered
+ marker, and is legal `CommonMark` this line-level predicate cannot see. The
+ corpus carries every fence at column zero, so the loud error is the one the
+ documents avoid today and the silent one is the one they face tomorrow. The
+ bound is now three spaces and the doc comment states both sides of the trade.
+
+ No gate covered this. `mdtablefix` is lenient through indent 6, and this
+ repository's `markdownlint` config sets no MD046 key, so MD046 runs in
+ "consistent" mode and a document whose only code block is indented passes.
+ Three new unit tests in `mod fence_tests` pin the boundary that no document
+ exercises: the 0–3 accept / 4–8 reject sweep, that a rejected opener leaves
+ the structure below it readable, and that an indented *closer* still closes.
+
+ `cargo nextest run --test rfc_stdlib_coverage_tests` → 18 passed, 0 skipped.
+- [x] (2026-09-28) **Gate run at `3707fc1b`: two red, both in the prose this
+ pass had just added, both fixed in the commit carrying this entry.** Seven
+ targets were commissioned from one runner rather than the four named in
+ `AGENTS.md`, because the turn-end hook runs `check-fmt`, `lint`, `typecheck`,
+ `markdownlint` and `nixie` while `AGENTS.md` adds `doc-coverage` and `test`;
+ the union is the honest set. Five were green on the first pass — `make test`
+ at 3494 passed / 0 failed / 6 skipped in 127s, `make lint` (all five stages,
+ both Whitaker invocations), `typecheck`, `nixie`, and `doc-coverage` at
+ 98.83% — and the two failures were `make check-fmt` and `make markdownlint`,
+ each on a defect this pass had introduced.
+
+ `mdtablefix` reported five hunks in the plan. Every line in every hunk was ≤
+ 80 columns on both sides, so this was **not** an MD013 violation: the tool's
+ greedy fill packs the same words into a different arrangement than the hand
+ wrap, and one word's displacement cascades through the paragraph. The correct
+ fix is therefore to accept the tool's arrangement, not to shorten anything.
+ Applied with the scoped invocation this plan already records, and confirmed
+ idempotent — `--check` with the gate's own selector then reports
+ `169 files left unchanged`, exit 0. The result matches the runner's read-only
+ `--diff` capture byte for byte, which is the determinism check: the fix
+ reproduced the tool's own output rather than merely silencing it.
+
+ `markdownlint` failed *before markdownlint ran*. Its `spelling` prerequisite
+ aborts on `recognise` at `1473:57`, so `mdlint` never executed and the diff
+ has **no** markdownlint verdict from that run — an unknown, not a green. The
+ re-run closes that gap explicitly: `spelling` passes and `mdlint` proceeds to
+ 169 files with 0 errors. The `-ise` to `-ize` fix was applied to both copies
+ of the sentence, the plan and the Rust doc comment it quotes, because the
+ branch's own Observation records that rule — *when a correction is applied to
+ an artefact, grep for its other copies in the same commit* — and applying it
+ to one copy only would reproduce the ADR/RFC divergence at the start of this
+ same pass. The `.rs` copy reds no gate: the spelling gate is Markdown-scoped
+ and the Rust corpus is genuinely split on this word (12 files `-ise`, 9
+ `-ize`, one file carrying both), so the local convention does not decide it.
+ What decided it was that this branch authored exactly one Rust occurrence and
+ it is the quoted same sentence.
+
+ Two things were deliberately not changed. The twelve pre-existing `.rs` files
+ spelling `recognise` are outside this delta and outside every gate's scope;
+ rewriting them would put twelve unrelated files in a prose-fix commit. And
+ `docs/execplans/…:1072` is 81 columns but is exempt: MD013's `\S*$` rule
+ means a line whose final token begins within 80 columns is not flagged, the
+ config sets no `strict`/`stern`, and `git log -S` shows the line was
+ introduced by `6ed33733` — an earlier, already-published commit, so it is not
+ a regression this pass introduced.
+
+ `cargo nextest run --test rfc_stdlib_coverage_tests` → 18 passed, 0 skipped.
+- [x] (2026-09-28) **Gate run at `68c266e8`: all seven targets green.** The
+ same seven-target union ran again on the fix commit and passed: `check-fmt`
+ (169 files, 0 reformatted, 2s), `lint` (all five stages, both Whitaker
+ invocations, actionlint resolved from `$HOME/go/bin`, 14s), `typecheck` (1s),
+ `test` (3494 run / 3494 passed / 6 skipped in 147.569s,
+ `rfc_stdlib_coverage_tests` 18/18, `execplan_status_contract_tests` 9/9,
+ doctest targets two not three), `markdownlint` (spelling now passes and
+ `mdlint` proceeds to 169 files with 0 errors, 19s), `nixie` (10s),
+ `doc-coverage` (98.83%, 32s). The runner also independently reproduced the
+ two facts this pass had argued from: the `spelling` target's scope is
+ Markdown-only (controlled A/B on a scratch repo: an `-ise` typo in a `.rs`
+ file reds no gate) and `make test` *is* sensitive to plan edits, via
+ `tests/execplan_status_contract_tests.rs:62,133`.
+
+ **This entry is the reason the revision it cites is not the revision that
+ stands green now.** Writing a Progress entry moves `HEAD`, so the run is
+ evidence for exactly `68c266e8` and for nothing later; the commit carrying
+ this paragraph is a *new* revision that the run did not cover. The next gate
+ run therefore has to cover the commit that contains it, not be assumed green
+ by inheritance — the same rule the plan's own Observation records about a
+ suffix in a gate log not being a revision.
+- [x] (2026-09-28) **Two `chatgpt-codex-connector` passes on `658b8157`
+ triaged; four inline comments, plus a non-review from `sourcery-ai[bot]`.**
+ The plan had recorded neither bot. Sourcery's is a size-limit refusal, not a
+ review — the diff exceeded the 150,000-character review limit — so it carries
+ no findings and no verdict to clear.
+
+ Of codex's four, one (`map.rs:123`) is a duplicate of CodeRabbit's F2 already
+ fixed at `797178c9` and is recorded as such rather than re-dispositioned. The
+ other three are substantive and are actioned in the commit carrying this
+ entry; each needed a premise check rather than a straight application,
+ because in all three the *finding* is sound and the *stated mechanism or
+ proposed remedy* is not:
+
+ - `map.rs:85` — `claim()` accepts a repeated helper when the repeat carries
+ the same row number, so a cell reading `` `8.1`; `8.1` `` claims every
+ helper twice and the `BTreeMap` collapses it. Codex's remedy is "reject any
+ existing owner". That remedy cannot be applied as written: row `0018`'s
+ `` `8.7` except `abs` `` resolves through `Survey::sections`, which is built
+ from `sections_of` *including* the optioned helpers (`section7.rs:260`), so
+ it already yields `glob`, and the same row's `Optioned` cell names `glob`
+ again. A blanket reject would red the live document.
+
+ This entry first recorded the remedy as a guard "within one row's own claim
+ list". That is **also wrong, and wrong in the same way**: `basename` and
+ `dirname` reach row `0017`'s `claims()` twice — once through `owns`, since
+ `sections_of` files them in `8.6`, and once through that row's `Optioned`
+ cell — so the legitimate overlap is *within* a row, not across rows. The
+ only scope that separates the defect from the design is **per cell**: an
+ `Owns` cell must name a helper once, an `Optioned` cell must name a helper
+ once, and the same name may appear in both because the two cells record
+ different facts (which subsection specifies it; that it gains an option
+ rather than being introduced). The guard is `ensure_distinct`, applied to
+ each cell in `parse_row`. Verified against the live document rather than
+ assumed, and proven live by mutation.
+ - `RFC 0013:264` — the serializers are specified as enforcing no bound. The
+ premise that a serializer's input need not be a bounded parser's output is
+ **correct, and stronger than codex put it**. RFC 0006 §6.8 says
+ "materialized output rejects unreasonable expansion before allocating", and
+ RFC 0013 had read "materialized" only in the `from_yaml_all` sense (fully
+ materialized before return), missing the output sense. The amplification is
+ not hypothetical and needs no hostile input: MiniJinja values are
+ `Arc`-shared, so a value built by repeated doubling is a DAG whose *logical*
+ size is exponential in its construction depth. The mechanism is visible in
+ the pinned crate: `impl Serialize for Value` recurses through
+ `ObjectRepr::Seq` with `seq.serialize_element(&item)` for each child, and for
+ a doubling both children are the same `Arc`, so every level visits the whole
+ subtree twice. Confirmed by measurement — one recursive macro with `n`
+ doublings emits `{{ v | tojson }}` as exactly `2^(n+2) - 3` bytes: `n=10` →
+ 4,093, `n=16` → 262,141, `n=20` → **4,194,301**. A four-line template
+ therefore drives a materialized output past §6.8's 8 MiB ceiling with no
+ large input anywhere. **Provenance:** that measurement ran through the
+ MiniJinja Python binding, which wraps this engine; the figure for the pinned
+ Rust crate is the one the implementation slice must re-measure, and the
+ source-level mechanism above is what makes the result binding-independent.
+ The bound is a ceiling on the *serialized byte count*, reachable only
+ through `to_yaml` and `to_nice_json`, and clause 6.8 requires the rejection
+ *before* allocating. That rules out counting bytes as they are written, so
+ both documents specify a length pass first: walk the value with checked
+ arithmetic, abandoning the walk when the running total passes the ceiling,
+ and write only a value that fits.
+ - `RFC 0013:189` — this is the one finding whose **stated mechanism is
+ false**, and it is recorded that way rather than applied. Codex argues
+ `{1: "a"} | to_nice_json | from_json` yields `{"1": "a"}`, "which is not
+ equal to the input under §6.7's canonical equality". But §6.7 defines
+ equality as *byte-identical canonical key*, and the canonical form of an
+ integer key **is** the string form: `serde_json`'s `MapKeySerializer`
+ renders every integer and boolean arm through
+ `begin_string`/`write_iNN`/`end_string`, and `serde_json_canonicalizer`'s
+ `JsonProperty::new` then re-parses those bytes and requires `.as_str()`. So
+ both mappings canonicalize to `{"1":"a"}` and compare **equal** — the round
+ trip holds, and codex's "impossible for part of the documented input
+ domain" does not follow. Its proposed remedy (constrain `to_nice_json` to
+ string-keyed mappings) is also the wrong split: RFC 0006 §8.1 requires the
+ stringification outright.
+
+ The real defect is **adjacent, sharper, and unnamed by codex**: one mapping
+ can hold both the integer `1` and the string `"1"` as keys, and `from_yaml`
+ accepts exactly that (probe: `yaml.safe_load('1: a\n"1": b\n')` yields
+ **two** entries, keys `int 1` and `str '1'`). Both render to the same JSON
+ key, so `{1: "a", "1": "b"} | to_nice_json` emits a document with a
+ **duplicate key** — which `from_json`, the stated inverse, **rejects** with
+ `duplicate_key` per §8.1. That is the round-trip guarantee failing on an
+ accepted input, in the direction codex missed. The collision is rejected at
+ the serializer, so `to_nice_json` is total on the inputs it accepts, and the
+ §5.7 guarantee is qualified to say so.
+
+ Both §5 findings land in the section the seven later child RFCs are copied
+ from, so both were also checked against `ADR-040`'s specimen copy of §5
+ (lines 372-463) in the same commit, per the rule this plan already records —
+ *when a correction is applied to an artefact, grep for its other copies in
+ the same commit*. The specimen differs from RFC 0013's §5 in several places
+ already (it is a preview, and shorter), and the two sentences the corrections
+ touch were found in both copies and corrected in both.
+
+ Two consequences of the §5.8 correction were found only by following it
+ through, and both are corrections of this entry's own earlier text:
+
+ - The new `output_too_large` condition is a condition of the group, so §5.9's
+ enumeration needed its row and the "thirteen conditions" claim needed to
+ become fourteen. The count is asserted in **four** places (both documents'
+ §5.9 prose and both clause-discharge tables); all four were changed and the
+ tables were counted mechanically afterwards — 14 rows, 14 codes, in each.
+ - `RFC 0013`'s first wording for the mechanism was "counts the bytes it writes
+ and fails past 8 MiB". Reading clause 6.8 rather than paraphrasing it showed
+ that is not sufficient: the clause opens "Every parser, combinatorial
+ helper, regular-expression operation, and materialized output rejects
+ unreasonable expansion **before** allocating." Counting while writing is a
+ post-hoc check, so it discharges the clause's letter only by accident. Both
+ documents now specify a length pass first, walking the value with checked
+ arithmetic and abandoning the walk when the running total passes the
+ ceiling, so a doubled value stops after 8 MiB of *logical* nodes rather than
+ expanding.
+ - The `map.rs` guard's scope was corrected **twice**. This entry first
+ recorded codex's remedy ("reject any existing owner") as unworkable, then
+ proposed the narrower "within one row's own claim list". That second
+ proposal is also wrong, and for the same reason: `basename` and `dirname`
+ appear twice *within* row `0017`'s claims (once through `owns`, once through
+ `Optioned`), so the legitimate overlap is inside a row, not across rows. The
+ only scope separating the defect from the design is **per cell**, which is
+ what `ensure_distinct` implements.
+- [x] (2026-09-28) **BLOCKER: the shared Cargo package cache is deadlocked
+ machine-wide, so no Rust gate can run.** The commit carrying the three codex
+ dispositions is `630b8116`, verified by inspection and by everything that
+ does not need Cargo, but **its local gate run is outstanding**. The commit
+ recording this entry is plan-only; the code delta under test is exactly
+ `630b8116`, and a later reader must not fold the two together. A reader must
+ not treat the absence of a gate result as a pass.
+
+ **Scope correction, added later the same day: this entry overclaims.** It
+ says no Rust gate can run, and that is true only of the *local* invocation.
+ CI runs the same gates on GitHub's runners, which do not touch this machine's
+ package cache, and it has since run them green on a later revision — see the
+ entry below. The blocker is real but narrow: it costs the local second
+ reading, not the gates themselves.
+
+ The cycle, read from `/proc` rather than inferred: PID `1832225`
+ (`cargo test --all-targets --all-features` in the `podbot` worktree, another
+ agent's job) holds the **write** lock on `~/.cargo/.package-cache-mutate` and
+ is blocked in `do_wait` on its child test binary `1855438`, which is blocked
+ in `futex_wait_queue`; that binary spawned a **nested** `cargo` (`1855450`)
+ which is blocked in `locks_lock_inode_wait` **on the lock its own grandparent
+ holds**. Two samples of `1855438`'s `/proc//stat` twenty seconds apart
+ showed utime+stime unchanged at 48 ticks, so the loop is not progressing.
+ Forty-seven processes were queued on that inode with `locks_lock_inode_wait`,
+ the oldest for 1h46m, and `pgrep -c rustc` was **0** system-wide — every Rust
+ job on the machine, this branch's included, was stalled behind it.
+
+ This was diagnosed and left alone deliberately. The house rule says not to
+ kill other agents' processes, and the holder belongs to another session; the
+ system prompt's remedy for a full or wedged cache is to stop and tell the
+ user, not to break someone else's lock. The `podbot` job is also
+ self-inflicted in the sense that matters here — it is a nested-Cargo deadlock
+ of the kind this repository has hit before (see the nested-Cargo timeout
+ records), not a cache that merely needs to drain.
+
+ The diagnosis carried no action of its own, so this entry closes as
+ *diagnosed and recorded* rather than as a task left undone. Its one live debt
+ — the local gate run it blocks — was paid later the same day: the cache
+ drained, and all seven targets ran green. The scope correction above is what
+ makes the checkbox safe to tick; the historical record is unchanged.
+
+ What *was* verified without Cargo: `mdtablefix --check` over the full
+ selector reports `169 files left unchanged` (exit 0), so every Markdown edit
+ is canonical and idempotent; `rustfmt --edition 2024 --check` on `map.rs`
+ exits 0, so the new guard parses and is format-clean; and the two §5.9 tables
+ were counted mechanically (14 rows, 14 codes each) rather than trusted. A
+ standalone `rustc` parse of `map.rs` was tried and is **inconclusive** — it
+ fails only on the unresolvable `anyhow` and `super::` imports, so it cannot
+ distinguish a syntax error from a missing dependency, and must not be cited
+ as evidence.
+
+- [x] (2026-09-28) **Two pieces of work completed while Cargo was blocked, and
+ the PR description brought back in line with the branch.** Neither needs the
+ package cache.
+
+ The two commits `630b8116` and `e209ee99` were **pushed**. The remote head
+ had stood at `68c266e8` while the local head was `e209ee99`, so the earlier
+ "remote-head discrepancy" is resolved. It was a fast-forward: the remote head
+ was verified to be an ancestor of local `HEAD` before pushing, so no force
+ was needed and none was used.
+
+ The PR description had drifted in three separate ways, all corrected in one
+ edit rather than left for a reviewer to notice. Its `Verification` section
+ described **four** gates on the long-superseded head `ada8b994` and never
+ mentioned the seven-target union, the later runs, or the blocker at all. Two
+ figures were stale: the coverage suite was "15 tests executed", which is
+ **18** (7 top-level in `tests/rfc_stdlib_coverage_tests.rs` plus 11 module
+ tests, 6 of them in `markdown.rs`), and RFC 0013 was "429 lines", which is
+ **452**. Both were re-derived from the working tree rather than adjusted by
+ arithmetic — `grep -c '#\[test\]'` over the module tree, and `wc -l` on the
+ committed blob via `git show HEAD:`.
+
+ The rewritten section leads with a per-revision table that states plainly that
+ `68c266e8` is the last revision to complete the set, and that `630b8116` and
+ `e209ee99` are **outstanding — blocked, not passed**. It records the deadlock
+ with the `/proc` evidence, what was verified without Cargo, and — since CI
+ does not use this machine's package cache — that **CI is the one channel the
+ deadlock does not block**, which is what can actually settle the current head.
+
+ One figure was **kept** after checking it: "Five seeded-fault controls run
+ before any second child exists" is not the same list as the
+ `Verification plan`'s "four seeded faults". The five are the early controls
+ (a deleted coverage-map table, a corrupted section 7 row, a dangling link, a
+ deleted roadmap bullet, a bullet moved to the wrong step); the four are the
+ later RFC 0014/0015 ownership faults. They agree, so neither was changed — an
+ apparent inconsistency that is only apparent.
+
+ The `ensure_distinct` doc comment's load-bearing claim was **re-verified at
+ the source level**, because it is the kind of prose a reviewer cannot cheaply
+ check and a wrong mechanism there would justify the wrong fix. The claim is
+ that `basename`, `dirname` and `glob` enter the per-section member lists even
+ though section 7 never accepts them, which is what forces the guard's scope
+ to be per cell rather than per row. Confirmed: `section7::apply_optioned`
+ inserts each optioned helper into `read.sections_of` (`section7.rs:260`), and
+ `survey.rs:130` builds `Survey::sections` by inverting exactly that map. So a
+ clause reading `` `8.6` except `expandvars` `` resolves to include `basename`
+ and `dirname`, and what a per-row or per-union guard would need to tolerate
+ is therefore present in the live document. The comment stands as written.
+
+- [x] (2026-09-28) **The blocker does not block the gates after all — CI runs
+ them, and they are green on `c7ff9e4a`.** This entry corrects the scope, and
+ the correction matters more than the original entry did: it was written on
+ the assumption that a deadlocked local package cache left the Rust gates with
+ no runner. That assumption was **never checked, and it is false**.
+
+ The `CI` workflow's `build-test` job ran, on `c7ff9e4a`, to completion and to
+ success — and its steps are the seven-target union almost exactly:
+ `make check-fmt` (169 files left unchanged by `mdtablefix --check`),
+ `make lint` (all five stages, with `actionlint` from the job's own download),
+ `make typecheck`, `make doc-coverage`, `make spelling`, `make nixie`,
+ `markdownlint-cli2` over `**/*.md`, and `make test-workflow-contracts`. The
+ `Windows / lint-windows` job independently carries `Format`, `Lint (Clippy)`
+ and `Lint (Whitaker)`.
+
+ The test evidence is the strongest of these, and it is not a subset.
+ `Test and Measure Coverage` invokes the shared `generate-coverage` action with
+ `all-features: true`, `all-targets: true`, `doctests: true`,
+ `use-cargo-nextest: true`, and `RUSTFLAGS: -D warnings` — the same flags the
+ local gate uses — and its log reads **
+ `3494 tests run: 3494 passed (1 slow), 6 skipped`**, with every
+ `rfc_stdlib_coverage_tests` instance `PASS`, including
+ `totals_and_purity_aggregate_agree`. Doctests ran too, in **two** targets
+ (`Doc-tests netsuke`, `Doc-tests test_support`), which re-confirms the
+ two-not-three figure from the other side. `COV-4` printed
+ `coverage map: 1 of 8 capability groups written; 7 remaining` in that passing
+ run, exactly as this plan requires.
+
+ So the outstanding work is **narrower than the blocker entry above claimed**.
+ What remains genuinely unrun is the *local* invocation of the set — which is
+ not the same claim as "the gates have not run". Two things must be stated
+ separately and must not be merged: CI's `3494 run / 3494 passed` on
+ `c7ff9e4a` **is** gate evidence for that revision, and the local `scrutineer`
+ run is still owed as the second, independent reading.
+
+ Read the run rather than the summary row: `36373283587`, head `c7ff9e4a`,
+ `workflowName: CI`, `event: pull_request`, all five jobs `success`. The
+ lessons are the general ones — a summary row is not the log, and an
+ assumption about what a blocker blocks is itself a claim that needs a check.
+
+ **And the run was repeated on the revision that carries this entry.** Head
+ `1524a7e6`, run `36375195362`, `CI`, all jobs `success`; `Format` reports
+ `169 files left unchanged`, and `Test and Measure Coverage` reports **
+ `3494 tests run: 3494 passed (1 slow), 6 skipped`** in 240.328s with
+ `coverage map: 1 of 8 capability groups written; 7 remaining`. So the plan
+ edits that record the correction are themselves covered by the same evidence,
+ which is the property the `68c266e8` entry above argues for and this entry
+ would otherwise have violated.
+
+ **Next action for whoever resumes:** run the seven-target gate set locally
+ once the cache clears (`pgrep -c rustc` returning non-zero, or the inode free
+ in `/proc/locks`), then commission the `scrutineer` run — not because the
+ gates are unrun, but because a local second reading on the runner's own
+ revision is what this plan owes. The liveness proof for `ensure_distinct` is
+ also still owed: the guard must be shown to *fire*, by mutating a
+ coverage-map row to `` `8.1`; `8.1` `` and observing the run fail with the
+ duplicate message.
+
+ **Re-measured, and still held, at 05:30Z — the blocker is unchanged, not
+ stale.** A `cargo metadata` probe returned exit 0, which looked like a clear,
+ but the subsequent build never produced a `target/debug/deps` and no `rustc`
+ ran. Reading `/proc/locks` properly settled it: the inode carries **exactly
+ one granted entry** (the un-arrowed line, `1832225`, `FLOCK ADVISORY WRITE`)
+ and **45 blocked requests** (every `->` line). The holder is still the same
+ `cargo test --all-targets --all-features` in the `podbot` worktree, at 2h14m,
+ still in `do_wait` on `1855438`; nested `1855450` is still in
+ `locks_lock_inode_wait`. `pgrep -c rustc` is still **0** and ~20 `cargo`
+ processes are queued machine-wide.
+
+ The probe was a false clear because `cargo metadata` is one of the few
+ commands that does not need the write lock. **Do not read a single exit-0
+ probe as the deadlock lifting** — the durable signal is either a non-zero
+ `pgrep -c rustc` or the granted-lock line disappearing from `/proc/locks`.
+ The build attempt made while diagnosing was blocked on the lock for its whole
+ life, not failing, and was stopped rather than left queued: a queued waiter
+ is itself one more entry in the 45, which makes everyone else's diagnosis
+ noisier.
+
+ Also corrected: the memory note's holder recipe (`awk '{print $5}'` over
+ every matching `/proc/locks` line) conflates the holder with its waiters,
+ because the field layout differs between granted and blocked lines. The
+ granted line is the one **without** a leading `->`.
+
+ **Unblocked at 06:10Z — the durable signal this entry named has fired.** The
+ condition it specified is met in the form it specified:
+ `grep "$INODE" /proc/locks | grep -v -- '->'` returns nothing (no granted
+ line), the blocked count is **0**, and `pgrep -c rustc` is 0 because nothing
+ is running rather than because everything is stalled. The holder `1832225`
+ and its descendants have left the process table. So the local seven-target
+ run is no longer blocked by the cache, and the second reading this entry
+ called outstanding is owed as work rather than waiting on an event. Note
+ which signal carried the verdict: the *inode* going free, exactly as the
+ entry above insists — a `pgrep`-based reading alone would not have
+ distinguished a cleared deadlock from a machine whose holders had merely not
+ yet restarted.
+
+ **Discharged the same day — the local seven-target run is green on
+ `a5455a1a`, the revision that carries this entry.** `scrutineer` ran the
+ whole set sequentially, to completion, and every gate exited 0:
+
+ | Gate | Exit | Duration | Diagnostic line, read from the log |
+ | -------------- | ---- | -------- | -------------------------------------------------------------- |
+ | `check-fmt` | 0 | 2s | `169 files left unchanged.` |
+ | `lint` | 0 | 85s | all five stages ran; `All checks passed!`, `rated at 10.00/10` |
+ | `typecheck` | 0 | 12s | `All checks passed!` (ty), then `Finished 'dev' profile` |
+ | `test` | 0 | 286s | `3494 tests run: 3494 passed (1 slow), 6 skipped` |
+ | `markdownlint` | 0 | 30s | spelling ran and passed, then `Summary: 0 error(s)` |
+ | `nixie` | 0 | 1s | `All diagrams validated successfully!` |
+ | `doc-coverage` | 0 | 39s | `aggregate 4801/4858 98.83%`, meets the 80.00% threshold |
+
+ Three details are worth more than the verdict, because each is a way a green
+ could have been hollow. First, `markdownlint` shows *both* a `spelling` pass
+ (`current: typos.toml`) **and** an mdlint verdict (`Linting: 169 file(s)`,
+ `Summary: 0 error(s)`) — this gate's spelling prerequisite can abort before
+ mdlint ever runs, and a spelling-only log would have been an unknown wearing
+ a pass's clothes. Second, `test` printed `COV-4`'s
+ `coverage map: 1 of 8 capability groups written; 7 remaining` inside a
+ *passing* run, which is the whole point of the counter: a half-finished split
+ passes every other coverage check, so a stall is visible only if this line
+ still reaches the terminal. Third, every one of the seven logs carries a
+ verdict line the runner appended itself, of the form
+ `GATE=… EXIT= … HEAD=`; all seven name the same revision,
+ `a5455a1aae235fb7fd52e5db0ca9fda135329151`. That is what binds the evidence
+ to a revision rather than to whatever HEAD happened to be when the report was
+ written, and it is why the exit status was captured through `PIPESTATUS[0]`
+ rather than read off `tee`.
+
+ **The first green was not the last word, and CI caught what the local run
+ could not.** CI on `86160a53` reded `build-test` — a *required* check — on
+ `MD013/line-length`, at this very entry's line 1165, 81 columns against a
+ budget of 80. The cause is worth recording because nothing local would have
+ found it: the "verdict line" format was written as one unbreakable code span
+ of 80 columns, and `mdtablefix` cannot break *inside* a code span, so its
+ greedy fill emitted the line and MD013 rejected it. The local seven-target
+ run was green on `a5455a1a` because the offending prose did not exist yet —
+ it arrived in `86160a53`, the commit that recorded that run. **A gate result
+ covers the revision it ran on and no other**, which is the recurrence this
+ plan keeps meeting; the fix is to shorten the frozen token so the wrapper has
+ somewhere to break.
+
+ The one slow test was
+ `packaging_smoke_tests::packaged_manifest_retains_build_script_sources`,
+ which logged `SLOW [>120.000s]` and then **passed** inside its budget, under
+ a machine load average of 58 from other agents' runs. That is the known
+ cold-build-cost member and a load observation rather than a correctness
+ signal; it is recorded because a 120s flag in a log invites the wrong
+ question otherwise.
+
+ So the two readings this plan wanted now both exist and are kept separate:
+ CI's `3494 run / 3494 passed` on `c7ff9e4a` and on `1524a7e6`, and this local
+ run on `a5455a1a`.
+
+ **And the liveness proof is discharged for the `Owns` call site.** The guard
+ was mutated, not merely re-run: RFC 0006's row `0013` was edited so its
+ `Owns` cell read `` `8.1`; `8.1` ``, and only the `rfc_stdlib_coverage_tests`
+ binary was run. It failed —
+
+ ```text
+ Error: the `Owns` cell at docs/rfcs/0006-ansible-inspired-template-standard-library.md:2068 names from_json twice
+ Summary [0.035s] 18 tests run: 12 passed, 6 failed, 0 skipped
+ ```
+
+ — and on restoring the pristine file the same binary read
+ `18 tests run: 18 passed`. The message names the mutation's own line, which
+ is what shows the edit reached the parser rather than being silently dropped
+ by table parsing. **Six** tests red rather than one, because the map parse is
+ a shared load step, so a single malformed row fails every test that reads the
+ map; the guard was the sole error source for all six (the captured log's
+ distinct `Error:` lines number exactly one). The string is also unique to
+ this call site: of the tree's `names … twice` producers, only `map.rs:197`
+ uses the `the {cell} cell at …` shape, so nothing else could have worn its
+ message.
+
+ That proof covers `map.rs:164` only, so **the sibling call at `map.rs:162`,
+ which guards the `Optioned` cell, was proved separately** — the same argument
+ applies to it verbatim, and a per-call-site liveness claim needs a
+ per-call-site witness. Row `0017`'s `Optioned` cell was mutated to
+ `` basename ``; `` basename `` and the same binary run:
+
+ ```text
+ Error: the `Optioned` cell at docs/rfcs/0006-ansible-inspired-template-standard-library.md:2072 names basename twice
+ Summary [0.038s] 18 tests run: 12 passed, 6 failed, 0 skipped
+ ```
+
+ Both call sites now have a witness, and the cell label is what separates
+ them: each run produced exactly one distinct `Error:` line, and in the
+ `Optioned` run a count of the `` `Owns` `` label over the log returned **0**,
+ so the failure is attributable to the call site under test rather than
+ borrowed from its sibling. The guard at `map.rs:162` also runs *before* the
+ `Owns` guard at `:164`, so an `Optioned` failure can never mask an `Owns`
+ one. Note what the pair does and does not establish: neither call site is
+ unexercised now, but a surviving falsification attempt is *not* proof — each
+ is falsified-or-not by one mutation, which is exactly the standard this plan
+ asked for.
+
+ **A second change rides on this head: `uv.lock` is untracked.** The file was
+ never deliberately tracked. `a94a3006` staged its own verification entry with
+ `git add -A`, and the sweep carried `uv.lock` along with it, so the branch
+ *added* the file against `origin/main` —
+ `git diff --name-status origin/main…HEAD -- uv.lock` read `A`. Commit
+ `f42202a4` adds the name to `.gitignore` **and** removes it from the index,
+ because the ignore rule alone would have been inert: git answers from the
+ index for a tracked path and never consults the ignore rules, so the file
+ would have stayed tracked while looking ignored. The working-tree file is
+ deliberately left in place, so `uv` still finds the lockfile it wrote.
+
+ Ignoring it is right for this repository even though `uv`'s own guidance says
+ a lockfile "should be checked into version control". That guidance presumes
+ dependencies to lock, and this repository has none by design and in as many
+ words: `pyproject.toml` declares no `[project]` table and no
+ `[build-system]`, "so no Python distribution can be built from it and `uv`
+ never treats the repository as a Python project". The Makefile agrees in
+ every invocation — `uv tool run …` throughout, and `uv run --no-project …`
+ for the contract tests and the Python lint gates; the `--locked`/`--frozen`
+ flags elsewhere in the tree belong to `cargo build`. What would be locked is
+ a three-line stub naming no package.
+
+ The change is inert by measurement rather than by assumption. The only test
+ that reads `.gitignore` copies it into a scratch repository and asserts that
+ each name in `MACHINE_LOCAL_DIRECTORIES` *is* ignored — one-directional, over
+ fifteen *directory* names that `uv.lock` is not among, so a new file pattern
+ cannot reach it. The two tests that shell out to `git ls-files` are both
+ path-scoped (`MUTATIONS_DIR`; `Cargo.toml`/`**/Cargo.toml`). `typos.toml`
+ already lists `uv.lock` in `extend-exclude`, so the spelling gate expects the
+ file to exist and is indifferent to who owns it. No test requires it tracked,
+ and none reads it.
+
+ This is a change outside `EP-M4`–`EP-M11`'s scope and is recorded here for
+ that reason: the branch carries it, so the plan and the pull request
+ description must both name it rather than let a reviewer discover it.
+
+- **The seven bot findings open at this head are already answered; the review
+ is stale, not unresolved.** Four from `chatgpt-codex-connector` and three
+ from CodeRabbit's `CHANGES_REQUESTED` pass `5332303125` all anchor at
+ `658b8157`, and every one names a defect this branch fixed afterwards.
+ CodeRabbit says so itself: each of its three carries
+ `✅ Addressed in commits 797178c to 68c266e`. The four codex comments do not
+ self-annotate, so each was checked against the revision rather than the
+ anchor:
+
+ | Finding | Subject | State at `6ff02d87` |
+ | ----------------- | ------------------------------------- | ------------------------------------------------- |
+ | codex `…609` | an `Owns` cell repeats a section | `ensure_distinct`, `map.rs:162`, `:164` |
+ | codex `…616` | duplicate child RFC numbers | `ensure!`, `map.rs:132` |
+ | codex `…627` | non-string JSON mapping keys | section 5.7 rejects non-distinct rendered keys |
+ | codex `…620` | serializers unbounded | section 5.8's output bound, both serializers |
+ | CodeRabbit `…304` | ADR-040 specimen contradicts RFC 0013 | specimen states `document_count` is not inherited |
+ | CodeRabbit `…313` | duplicate child RFC reservations | the `map.rs:132` guard |
+ | CodeRabbit `…325` | a fence opened by four spaces | `opening` rejects more than three |
+
+ The second codex row is rehearsed by a seeded fault; the rest are guards read
+ at the revision named in the first column.
+
+ **A caution for anyone following that annotation after the rebase.** The span
+ `797178c to 68c266e` was correct when CodeRabbit wrote it and is now
+ misleading in a way that does not announce itself:
+ `git merge-base --is-ancestor 68c266e8 HEAD` fails, because the 2026-09-28
+ rebase rewrote the branch and neither object is an ancestor of any current
+ branch or of `origin`. They survive only because the rebase preserved the
+ pre-rebase head, so `68c266e8` is reachable from
+ `refs/recovery/6-1-1-old-head-20260928-162555` and nowhere else. The content
+ they introduced did *not* go missing — the guards and prose each row of the
+ table names were re-read at `b89652b9` and all four are present there — but
+ the *citation* now points into a history that only the recovery ref holds.
+ This is the same class as the plan's other rebase-provenance lessons: a
+ rebase invalidates every SHA cited across it, including one cited by a
+ reviewer rather than by this plan, and a reader who checks the annotation
+ finds a missing object rather than a missing fix.
+
+ Two codex findings are worth recording as *not applied as written*, because
+ their mechanisms were wrong even though the defects were real, and `630b8116`
+ says so. `…627` claimed the round trip was impossible, but section 6.7's
+ canonical form of an integer key *is* its string form, so `{1: "a"}` and
+ `{"1": "a"}` canonicalize identically and the round trip holds; the real
+ defect was adjacent and unnamed — `from_yaml` accepts a mapping holding both
+ keys, which would render a duplicate key its own inverse rejects. And
+ `…609`'s suggested remedy ("reject any existing owner") cannot be applied at
+ all: row `0018`'s `Owns` clause resolves to `glob` and its `Optioned` cell
+ names `glob` again, so a blanket reject would red the live document.
+
+ **The lesson is about review state, not about the bots.** A comment anchored
+ at the current head is not necessarily live. GitHub re-anchors a comment on
+ push whenever its line survives, so `commit_id` tracks the head while the
+ comment body still describes an older revision — `line` and `position` are
+ both non-null for all seven here, which is exactly the shape that reads as
+ "live". Treating the anchor as the verdict would have sent a reader to re-fix
+ three defects that were already fixed. Read the body, and check the revision
+ it describes.
+
+- **A credential report filed earlier was wrong, and is corrected here.** A
+ push failed with `could not read Username for 'https://github.com/…'`, and
+ the first diagnosis blamed the Lody credential helper's `missing_path`
+ early-return with `credential.useHttpPath` unset. That diagnosis was reported
+ to Lody as an environment misconfiguration. The retry then succeeded, which
+ prompted a second look, and the second look contradicts the first. The
+ harness **injects `credential.useHttpPath=true`** into every command through
+ the `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/ `GIT_CONFIG_VALUE_n` channel:
+ `git config --show-origin --get-all credential.useHttpPath` reports
+ `command line: true`. The helper succeeds end-to-end when a `path=` is
+ supplied — run directly with its own debug log enabled at
+ `http://127.0.0.1:41269`, it records `request` to `broker_config` (from an
+ `env_var`) to `fetch` to a `200` `fetch_response` to `success`, and emits
+ `username=x-access-token` on stdout with a 40-character password.
+
+ The `missing_path` observation therefore came from a hand-built `printf`
+ probe, which omitted `path=` and so never reproduced what a real push sends.
+ It was a true statement about a command that is not the one that failed. An
+ earlier `git ls-remote` "proof" was void for the same reason in reverse:
+ `leynos/netsuke` is **public**, so that read needs no credentials at all and
+ could not have exercised the helper. `git push --dry-run` exits 0, and that
+ is the ordering which actually exercises write auth. The single intermittent
+ failure is left **unexplained rather than misattributed**; the filed report
+ stands as a report of a symptom, and this entry is the correction to its
+ diagnosis. **A probe that does not reproduce the failing command's inputs
+ cannot establish the failing command's cause.**
+
+- [x] (2026-09-28) **The local `coderabbit review --agent` pass was read and
+ dispositioned; its seven in-scope findings are fixed in the commit carrying
+ this entry.** The run is `REV=ac314a94`, `CR_STATUS=0`, 617 s, 23 findings.
+ The count is 23, not 7, and the difference is the whole reason this entry
+ exists: the local review scans the **working tree**, while the GitHub review
+ scans the **PR diff**, and the two sets are not the same artefact. Sixteen of
+ the 23 land in files this branch never touches — `.github/scripts/`
+ nextest-oracle modules, `tests/kani_scope_wrapper_e2e_tests.rs`, other
+ branches' execplans — so they belong to the revisions that wrote them and are
+ recorded as out of scope rather than silently dropped. They are not "skipped
+ as unimportant"; they are not this PR's to fix.
+
+ All seven in-scope findings were verified against the document text before
+ any edit, and all seven were correct. Four were stale facts the plan's own
+ later entries already contradicted: the ToR claim, the still-open blocker
+ checkbox, the superseded standalone-PR instruction, and the missing
+ `.gitignore` line. Three were substantive: the `fourteen` test count (the
+ breakdown was wrong too — 7 + 6 + 5, not 7 + 3 + 4), the `D10`/`:2148`
+ contradiction, and the first-person passages. The last of these is a real
+ style-guide rule (`docs/documentation-style-guide.md:39`), though a corpus
+ probe shows it is widely violated elsewhere in `docs/execplans/`; that makes
+ the rule no less binding on this file, and the remaining quote at line 835 is
+ verbatim bot text and correctly left alone.
+
+ One correction went further than the finding asked, because the finding's own
+ premise was checked rather than trusted. The ToR entry's existing `924cb215`
+ pin is a commit that **predates the document's existence**; the ToR arrived on
+ `main` in `96b89ca9` (PR #786). Citing the document at a revision where it
+ does not exist would have been a fresh instance of exactly the staleness
+ class this entry is clearing, so the new artefact carries its own pin and
+ says why it differs from its neighbour.
+
+ **The commit that fixes all seven then reded `check-fmt` in CI, and the
+ reason is a green from the wrong command.** After editing, the plan's
+ Markdown was verified with a bare `mdtablefix --check `, which reported
+ "1 file left unchanged". The gate does not use that invocation. It runs
+ `mdtablefix --check --git --include-untracked --wrap --renumber
+ --breaks --ellipsis --fences`,
+ and `--wrap` is the flag that rewraps prose to the line width; the bare form
+ is a strictly weaker check that does not rewrap at all. The gate's own
+ command reported `+45 -45` on the same file, which `build-test` — a
+ *required* check — caught and reded. The edit was then canonicalized with
+ `--in-place` under the gate's flags, after which the gate's own command
+ reports `169 files left unchanged`, exit 0. The rewrap is provably
+ content-free: the word sequence before and after is identical (34481 words,
+ compared programmatically rather than by eye).
+
+ This is the plan's recurring theme in a new costume. The earlier instance was
+ "a gate result covers the revision it ran on and no other"; this one is **a
+ verifier that is not the gate's verifier is not the gate**, and a pass from
+ it is not a pass. Both share the same root: an artefact was treated as
+ evidence for a claim it does not cover. The remedy is the same too — run the
+ command the gate runs, not a command that resembles it.
+
+ The fix above then produced a third instance, caught by a local gate run on
+ `feed5192` rather than by CI. The sentence *about* canonicalization was
+ itself written `canonicalised`, which `make spelling` rejects under the
+ en-GB-oxendict `-ize` rule, so `spelling` aborted and `markdownlint` reported
+ **no verdict at all** — an unknown that the outside observer would have read
+ as green had the abort not been noticed. Two properties of that failure are
+ worth keeping. First, the word was outside backticks: the plan already
+ records that a code span is exempt from the rule, and the only reason this
+ one was not exempt is that it is plain prose. Second, the same file contains
+ `recognise` twice, and both are correct as they stand — each is a backticked
+ quotation of a *different* revision's gate output, so the exemption still
+ applies and "fixing" them would have falsified a historical record. A
+ repo-wide `-ise` sweep is therefore not a safe repair; the distinction is
+ prose versus quoted evidence, not one spelling against another.
+
+- [ ] `EP-M4` RFC 0014, mapping and sequence transforms (step 6.3).
+- [ ] `EP-M5` RFC 0015, ordered collection algebra and truth predicates (6.4).
+- [ ] `EP-M6` RFC 0016, pattern and version predicates (step 6.5).
+- [ ] `EP-M7` RFC 0017, lexical path composition (step 6.6).
+- [ ] `EP-M8` RFC 0018, host-state predicates and environment expansion (6.7).
+- [ ] `EP-M9` RFC 0019, encoding, identity, and formatting (step 6.8).
+- [ ] `EP-M10` RFC 0020, date and time conversion (step 6.9).
+- [ ] `EP-M11` Reconcile, retarget roadmap citations, run all gates, mark
+ roadmap 6.1.1 done.
+
+- [x] (2026-09-28) **A gate run reded the current head, and the defect was the
+ branch's own.** `make markdownlint` on `feed5192` exited 2, but not because
+ `markdownlint` found anything: its `spelling` prerequisite aborts on
+ `canonicalised` at this file's line 1391, so `markdownlint-cli2` never ran
+ and emitted no verdict at all. The word was added by `feed5192` itself, in
+ the very sentence describing canonicalization — a `-ise` form where the
+ project's en-GB-oxendict rule mandates `-ize`. CI then failed the required
+ `build-test` check on the same word at the same line and column, so the local
+ diagnosis and CI agree independently rather than one being inferred from the
+ other.
+
+ The repair is a one-word substitution of equal length, so no wrap boundary
+ moves; that was verified with the gate's own command rather than assumed,
+ since `mdtablefix` reflows prose greedily and a longer or shorter token would
+ have reded `check-fmt` in exchange. Two neighbouring spellings were
+ deliberately left alone: this file contains `recognise` twice, and both are
+ backticked quotations of a *different* revision's gate output, so the
+ code-span exemption applies and rewriting them would falsify a historical
+ record. The general lesson is recorded under `Surprises & discoveries` — for
+ this class the deciding question is prose versus quoted evidence, not one
+ spelling against another, so a repo-wide sweep is not a safe repair.
+
+ The episode is the third instance of one theme, and the plan now says so in
+ one place: the earlier two were "a gate result covers the revision it ran on
+ and no other", and "a verifier that is not the gate's verifier is not the
+ gate". This one is "a failed prerequisite hides a stage that never ran", and
+ its tell is the same in all three — an artefact was read as evidence for a
+ claim it does not cover. A green `check-fmt` and a green `spelling` are now
+ recorded on `c765659d`, with `mdlint` reaching `169 files, 0 error(s)` for
+ the first time on this branch, which also demonstrates the prerequisite is
+ live rather than vacuous.
+
+ Re-verifying the seven stale bot findings at this head turned up one stale
+ citation of the plan's own. The `chatgpt-codex-connector` finding that the
+ serializers were unbounded is discharged by RFC 0013 **section 5.8**
+ ("Resource bounds"), where `to_yaml` and `to_nice_json` each carry "output 8
+ MiB; checked before the result is returned" — but the disposition table above
+ credited "section 5.3's length pass", and 5.3 is "Determinism", which
+ contains no such pass. The two sections are distinct entries in this plan's
+ own list of substantive clauses, so the number was simply wrong. Corrected in
+ place. All seven findings were then confirmed discharged at HEAD, not merely
+ self-annotated: the `map.rs` duplicate-number guard, `ensure_distinct`'s two
+ call sites, and the two RFC 0013 obligations were each read at the revision.
+
+- [x] (2026-09-28) **The branch was rebased onto `main`, 53 commits replayed
+ one-to-one with no conflict.** `OLD_HEAD` was `c765659d`, `OLD_BASE`
+ `aa764819` (the branch's exclusive replay boundary), target `7677c388`, and
+ the result is `93a5d8b6`. `git range-diff` reports `=` for all 53 pairs and
+ emits no non-summary output, so every replayed patch is byte-identical and
+ nothing was resolved by hand. The old range and the new range each contain 53
+ commits and no merges, `OLD_BASE` is an ancestor of `OLD_HEAD`, and
+ `OLD_HEAD` is no longer an ancestor of the result — the expected shape after
+ a replay.
+
+ The reason the replay was mechanical is worth recording, because it is a
+ property of the *pair* of revisions rather than luck: main's three new commits
+ (`7677c388`, `027a848e`, `dc4c116a`) touch ten paths and this branch touches
+ twenty-seven, and **the two sets are disjoint**. There was therefore no file
+ for a merge driver to arbitrate, which is also why the replay needed no
+ driver-consent decision — no path was ever selected for a three-way merge.
+ That was checked rather than assumed: `comm -12` on the two path manifests is
+ empty, and all ten main-only paths are byte-identical at `93a5d8b6` to their
+ `7677c388` blobs, which is the audit the merge policy requires of a
+ target-only path.
+
+ **Main's incoming changes are not pertinent to this branch, and that is
+ evidenced rather than asserted.** The three commits change workflows, the
+ `Makefile`'s Pylint invocation, `docs/developers-guide.md`, and add
+ `tests/workflow_contracts/pylint_tier_test.py`. The branch is documentation
+ plus Rust source plus one `.gitignore` line, so the overlap in *subject
+ matter* is nil; more concretely, every `file:line` citation this plan makes
+ was re-resolved at the new head and no citation names a main-touched path
+ except the one corrected below. The `Makefile`'s Pylint refactor is the
+ nearest miss: it changes how the *Python* baseline lint runs, and this branch
+ touches no Python. So the decision is to adopt none of it and to record why,
+ rather than to manufacture a merge.
+
+ Two things did need attention. First, `uv.lock`. The incoming commit
+ `d03a3e31` (a replay of `a94a3006`) had swept `uv.lock` into the repository
+ with `git add -A`, while the later commit `224dc762` (a replay of `f42202a4`)
+ untracked it and added `.gitignore:21`. The rebase therefore stopped with
+ "The following untracked working tree files would be overwritten by merge:
+ uv.lock". The on-disk copy is a 52-byte file that no configuration in this
+ repository reads, and it is byte-identical (`md5 8bbc054c…`) to the incoming
+ blob, so removing the blocker discards nothing. It was preserved to
+ `/tmp/rebase-6-1-1-untracked/uv.lock`, restored after the rebase, and
+ verified at the new head to be both present and ignored
+ (`git check-ignore -v` names `.gitignore:21`) and untracked (`git ls-tree`
+ finds no entry). The commit that untracks it survives as a non-empty commit,
+ so its `.gitignore` hunk still applies.
+
+ Second, the rebase exposed a stale citation of this plan's own, in the same
+ class as the `5.3`→`5.8` correction above. The `doc-coverage` observation
+ cited `Makefile:206-210` as its evidence. That span is the `RUSTDOC_FLAGS`/
+ `VERUS_FLAGS`/`WHITAKER` block, not the `doc-coverage` target, at every
+ revision examined — including `b23d0535`, the commit that introduced the
+ citation, where the target sits at line 325. The `206` was correct at an
+ earlier main state (`5fda1e6e`), so the citation drifted as main moved and
+ nobody re-resolved it. Main's Pylint hunk made it drift once more, by
+ deleting one line at 161–168 and shifting everything after 168 up by one. It
+ now reads `Makefile:325-329`, which is the `doc-coverage` target and its
+ recipe, and the claim it supports (`scripts/doc-coverage.py` measures library
+ and binary targets; integration tests are not measured) is carried by the
+ script's own module docstring at `scripts/doc-coverage.py:4`.
+
+ This is the fourth instance of the plan's recurring theme, and the sharpest:
+ all three earlier ones were about *running* the wrong check. This one is
+ about a citation that was never re-resolved after the file under it moved — a
+ `file:line` is a pointer, and a pointer into a moving file is a claim with an
+ expiry date. The rebase is precisely the event that invalidates it, which is
+ why the sweep belongs in the rebase audit rather than in a later review. The
+ remedy applied here is a bounds-and-identity pass over every citation the
+ plan makes at the new head, not a spot fix.
+
+ Recovery refs are retained until publication is confirmed:
+ `refs/recovery/6-1-1-old-head-20260928-162555` (→ `c765659d`),
+ `-old-base-20260928-162555` (→ `aa764819`), and `-target-20260928-162555` (→
+ `7677c388`). The four gates the rebase hook names (`check-fmt`, `typecheck`,
+ `lint`, `test`) were run in sequence on `93a5d8b6` immediately after the
+ replay, and all four exited 0. Those results are bound to `93a5d8b6`; this
+ entry is a later revision and they do not cover it. The seven-target run that
+ acceptance requires is therefore commissioned against the commit that adds
+ this entry, because a rebase creates a new candidate and every gate result
+ bound to `c765659d` is historical.
+
+- [x] (2026-09-28) **All seven gates pass on `802be5ea`, and `markdownlint` is
+ a real pass rather than the UNKNOWN the earlier episode produced.** The seven
+ targets ran sequentially on the post-rebase head: `check-fmt` (4s,
+ `164 files already formatted`, mdtablefix `169 files left unchanged`), `lint`
+ (14s, clippy `-D warnings` clean, both pylint runs `10.00/10`, interrogate
+ `100.0%`, yamllint and actionlint clean), `typecheck` (1s, ty
+ `All checks passed!`), `test` (180s, nextest
+ `3494 tests run: 3494 passed (1 slow), 6 skipped`, doctests 87+2+39 passed
+ with 0 failed), `markdownlint` (14s), `nixie` (1s,
+ `All diagrams validated successfully!`), and `doc-coverage` (7s,
+ `aggregate 4801/4858 98.83%`, meets the 80% threshold).
+
+ The `markdownlint` verdict was checked for the *state*, not merely the exit
+ code, because the earlier episode established that a red prerequisite makes
+ the target report nothing at all and an outside reader takes silence for
+ green. Both tells are present and in order — `Linting: 169 file(s)` at line 5
+ and `Summary: 0 error(s)` at line 6 — so `markdownlint-cli2` genuinely ran.
+ This is the first seven-target run on this branch where every gate is green
+ *and* every verdict is a verdict.
+
+ Two log artefacts were inspected rather than waved through. `lint`'s log
+ carries `Blocking waiting for file lock on package cache` three times, which
+ is the shared Cargo cache serializing access as intended, not a defect. And
+ the candidate for a false reading is the `test` log, which is 669 KB and
+ contains the strings `error` and `FAIL`: every `error` occurrence is a test
+ *name* under an `error::tests` module, and `FAIL` does not occur at all. Both
+ were established by reading the lines, not by the absence of a grep hit.
+
+ Logs are `/tmp/g3--6-1-1.out`. These results cover `802be5ea` only, and
+ the later revision is covered separately below.
+
+- [x] (2026-09-28) **CI's full gate set and all four required checks pass on
+ `09e609ab`, the current head; the four Markdown-sensitive targets were also
+ re-run locally on it.** This entry closes the gap the entry above left open
+ on purpose — a gate result covers the revision it ran on, so `802be5ea`'s
+ green did not speak for the commit that recorded it.
+
+ CI is the stronger of the two readings because it is revision-bound and it
+ ran the whole union rather than a subset. `build-test` on `09e609ab` completed
+ `success` after running `Format`, `Lint Markdown`, `Lint`, `Typecheck`,
+ `Doc coverage`, `Spelling`, `Validate Mermaid diagrams`,
+ `Workflow contract tests`, and `Test and Measure Coverage` — every step
+ `success`. Its log carries the two verdicts that matter here:
+ `3494 tests run: 3494 passed (1 slow), 6 skipped`, and `Linting: 169 files`
+ followed by `Summary: 0 issues in 0 files`.
+
+ The four required checks are `success` on the same revision, and each
+ *completed after* the commit was created at 14:51:11Z — which is what makes
+ them evidence about this revision rather than about an ancestor:
+
+ | Check | Conclusion | Completed | Job |
+ | -------------------- | ---------- | --------- | -------------------- |
+ | `build-test` | success | 15:08:03Z | `…/job/108985193830` |
+ | `kani-smoke` | success | 15:06:36Z | `…/job/108985193426` |
+ | `netsukefile` | success | 14:54:57Z | `…/job/108985192215` |
+ | `release / metadata` | success | 14:53:38Z | `…/job/108985197560` |
+
+ All four job links point at runs whose `head_sha` is `09e609ab…`, checked
+ through the runs API rather than read off the check-run row, since a summary
+ row also lists checks that never ran.
+
+ The local re-run is the weaker reading and is recorded as such. The delta
+ `802be5ea` → `09e609ab` changes exactly one path — this ExecPlan
+ (`git diff --name-only --no-ext-diff 802be5ea 09e609ab` prints one line) — so
+ only the Markdown-sensitive targets can move, and those four were run again:
+ `check-fmt` (`164 files already formatted`; mdtablefix
+ `169 files left unchanged`), `markdownlint` (`Linting: 169 file(s)` then
+ `Summary: 0 error(s)` — both tells present, so `markdownlint-cli2` genuinely
+ ran), `nixie` (`All diagrams validated successfully!`), and `doc-coverage`
+ (`aggregate 4801/4858 98.83%`). Logs are `/tmp/g4--6-1-1.out`, written
+ at 14:51:38Z–14:52:16Z.
+
+ Two honest limits on the local half. First, those log files carry **no**
+ runner verdict line of the `GATE=… EXIT= … HEAD=` form that the
+ `a5455a1a` run appended; the verdicts above are read from each tool's own
+ terminal success line, which is one step weaker than an exit status captured
+ through `PIPESTATUS[0]`. Second, the other three targets were not re-run
+ locally, because they are not reachable by this delta — but that is an
+ argument from the diff, not a measurement, and CI supplies the measurement on
+ the same revision.
+
+ That the evidence still describes the current head is itself checked, not
+ assumptions: `git reflog` shows no commit since `09e609ab` and
+ `git ls-remote origin` agrees with the local ref.
+
+ The run has since closed out, and the whole of it is green rather than the
+ required four alone: `36439268002` is `completed/success` with every job
+ `success` — `build-test`, `kani-smoke`, `netsukefile`, `release / metadata`,
+ and also `Windows / lint-windows`, `Windows / build-test-windows`, and
+ `Windows / windows-msi-upgrade`. `lint-windows` is the one worth naming
+ because it was the last to finish and it is *not* quick: its
+ `Lint (Whitaker)` step ran 15:01:43Z–15:16:12Z against `Lint (Clippy)`'s
+ 15:00:58Z, so a reader watching the run mid-flight would have seen a 14-minute
+ `in_progress` on a step whose local counterpart takes seconds. That is
+ dylint building its lint library from source on a cold runner, not a stall —
+ the diagnostic the earlier deadlock entry taught, applied forward: an
+ unexplained wait gets measured against its own baseline before it is called a
+ wedge.
+
+- [x] (2026-09-28) **All seven targets pass on `84e4fa82`, the commit that
+ lands the entry above, so the full local second reading this plan owes is
+ discharged on the acceptance revision.** `check-fmt`
+ (`164 files already formatted`; mdtablefix `169 files left unchanged`),
+ `lint` (clippy `-D warnings` clean, both pylint runs `10.00/10`, `ambrleaks`
+ silent, interrogate `100.0%`, yamllint and actionlint clean), `typecheck` (ty
+ `All checks passed!`, then `cargo check --all-targets --all-features`
+ finished), `test` (nextest `3494 tests run: 3494 passed (1 slow), 6 skipped`,
+ then doctests `87 + 2 + 39` passed with 0 failed), `markdownlint`
+ (`Linting: 169 file(s)`, `Summary: 0 error(s)`), `nixie`
+ (`All diagrams validated successfully!`), and `doc-coverage`
+ (`aggregate 4801/4858 98.83%`). Logs are `/tmp/g5--6-1-1.out`.
+
+ Four of the seven were logged *before* the commit and three after (the commit
+ is 15:23:44Z; `markdownlint`, `check-fmt` and `nixie` closed at
+ 15:21:36Z–15:21:52Z, `doc-coverage` at 15:22:43Z, and `lint`, `test` and
+ `typecheck` at 15:24:27Z–15:29:01Z). That is not a gap in the evidence,
+ because the commit only had to capture a working tree the gates had already
+ read, and `git status --porcelain` was empty both before the commit and after
+ — the tree did not move between the pre-commit gates and the commit, and
+ `git rev-parse HEAD` has read `84e4fa82` at every probe since. Recording the
+ *order* rather than the aggregate is the point: an earlier entry in this plan
+ had to say that its gate log described the commit that recorded it and
+ therefore did not cover it, and the remedy that produced `84e4fa82` was to
+ gate the tree and then commit exactly what was gated.
+
+ The `test` log was read rather than grepped, for the same reason as before:
+ it carries `coverage map: 1 of 8 capability groups written; 7 remaining`
+ inside a *passing* run. That line is the whole point of the counter — a
+ half-finished split passes every other coverage check, so a stall is visible
+ only if this line still reaches the terminal — and `7 remaining` is the
+ correct reading at `EP-M3`, with `EP-M4` not yet begun. `FAIL` does not occur
+ at all (count 0), and the doctest target count is 2, matching the two
+ `Doc-tests` headers this plan records as the real number rather than the
+ three a hasty grep suggests.
+
+ **The MD038 fix was then carried forward, and the two affected gates re-run at
+ `6720c2b9` rather than argued about.** Writing the paragraph above reded
+ `markdownlint` on a code span with a trailing space — `` `Doc-tests ` `` —
+ which is MD038, and the repair was to shorten the span to `Doc-tests` rather
+ than to touch the rule. `check-fmt` and `markdownlint` were re-run on the
+ fixed tree (both green, the latter again in state 3 with
+ `Linting: 169 file(s)` before `Summary: 0 error(s)`), and the commit
+ `6720c2b9` lands exactly that tree.
+
+ `nixie` and `doc-coverage` were re-run too, and the reason is worth stating
+ because the first instinct was to exempt them as Markdown-irrelevant. They
+ are not, for `nixie`: a probe of its own log shows it names **this** plan (2
+ hits) among 47 `docs/execplans/*.md` files in a 170-file sweep, so a
+ one-character edit inside a plan is inside `nixie`'s scope even though the
+ plan carries no mermaid fence of its own. Both re-ran green
+ (`All diagrams validated successfully!`; `aggregate 4801/4858 98.83%`).
+ `doc-coverage` reads no Markdown at all (0 hits for `execplans`), so its
+ re-run was the cheap confirmation of that rather than a necessity.
+
+ The loop this plan has hit before is recording a gate run in a tracked file,
+ which moves HEAD past the revision the run verified. It is not re-opened here:
+ `6720c2b9` is the last revision verified, and the delta since `84e4fa82` is
+ the MD038 repair plus this paragraph — prose, in this one file, whose Rust
+ content is byte-identical to what the seven-target run passed. Earlier
+ entries in this plan handled the same situation by *stating the last verified
+ revision and the delta*, and that is what is done here. CI covers the same
+ four required contexts on every push and is the authority for any tip beyond
+ `6720c2b9`.
+
+## Surprises & discoveries
+
+- Observation: **two independent safety nets can both report success while
+ neither is watching.** Evidence: `EP-M3`'s acceptance criterion is "every
+ gate green", and it was pursued through two channels that both silently
+ no-op'd. CodeRabbit's commit status on `f5300602` reads `SUCCESS`, while its
+ only comment on the pull request says "Draft PR not reviewed" — a status that
+ means "did not fail", not "did review". GitHub Actions reports nothing at
+ all: PR #697 is `CONFLICTING`/`DIRTY`, and because GitHub cannot construct the
+ `refs/pull/697/merge` ref for a conflicted pull request, every
+ `pull_request`-triggered workflow is suppressed outright rather than failing.
+ Neither condition produces an error, a red check, or a notification. Impact:
+ the branch's 23 commits have never been CI-validated, and the last CI run on
+ it is `3f02ee37`, its opening push on 2026-09-08. Lesson: a green status is
+ only evidence if it was *earned by a run* — check that the run exists before
+ reading its conclusion, and treat an absent run as a failure, not as silence.
+ The mitigation is structural, not vigilance: keep the pull request mergeable,
+ because a conflict disables the entire CI channel.
+
+ *Amendment, added the same day: the mitigation worked, and this Observation
+ is kept in its original tense because it records a state that no longer
+ holds.* PR #697 became `MERGEABLE` when the conflict was resolved, and the CI
+ channel reopened with it: the branch has since been validated on `09e609ab`
+ (run `36439268002`, `completed/success`, every job green) and again on
+ `eb21dec1` (run `36445217340`, triggered 2026-09-28T15:39:14Z). The sentence
+ "the branch's 23 commits have never been CI-validated, and the last CI run on
+ it is `3f02ee37`" was true when written and is now false; it is left standing
+ because the lesson depends on it. What the amendment adds is the confirmation
+ that the *structural* fix — keep the pull request mergeable — is what
+ restored the channel, rather than attention or luck.
+- Observation: **"every gate green" is the wrong acceptance criterion for a
+ shared, contended host, and the plan wrote it anyway.** Evidence: the same
+ commit `288526a2` is red locally (`make test`, one nested-Cargo compile test
+ timed out at 300.008s with load average 74.39 on 24 cores) and green on CI
+ (3408/3408 passed, no timeout, all 3408 in 415.659s). Both are honest runs of
+ the same code. Impact: the milestone was gated on an acceptance criterion
+ that its own environment cannot reliably satisfy, so a correct change could
+ be held indefinitely by host load, and the natural failure mode is to keep
+ re-running until it goes green — which is exactly the "flake" reasoning this
+ plan's own memory rule forbids. Lesson: when a criterion is about the
+ artefact rather than the machine, say so, and name the authority that
+ measures the artefact. Here that authority is CI, which runs on a clean guest
+ with the repository's own budgets and no other tenants; a local gate run's
+ job is to be *green where it can be* and to have its residual failures
+ *attributed*, not eliminated. The plan should have read "every deterministic
+ gate green, and any remaining failure attributed to a named, measured cause".
+ `EP-M3` is accepted on that reading, with the local red recorded rather than
+ suppressed.
+- Observation: **a delegated causality check can be reported in a form that
+ does not reproduce, while its conclusion still holds.** Evidence: the ninth
+ gate run reported that
+ `git diff --name-only origin/main…HEAD -- crates/ src/ tests/ tests/ui/
+ tests/support/`
+ "returns 0 paths", and drew from that the conclusion that the failing test
+ is byte-identical to `origin/main`. Re-running it returns **17** paths — this
+ branch's entire `tests/rfc_stdlib_coverage*` tree. The conclusion is
+ nevertheless true, and provable a different way: the failing test's own files
+ are untouched
+ (`git diff --name-only origin/main…HEAD -- tests/command_env_ui_tests.rs
+ tests/ui/cli_configuration_pass/`
+ is empty). Impact: had the conclusion been false and the command cited as
+ its warrant, an inherited failure could have been waved through. Lesson:
+ re-run a subagent's headline command before relying on it, and prefer an
+ attribution that names the specific test's files rather than a coarse
+ directory prefix — a path-prefix filter is exactly the kind of probe that can
+ pass for the wrong reason, since `tests/` is both the directory this branch
+ adds to and the directory the failing test lives in.
+- Observation: **a corpus-wide invariant test finds defects in files the branch
+ does not own, and a rebase can hand it new ones.** Evidence: the dangling
+ `netsuke-test-framework-technical-design.md` link at `96aefc9c` is on
+ `origin/main`'s tip and is *live there* —
+ `git show origin/main:docs/rfcs/ 0007-…md` line 62 carries the bad form while
+ line 22 of the same file carries the correct `../`. Main's own CI is green on
+ that commit (`build-test`, `Windows / lint-windows`, `kani-smoke`,
+ `netsukefile` all `success`), because `links::dangling` and its
+ `inter_document_links_resolve` caller exist only on this branch:
+ `git ls-tree -r origin/main tests/` has no `rfc_stdlib_coverage*` entry at
+ all. Impact: the invariant is this branch's to enforce and no upstream gate
+ shares it, so the failure could only ever appear here, and it appeared only
+ because a rebase imported a document neither party was editing in this branch
+ — the RFC 0006 split never touches RFC 0007. Lesson: when a branch adds a
+ test that reads the whole corpus rather than its own diff, re-run it after
+ every rebase and expect it to indict the incoming commits, not the branch.
+ The repair belongs in the branch (and rides to main with it) rather than in a
+ separate upstream pull request, because the two are the same edit.
+- Observation: **"environmental" can be the right verdict for the wrong
+ reason.** Evidence: the seventh and eighth gate runs both correctly cleared
+ this branch's diff of blame for the two `make test` timeouts, and both
+ reached for host load as the cause. Load was real but not decisive: the two
+ tests are over budget on this base *by construction*. `origin/main` already
+ fixed the class, and not by widening a timeout — it moved
+ `harness_compiles_under_a_split_build_dir` onto recorded parser coverage and
+ serialized the remaining nested Cargo builds into a `max-threads = 1` group.
+ Impact: the load explanation implied "re-run when quiet", which would have
+ burned another 400-second gate run and failed the same way. The decisive
+ check is ancestry, not load: `git merge-base --is-ancestor e2fc2083 HEAD`.
+ Lesson: when local gates disagree with CI, check whether the fix already
+ landed upstream before explaining the discrepancy from the machine.
+- Observation: **a review finding can name a real defect and still prescribe the
+ wrong fix**, and the fix is the part that ships. Evidence: the `CONF-1`
+ deference finding asked that `as Ansible does` stop being flagged and offered
+ `such as Ansible` as the false positive to fix instead. The first half is
+ wrong: `as Ansible does` is this plan's *own recorded seeded fault*, the
+ transcript at `docs/execplans/…md` showing
+ `justifies a helper by appealing to Ansible ("as Ansible")`, and it is proven
+ to fire. Un-flagging it would have turned a green control into a
+ green-looking empty one — exactly the failure this plan's `CONF-1`
+ observation already records having been bitten by once, when the obligation
+ sat as prose for six days with nothing implementing it. The second half is
+ right: `Unlike Ansible` contains `like Ansible` and means the reverse, and
+ `such as Ansible` is a compound preposition introducing an example. Impact:
+ the leading-boundary fix was adopted and clears `Unlike Ansible`;
+ `such as Ansible` needs a separate exclusion because its `as` is its own
+ word, so the boundary alone cannot reach it. The reviewer's `as Ansible does`
+ example was refused, with the refusal recorded in the code's own doc comment
+ so the next reader does not re-litigate it. Lesson: verify a finding's
+ *examples* as well as its mechanism — the mechanism here was sound and the
+ boundaries it proposed were not.
+
+- Observation: two of this pass's findings asserted a mechanism about the
+ document that the document does not support, which is the same failure mode
+ as a rule whose stated reason is false. Evidence: `section7.rs`'s comment
+ claimed `glob` "also reaches `accepted` as an accept row in its own right";
+ every section 7 row naming `basename`, `dirname`, or `glob` is a `Reject`
+ row, and `glob`'s only row is the `fileglob` reject at RFC 0006:498.
+ `document.rs`'s comment promised that a heading quoted inside a fence "still
+ lands on the real one", which `position()` cannot deliver — though no
+ document in the corpus has such a heading, and each looked-up heading is
+ unique, so nothing was failing. Impact: both were fixed at the source of the
+ false claim, and the `document.rs` fix went further than the finding asked by
+ making the promise true rather than only rewording it. A green suite was no
+ evidence either way here: neither defect could fail, which is what made them
+ survive two prior reviews.
+
+- Observation: a change can pass two CodeRabbit reviews and still fail the
+ commit gate, because the two read different things. The second pass's seven
+ Rust files were reviewed twice and cleared both times; `make lint` then
+ rejected two of them at `4c631e18`, on `too_many_lines` (73/70) and
+ `iter_skip_next`. Evidence: `/tmp/lint-netsuke--4c631e18.out:10,20`
+ against the same files at `c7c309d6`, where `clippy` was green. Impact: the
+ gate ran red on a tree CodeRabbit had just approved, which inverts the
+ expected order. The instruction to make every gate green *before* requesting
+ a review is not a formality about sequencing — it is what keeps the review
+ looking for semantics, because a reviewer asked to find style defects finds
+ some, and they are worse ones than `clippy`'s.
+
+- Observation: this repository's `clippy.toml` sets
+ `too-many-lines-threshold = 70`, well below the default 100, so a function
+ split by responsibility can still be rejected for length alone. Evidence:
+ `clippy.toml` against `map::parse`, which grew from 68 to 85 lines when the
+ link-number check was added to it. Impact: adding a check to an existing
+ function is a length risk even when the addition is small, and the remedy is
+ to extract the *reasoning* into named functions rather than to compress
+ statements.
+
+- Observation: RFC 0006 section 8.1 opens "All six helpers in this group are
+ pure" but specifies five. Evidence: `docs/rfcs/0006-...md:650` against the
+ five headings at 654, 671, 694, 707, and 728. Impact: correct to "five" in
+ `EP-M1`. The group totals do reconcile, so the defect is the word, not the
+ accepted set.
+
+- Observation: RFC 0006 section 8.6's group preamble states "Every helper in
+ this group is **pure and lexical**", but `expandvars` is in that group and
+ section 8.6 itself calls it the one environment-observing helper in the RFC.
+ Evidence: `docs/rfcs/0006-...md:1099` against `:1184`. Impact: a second
+ defect of the same class, sitting exactly on the seam this plan cuts. Correct
+ in `EP-M1` by scoping the sentence to the lexical helpers.
+
+- Observation: the naive heading count under section 8 is 58, not 57.
+ Evidence: three headings are prose rather than helpers (`:953`, `:1084`,
+ `:1502`); three headings each name two helpers (`:1280`, `:1289`, `:1321`);
+ `glob` (`:1261`) is an existing helper; and `basename` and `dirname` have
+ **no heading at all**, appearing only in the prose at `:1095`. Impact:
+ decisive. Any heading-based parser is wrong before it is written. This is the
+ strongest single reason the coverage test anchors on tables. See `D4`.
+
+- Observation: section 7.1 gives `basename`, `dirname`, and `expanduser` the
+ disposition `Reject`, with a note saying the helper already exists and gains a
+ `dialect` argument. Evidence: `docs/rfcs/0006-...md:468-470`. Impact:
+ `Reject` is overloaded three ways — table 11 splits it into 22 "already
+ provides", 10 "redundant alias", and 18 "on principle". Only the latter two
+ classes are forbidden. Two of the three helpers table 11 counts as gaining a
+ behaviour-preserving option are `Reject` rows. The derivation rules in `D5`
+ must handle this, or the test will forbid the very helpers it requires.
+
+- Observation: section 7 contains no accept row for `splitdrive` or
+ `shell_quote`. Both arrive from `Reject` rows — `win_splitdrive` and
+ `quote` — via the rename note in section 7.8. Evidence:
+ `docs/rfcs/0006-...md:478` and `:485`, reconciled at `:635-640`. Impact:
+ "every accepted capability covered once, every rejected covered never" is
+ unsatisfiable read naively for three helpers. The rejected thing is the
+ *Ansible spelling*; the *Netsuke capability* is accepted. `D5` records this
+ as an explicit three-row exception table.
+
+- Observation: the roadmap already deep-links every phase-6 task to its RFC
+ 0006 section. Phase 6 contains 51 such references, 39 of them to a section 8
+ subsection. Evidence: `docs/roadmap.md:715-1267`; for example task 6.3.1 at
+ `:863`. Impact: this falsifies the first draft's premise that a contributor
+ must read 2132 lines. Reaching the `combine` contract today costs about 80
+ lines. The plan's stated value had to be rebuilt around what is genuinely
+ absent — the per-helper contract obligations — rather than around navigation.
+
+- Observation: `make doc-coverage` runs `cargo rustdoc --show-coverage` over
+ library and binary targets only; integration tests are not measured. Evidence:
+ `scripts/doc-coverage.py`; `Makefile:325-329`. Impact: the governing gate on
+ the new test file is `missing_docs_in_private_items = "deny"`
+ (`Cargo.toml:252`) under `make lint`, which does apply and covers enum
+ variants and struct fields. `unwrap_used` and `expect_used` are also denied
+ (`Cargo.toml:213-214`), so parser helpers must return `anyhow::Result`.
+
+- Observation: `mdtablefix --wrap` does not wrap headings or table rows. A
+ 113-character heading sits on green `main` at `docs/rfcs/0006-...md:982`.
+ Impact: both candidate anchors are safe from reflow, so the choice between
+ them rests on semantics. Backticked spans in **prose** are not safe, which is
+ what corrupted the first draft.
+
+- Observation: no tooling parses `docs/roadmap.md`, and `markdownlint-cli2` is
+ configured with no cross-file link or anchor validation. Impact: a stale
+ relative link between documents is caught by nothing. `COV-5` adds that check
+ to the coverage test, which is already reading every file in `docs/rfcs/`.
+
+- Observation: Whitaker's `module-max-lines` lint caps every non-root module at
+ 400 lines, counted over the module's whole span. Evidence: the second gate
+ run failed at `tests/rfc_stdlib_coverage/mod.rs:30:5` with "Module `survey`
+ spans 787 lines, exceeding the allowed 400" — a failure the first run never
+ reached, because `lint-clippy` aborted `make lint` before Whitaker ran.
+ Impact: the parser was split into five modules by responsibility — `section7`
+ (the disposition tables), `section8` (the section-reference guard), `totals`
+ (table 11 and the purity aggregate), `inventory` (the transcribed section 7
+ literals), and `assertions` (the cross-checks) — leaving `survey` to hold the
+ derived result and its orchestration. The largest is now 277 lines. The lint
+ inspects `mod` items, so a test crate root is not measured: 660-line
+ `tests/manifest_jinja_tests.rs` is green. Worth knowing before the child RFCs
+ arrive, since their registry and clause parsers grow the same way.
+
+- Observation: the section 14.13 coverage map inserted at `EP-M1` was captioned
+ `_Table 12:_`, colliding with the earlier table 12 (the `to_datetime`
+ conversion specifiers at `:1536`). The document numbers tables sequentially,
+ and `_Table 15:_` already existed, so the map is now `_Table 16:_`. Evidence:
+ `docs/rfcs/0006-...md:2068`. Impact: nothing parses captions, so only review
+ would have caught it. Worth re-checking in each child RFC, where the registry
+ table is the one this plan adds.
+
+- Observation: a `sed -i "START,ENDd"` whose `END` resolves *above* `START`
+ deletes exactly one line and reports no error. Evidence: the `COV-4` control
+ first looked its caption up with `grep -n '^_Table 12:' | head -1`, which
+ matched the earlier table 12, so the range ended above its start; POSIX then
+ matches only the first address, and the control removed the header row alone
+ and failed the suite with "the coverage map has 7 rows; expected 8" — a real
+ failure, but not the one intended. Impact: general, and the reason the
+ controls print the seeded diff before the verdict. A control that fails is
+ not yet a control that tested what it meant to, which is exactly the vacuity
+ `COV-1` to `COV-6` exist to prevent. Two fixes were needed: anchor the
+ caption search after the header (`awk -v start=...`), and guard both lookups
+ so an empty address aborts the script rather than reaching `sed`.
+
+- Observation: the **first written version of this parser was fence-blind**, and
+ the failure is the silent kind. A fenced `# not a heading` line inside RFC
+ 0006 section 8.9 read as a depth-1 heading, ended the subsection at the
+ fence, and left `strftime` and `to_datetime` — specified further down section
+ 8.10 — looking like helpers with no contract. Six of the seven checks failed,
+ and the message named the two helpers, not the code block. Evidence: the
+ fence control transcript in `Verification plan`; the red run is reproducible
+ by re-running `/tmp/rfc-fence-control.sh` against a checkout of `885edb93`.
+ Impact: this was the one control that had to be *written* to be believed,
+ because it was raised as a CodeRabbit review finding marked "trivial" and its
+ severity is anything but. Every child RFC this plan goes on to write carries
+ example fences in section 5, so the fault was one document edit away from
+ being live. Three scans shared the root cause — `Section::tables`,
+ `Section::subsection`, and `section8::subsection_lines` — and all three now
+ consult one `Fences` cursor, extracted with the rest of the Markdown lexical
+ layer into `markdown.rs`. The post-fix run of the same control leaves the
+ suite at 7 passed.
+
+- Observation: the plan's own COV-3 scoping note was right and the code did not
+ implement it. The purity aggregate must range over the 57 **proposed**
+ helpers; the registries carry all 60 accepted ones. Filtering on purity alone
+ yields 54/5/1 against section 6.1's 52/4/1 and would have failed a correct
+ document at `EP-M11`, the first milestone where all eight registries exist.
+ Evidence: `with_purity` had exactly three call sites, none filtering on
+ registration kind, and the optioned rows include the filesystem-observing
+ `glob`. Impact: the fix is at the accessor rather than the call site —
+ `new_with_purity` carries the registration filter in its contract, so a
+ future caller cannot get the wrong answer by forgetting it. Also raised by
+ CodeRabbit and also latent: the check is guarded by `written == rows.len()`,
+ so it cannot fire before every child exists.
+
+- Observation: Whitaker's `conditional_max_n_branches` counts a match **guard**
+ as branches, and the limit is 2. Evidence: the first `Fences` implementation
+ guarded its closing arm with
+ `opened == character && run >= opened_run && info.trim().is_empty()` and
+ failed at `markdown.rs:91` with "Collapse the match guard to 2 branches or
+ fewer". Clippy passed the same expression, and so did `make test` and
+ `make typecheck`; the violation is dylint-only, which is why it surfaced at
+ `make lint` alone. Impact: the guard was not rewritten as a flatter `if`,
+ which would have lost the "closing fence carries no info string" rule — the
+ natural cheat is to drop a condition and the branch count falls with it.
+ Instead the three conditions became two predicates, `Delimiter::closes` and
+ `Delimiter::is_closing_run`. Eleven hand-run probes over `/tmp` copies
+ confirm the CommonMark semantics survive: info strings, longer and shorter
+ closing runs, tilde-versus-backtick nesting, inline code spans, indented
+ fences, and unterminated blocks. When a child RFC's parser grows a similar
+ guard, expect this lint, and split a condition into a named predicate rather
+ than deleting it.
+
+- Observation: `make fmt` cannot be pointed at one file, and `mdtablefix` has
+ no check-only mode. Evidence: `check-markdown-format.sh` stages copies and
+ compares, precisely because the tool always writes; `MD_FILES_FIND` in the
+ Makefile covers the whole corpus. Impact: after an editorial prose edit the
+ scoped invocation is
+ `mdtablefix --in-place --wrap --renumber --breaks --ellipsis --fences …`,
+ with the flags copied from `mdformat-all` and the checker. Running
+ `make fmt` instead would reformat unrelated documents and bury the milestone
+ diff. Both files edited at `EP-M1`'s second review were pure paragraph
+ rewrapping — zero table-pipe changes — which the checker's failure message
+ alone does not distinguish.
+
+- Observation: the worked section `EP-M2` commits is a **fenced** copy, so it
+ is subject to a width limit the real child RFC is not, and the first draft
+ was written to the real child's width and failed the gate. Evidence: a
+ 208-column fenced row failed `MD013` with
+ `t.md:6:121 error MD013/line-length Line length [Expected: 120; Actual: 208]`,
+ and `.markdownlint-cli2.jsonc` sets `MD013.code_block_line_length` to 120
+ while setting `tables: false`. Impact: a fenced copy must be laid out to 120
+ columns even though `tables: false` means no real table in the corpus is ever
+ measured, so the worked section's tables are narrower than RFC 0013's will be.
+ `mdtablefix` compounds it by leaving fenced content completely alone —
+ probed on a file with a misaligned fenced table and an over-wide fenced
+ paragraph, both of which it reported as "left unchanged" while reflowing the
+ same paragraph outside the fence — so `make check-fmt` will not repair a
+ fenced copy either. The consequence for the remaining children is nil,
+ because a child RFC is real Markdown: its tables are exempt from `MD013` and
+ are reflowed by `mdtablefix` like every other table in the corpus. The
+ consequence for this plan is that the specimen is sized for the fence and
+ says so, rather than being the widest form a child may use.
+
+- Observation: the first draft of the worked section named a `non_string_key`
+ error condition for `from_yaml`, and RFC 0006 section 8.1 rejects the
+ opposite set. Evidence: section 8.1 says "Mapping keys may be strings,
+ integers, or booleans. Sequence and mapping keys are rejected." An integer or
+ boolean key is therefore *accepted* and a sequence or mapping key is
+ rejected, so a condition called `non_string_key` names the wrong predicate in
+ both directions: it would fire on input the document admits and stay silent
+ on input it excludes. Impact: renamed to `unsupported_key`, which states what
+ is rejected without contradicting the accepted kinds. The defect was found by
+ writing the condition list against section 8.1 rather than from memory of it,
+ which is the whole reason the worked section was drafted before `EP-M3`
+ rather than during it.
+
+- Observation: the skeleton this plan tells `EP-M2` to "copy literally" **does
+ not satisfy the parser `EP-M1` already shipped**, in two independent places.
+ Evidence: the skeleton's registry heading read
+ `### 5.1. Purity and manifest-query registry` against `REGISTRY_HEADING`'s
+ `### 5.1. Registry` (`registries.rs:20`), and its manifest-query cell
+ vocabulary was `Available`/`Stub` against `check_manifest_query`'s `yes`/`no`
+ (`registries.rs:229-237`). Impact: both are hard failures on the first row
+ read, and both would have fired only at `EP-M3` — after the go/no-go had
+ already been spent on a document written to the wrong contract. Neither was
+ caught by `EP-M1` because no child RFC exists yet, so every check that reads
+ a registry is vacuously green; the template was prose the parser had never
+ been pointed at. Two readings were available for each — change the template
+ or change the parser — and the template gave way in both, because in both the
+ parser already agrees with the parent document. RFC 0006 table 2's own column
+ is headed "Available in manifest queries" with cells `Yes` and `No`, so
+ `Available`/`Stub` was a second vocabulary for a fact the parent already
+ spells; and `### 5.1. Registry` also matches RFC 0006's own section 14.13
+ wording, "carries the group's registry". The fix is to the skeleton plus a
+ note at each site naming the constant that reads it, so the next editor knows
+ the heading is parsed rather than prose. The general lesson: a template
+ committed in prose is untested code, and the milestone that first consumes it
+ is the wrong place to discover that.
+
+- Observation: the cross-check added to catch a *child's* wrong namespace read
+ a value the same task had hardcoded wrong, so the guard would have failed a
+ correct document. Evidence: `section7::apply_optioned` inserted every
+ optioned helper as `Namespace::Filter`, while RFC 0006 section 3.2 lists
+ `glob` under Functions; `check_rows_agree_with_survey` compares the child's
+ namespace cell against that value, so `COV-1` would have failed RFC 0018 —
+ the group that owns `glob` — for being right. Impact: found only because the
+ previous pass's fix was re-derived from the document rather than trusted. A
+ check is a comparison, and a comparison is only as good as its weaker side:
+ hardening the side that reads the untrusted document is pointless while the
+ trusted side is a literal nobody re-reads. `Optioned` now carries its
+ namespace, sourced from section 3.2, and the same reasoning is why the
+ optioned rows' purity is parsed from the child's own cell rather than
+ defaulted.
+
+- Observation: `CONF-1` existed as an obligation in this plan for six days while
+ the code implemented only its table half. Evidence: the plan's `CONF-1` says
+ each subsection must be non-empty, name an owned helper or carry the `D6`
+ escape phrase, and contain no Ansible-deference phrase; `clauses.rs` parsed
+ the `Clause | Discharge` table and compared its id set, and read no
+ subsection body at all. Every subsection check the plan names was unchecked,
+ and every control in `CONF-1`'s non-vacuity list would have passed — because
+ none of them was implemented either. Impact: this is the plan's own principal
+ risk, left unguarded by the very obligation written to guard it, and it was
+ found by review rather than by a failure. The gap was invisible precisely
+ because the check that *did* exist passed: a green suite one obligation short
+ of what the plan claims is indistinguishable from a complete one. The general
+ lesson is that an obligation's prose and its implementation need to be read
+ against each other once, deliberately, and that the plan's non-vacuity
+ controls are the cheapest way to do it — a control that cannot fail is a
+ spec, not a test.
+
+- Observation: the spec of the `CONF-1` check, written *before* it parsed the
+ ADR's worked specimen, turned out to reject that specimen. Evidence:
+ subsection 5.9 of the `EP-M2` worked section named thirteen diagnostic codes
+ and no helper, so `names_an_owned_helper` was false and the `D6` escape
+ phrase was absent — a true positive, not a false one. Impact: the specimen
+ was demonstrating clause 6.9 by enumerating the group's contribution to it,
+ which is exactly the "specific enough to be worth writing" standard the rule
+ is meant to enforce, and the rule's own vocabulary was still satisfied by
+ naming the codes' subject. The fix names all five helpers alongside the codes
+ rather than weakening the check, because the check is right about what a
+ reader needs. This is the useful shape of the interaction: a rule written
+ against a document it has not read yet is the only version that can surprise
+ its author, and it is worth reading the two against each other before the
+ rule is relied on rather than after.
+
+- Observation: two of the three shapes `CONF-1` checks for are already caught
+ when the class is written *inconsistently*, and only the third needs the new
+ check. Evidence: `check_manifest_query` (`registries.rs:240`) already rejects
+ a row whose purity class and manifest-query cell disagree — table 2's own
+ rule — so a probe setting `from_json` to `Subprocess-observing` with the cell
+ left at `Yes` fired that check, not the new one. The new aggregate checks
+ caught it only once the probe was made *consistent* (class
+ `Subprocess-observing`, cell `No`), which is the case nothing else sees: the
+ row is internally coherent and still contradicts section 6.1's "no proposed
+ helper is". Impact: the two checks are complementary rather than redundant,
+ and a probe that proves one is live must be constructed so the other cannot
+ answer first. Worth remembering when adding checks to a suite that already
+ guards adjacent invariants.
+
+- Observation: the two totals checks were gated on all eight capability groups
+ being written, so neither could fire until the split was complete. Evidence:
+ the generator's aggregate was wrapped in `if world.map.unwritten() == 0`,
+ which is true only when no child exists yet or all eight do; the `EP-M3`
+ control writes exactly one child, so the milestone it was written for could
+ not trigger it. Impact: the totals are now asserted partially as well as
+ exactly — an upper bound on each purity class and on the optioned count runs
+ at every prefix, and the exact equality runs only when complete. The bound is
+ sound because section 6.1's three counts are a budget, not a target: a
+ half-written split can spend too much of it but can never spend too little.
+ The forbidden classes (clock, network, subprocess) are asserted as hard zeros
+ at every prefix, because section 6.1 states no proposed helper is any of them
+ and a zero is not a budget. The general shape is worth reusing: a check
+ guarded by a completion condition is a check whose subject is the completion,
+ not the property.
+
+### `EP-M0` audit results (2026-09-11)
+
+The audit re-derived every count in this plan mechanically from
+`docs/rfcs/0006-...md` with a throwaway script (`/tmp/ep-m0-audit.py`, not
+tracked; the derivation it performs is reimplemented in the coverage test at
+`EP-M1`). Results, with the plan's claims alongside:
+
+| Quantity | Plan claims | Derived | Verdict |
+| ----------------------------- | ----------- | ------- | ------------ |
+| Accept rows in section 7 | 55 | 55 | agree |
+| Defer rows in section 7 | 6 | 6 | agree |
+| Reject rows in section 7 | 50 | 50 | agree |
+| New Netsuke helpers | 57 | 57 | agree |
+| Optioned existing helpers | 3 | 3 | agree |
+| Accepted set | 60 | 60 | agree |
+| Filters / tests / optioned | 41/16/3 | 41/16/3 | agree |
+| Purity (pure/fs/env) | 52/4/1 | 52/4/1 | agree |
+| Naive section 8 headings | 58 | 58 | agree |
+| Forbidden-set members | 34 | 71 | **disagree** |
+| Table 11 class counts sum | 50 | 50 | agree |
+| Class split, last stated rule | 22/10/18 | 8/24/18 | **disagree** |
+
+- Observation: the plan's `COV-2` assertion that the forbidden set has "exactly
+ 34 members" reconciles only as a **row** count — 50 reject rows minus 22
+ "already provides" rows, plus 6 deferred rows — and not as a name count under
+ any derivation. Reject-row name cells expand to 67 names; removing the names
+ on rows the notes class as "already provides" leaves 38 or 40, and adding the
+ 6 deferred names gives 44 or 46. Evidence: the audit script's alias-group
+ expansion, and `docs/rfcs/0006-...md:468-635`. Impact: the plan's number is a
+ category error. `D10` replaces the size assertion with the complement rule's
+ 71-name deny set. The membership assertions are nearly all satisfied:
+ `is_file`, `quote`, `fileglob`, and `lookup` are all forbidden under the
+ complement, so the four names the first draft's handwritten list omitted are
+ still caught. One assertion fails: the plan required the deny set to
+ **exclude** `expanduser`, on the ground that its row is classed "already
+ provides"; the complement forbids it. That exclusion was the one place the
+ plan's stated method and its expected members disagreed, and the members had
+ the better of it — the internal inconsistency, not the number 34, is what
+ made the class-based method untenable.
+
+- Observation: the plan's "exactly 34 members" and its `is_file` membership
+ assertion are mutually inconsistent, which is what actually indicts the
+ class-based method. Under a faithful class reading (`file` / `is_file`
+ classed "already provides") `is_file` is **not** forbidden; under the row
+ count that yields 34 it is not either. The plan could not have both its
+ number and its membership list, and `EP-M0` was right to stop on it. Evidence:
+ `docs/rfcs/0006-...md:468-635` and `:600-639`. Impact: the complement rule
+ satisfies both the intent (catch `is_file`) and the four-name witness, and
+ gives up only the `expanduser` exclusion, which nothing depended on.
+
+- Observation: the resolution-note column does **not** discriminate the three
+ reject classes. `EP-M0`'s first reading said it did, and `EP-M1` falsified
+ that; the retraction is recorded here rather than only in `D10`, because the
+ earlier reading is the one a reviewer would otherwise still be working from.
+ The rule that was believed to reproduce the split was: **alias** if the
+ resolution cell contains the token "alias" or cites §10.2; otherwise
+ **exists** if it begins "Exists" or names a provider in backticks; otherwise
+ **principle**. Re-derived at `EP-M1` it gives **8 alias / 24 exists / 18
+ principle**. The *principle* count is right and the (correction at `EP-M1`)
+ premise holds — every principle row is backtick-free, so `ternary` ("Jinja
+ conditional expressions") and `mandatory` ("Strict undefined already errors")
+ land there correctly. The error is the 32-row remainder: table 11 counts 10
+ alias and 22 exists, and the two-row difference is exactly `win_splitdrive`
+ and `fileglob`, the rename rows whose resolution cells name a Netsuke call
+ form rather than the word "alias". Recovering 22/10/18 would mean
+ special-casing those two out of *exists* while leaving `now` — a reject row
+ that also names an existing helper in backticked call form — inside it, with
+ nothing in the document to distinguish them. Evidence:
+ `docs/rfcs/0006-...md:468-635`, `:1617-1690`, and `:600-639`. Impact: the
+ split is **not derivable** and is not asserted; `D5` rule 3's prescribed
+ remedy — adding a discriminating column to section 7 — is needed only if the
+ split is ever wanted as a contract, and no normative edit to RFC 0006 is made
+ now. `COV-2` asserts the parseable totals and that table 11's three class
+ counts sum to the reject-row count, so a broken table still fails loudly.
+
+- Observation: the control schedule below is off by one milestone, and this was
+ found by writing `EP-M2`'s probe rather than by reading. It says the `COV-2`,
+ `COV-3`, and `CONF-1` controls "need something to corrupt and run at `EP-M4`
+ and `EP-M5`, the first milestones with a registry row and a clause body". The
+ first milestone with a registry row and a clause body is `EP-M3`: it delivers
+ RFC 0013, which owns five registry rows and discharges all eleven clauses.
+ `EP-M4` is merely the *second*. Evidence: `EP-M2`'s liveness probe placed the
+ ADR's worked specimen at `docs/rfcs/0013-…md` and flipped RFC 0006's map row
+ to `written`, and `every_child_discharges_every_clause`, `COV-1`, `COV-3`,
+ and the link check all ran non-vacuously against it. Impact: the four
+ controls are runnable at `EP-M3` and are discharged there, not deferred; the
+ schedule was corrected in place rather than left to contradict the milestone
+ it sits above.
+
+- Observation: `COV-3`'s purity aggregate is satisfiable only over rows whose
+ `Registration` is `New`. The registries carry all 60 accepted helpers, and
+ the three optioned rows include `glob`, which is filesystem-observing;
+ all-row aggregation therefore yields 54 pure / 5 filesystem / 1 environment,
+ not the 52/4/1 that section 6.1 states. Evidence:
+ `docs/rfcs/0006-...md:222-260` counts only the 57 proposed helpers. Impact:
+ minor; the fix is to scope the aggregate to `New` rows and say so in `COV-3`.
+
+- Observation: RFC numbers 0013 to 0020 are free. `origin/main` carries RFCs
+ 0001 to 0012 only, and every active remote branch checked (`3-14-8-…`,
+ `4-3-1-…`, `4-4-1-…`, `7-1-1-…`, `adopt-rstest-bdd-v0-5-0`,
+ `docs/rfc-0001-implementation-roadmap`, `document-the-timeout-tiers`,
+ `property-testing-rfc`) stops at 0012. Impact: no reservation is required
+ before `EP-M3`, but the allocation is only a convention and `EP-M1` must
+ still backfill the number-allocation table in RFC 0006 and re-enumerate
+ before each child commit.
+
+- Observation: the partition's per-group registry contents all reconcile
+ against the accepted set. Group sizes are 5, 6, 8+7, 4+4, 6+1(+2), 1+4(+1),
+ 9, and 2, summing to 41 filters, 16 tests, and 3 optioned helpers, exactly
+ the accepted set of 60. Impact: `EP-M0`'s partition is confirmed; no helper
+ needs to move between children, and the ambiguity tolerance is not triggered.
+
+- Observation: three defects in the coverage parsers were found only by
+ running the checks against the real documents, and all three would have
+ misreported rather than crashed. First, `Section::tables` pushed the table's
+ own accumulator and then appended rows to a different allocation, so every
+ table parsed as empty. Second, `Section::subsection` excluded its own heading
+ line, so a table placed directly under a subsection heading was attributed to
+ no heading at all and named `""`; `map::parse`, `registries::parse`, and
+ `clauses::discharged` all filter on that heading text, so all three would
+ have failed on the first child RFC as well. Third, `roadmap`'s `FIRST_STEP`
+ and `LAST_STEP` carried a `###` prefix while being compared against a heading
+ already stripped of it, so no capability step was ever in range and `COV-6`
+ reported the roadmap as empty. Evidence: the diagnostics that located each —
+ `coverage map subsection contains no table; 39 lines, 1 tables [("", 8)]` for
+ the second, `docs/roadmap.md has no capability steps starting ### 6.2.` for
+ the third. Impact: the narrow parser contract in the module docs was right,
+ but three parsers were never exercised against their subject before these
+ runs, which is the argument for landing the checks while their subjects are
+ still unwritten.
+
+- Observation: `COV-4`'s required line cannot be printed the obvious way. The
+ workspace denies `clippy::print_stdout` and `clippy::print_stderr`
+ (`Cargo.toml:209-210`), and `cargo nextest run` captures a passing test's
+ output by default, so a `println!` would be both a lint error and invisible.
+ Evidence: `make test` runs `cargo nextest run`, whose
+ `profile.default.overrides` already raise `success-output` for two tests.
+ Impact: the check carries one
+ `#[expect(clippy::print_stdout, reason = ...)]`, and `.config/nextest.toml`
+ gains an override for `coverage_map_status_is_reported` with
+ `success-output = "immediate"`. Verified: a green run prints
+ `coverage map: 0 of 8 capability groups written; 8 remaining`.
+
+- Observation: the 400-line cap is measured on *code*, not on file length, and
+ the difference defeated the first attempt to verify the fix. Liveness-probing
+ `module_max_lines` by appending 120 `// pad` lines to a 303-line module left
+ Whitaker green, which reads as "the lint is not looking at this file". It is
+ looking; it does not count bare comment lines. Re-probing with 110
+ doc-comment lines plus a function took the same file to 415 and produced
+ `error: Module progress spans 415 lines, exceeding the allowed 400.` Impact:
+ the fix at `7605c884` is confirmed by a live oracle rather than by an absence
+ of output, and every module is now at most 303 lines, so none is near the
+ boundary under either counting rule. This is the second time in this task
+ that a probe's own defect would have been read as a pass had the probe not
+ been run against a case it was expected to fail.
+
+- Observation: the cap's scope is *nested modules*, and the corpus confirms it
+ rather than merely permitting it. Every non-crate-root Rust file in the tree
+ is at most 400 lines, with four (`src/ninja_gen/mod.rs`,
+ `src/manifest/mod.rs`, `src/manifest/render_tests.rs`,
+ `src/manifest/expand_test_cases/condition_cases.rs`) sitting exactly at 400 —
+ a wall, not a coincidence. Eight `tests/*.rs` files exceed it (411 to 660),
+ all of them crate roots, and `dylint.toml` treats `tests/*.rs` targets as a
+ separate compilation unit. Impact: the two files this milestone added were
+ the only nested modules over the cap, so the violation was this task's to fix
+ and not an inherited condition; had the cap applied to crate roots, the same
+ split would have been required of eight pre-existing files and would have
+ been out of scope.
+
+- Observation: "just a module move" is exactly the change for which a
+ formatting gate gets skipped, and exactly the change most likely to need one.
+ The split at `7605c884` was verified with three gates — clippy, nextest, and
+ Whitaker — and committed without `make check-fmt`, which then failed on two
+ mechanical diffs in the two files the split had just created by hand. The
+ first stage of the documented gateway set was the one omitted. Impact: the
+ cost was a fifth gate run and a second scrutineer invocation, both of which
+ the first-stage gate would have prevented for the price of one command. Every
+ commit from here runs all five `make` targets as a single command, however
+ mechanical the change looks.
+
+- Observation: `docs/contents.md` is the one index in the corpus that nothing
+ checks, and this task was carrying the only defect it had. Of 39 ADR files,
+ 38 were indexed and the missing one was exactly this branch's. Impact: the
+ omission survived nine gate runs, and it was found only by reading the file,
+ not by a gate. No test in the tree mentions `contents.md` at all. The rule
+ this plan already fixed for RFC numbers therefore has a second, unguarded
+ instance: `D2` allocates RFC numbers lazily *because* they collide, and the
+ index that records them has no equivalent guard. Not fixed here — a check on
+ `docs/contents.md` is outside this plan's declared interface set, and adding
+ one would be a new obligation rather than a discharge of an existing one. It
+ is recorded so the omission is not rediscovered later.
+
+- Observation: "run `make fmt`" is not a remedy for MD013, and the two look
+ alike from the gate output. The three over-long lines this task added were
+ prose, and `make fmt` rewrapped neither: `mdtablefix` has no wrap rule that
+ reaches an unwrapped prose line, and `markdownlint --fix` does not implement
+ MD013 at all. Impact: the plan's summary of `make fmt` as the fix for
+ formatting findings was too broad. MD013 is a hand fix, and treating the
+ formatter as its remedy would have left the gate red for a second run. The
+ converse also held and was checked rather than assumed: after the hand fix,
+ `make fmt` ran again and touched none of the three, and
+ `mdtablefix --renumber` did not eat the `- [x] (2026-09-25)` progress
+ entries, which is the failure mode that tool is known for.
+
+- Observation: a non-normative copy of a normative section is still a copy, and
+ nothing checks the two against each other. `ADR-040` carries RFC 0013's
+ section 5 as a worked specimen and says in terms that RFC 0013's copy is the
+ normative one. The specimen's `from_yaml_all` bullet nevertheless still read
+ "rejects every `from_yaml` condition" — the precise wording RFC 0013 §5.6 was
+ corrected away from when RFC 0006 §8.1 was read properly, since a
+ zero-document stream is an empty sequence rather than an error. So the
+ divergence was introduced by the correction itself: it was applied to the
+ artefact and not to the specimen that previews it, and the two then disagreed
+ for eleven days across three gate runs and three review passes. Evidence:
+ CodeRabbit's fourth pass flagged the ADR line; extracting the bullet from
+ both files showed the ADR's copy to be the pre-correction text. Impact: this
+ is not a stale comment. The specimen is the only worked example a `EP-M4` to
+ `EP-M10` author has, and it is a full section rather than a summary, so the
+ ADR is what gets copied. A second copy of a normative text needs either a
+ check or an explicit pointer to the artefact as the single source — the ADR
+ already has the pointer and it was not enough, so the rule this records is
+ narrower: **when a correction is applied to an artefact, grep for its other
+ copies in the same commit.** Neither `make check-fmt` nor `make lint`
+ compares two documents' prose, and no review pass before this one had both
+ copies in view.
+
+- Observation: the ownership check could not see the one collision its own
+ subject matter is named for. The coverage map must contain exactly one row
+ per capability group, `D2` allocates RFC numbers lazily *because* they
+ collide, and the plan re-enumerates remote heads before each child commit for
+ exactly that reason — yet `parse` had no duplicate-number guard, and nothing
+ else noticed one either. Evidence: `Map::ownership` inserts every claimed
+ helper into one map keyed by name and errors only when the *same helper* is
+ claimed by two *different* numbers, so two rows sharing a number are not a
+ conflict to it; downstream, `registries::parse_all` filters on
+ `children.contains`, and both the status and registry checks match rows with
+ `find`/`any`. A mutation of row `0015` to `0014` therefore passed all fifteen
+ checks, one child silently representing two capability groups. Impact: three
+ separate guards each had a partial view and each assumed another had the
+ whole one. The duplicate-number guard now sits in `parse`, the only place
+ both rows are visible before they are separated into `Map::rows`. The general
+ lesson is the converse of the ADR one: a check keyed on the *aggregate*
+ (helpers → owner) cannot enforce a property of the *members* (one number per
+ row), and the subject matter's own history of collisions is a hint about
+ which property deserves a direct guard rather than an emergent one.
+
+- Observation: three of this repository's Markdown tools disagree about where a
+ fence may begin, and the disagreement runs in the direction that hides a
+ defect. `CommonMark` allows up to three leading spaces before an opening
+ fence; `Delimiter::opening` accepted any amount; `mdtablefix`, the gating
+ tool, is lenient through indent 6 by way of `trim_start()`; and this
+ repository's `markdownlint` config sets no MD046 key, so MD046 runs in
+ "consistent" mode. Evidence: `Delimiter::opening` read `line.trim_start()`;
+ an indentation sweep showed `mdtablefix` leaving a table untouched at every
+ indent 0–6, and `markdownlint` reporting zero errors for a 4-space-indented
+ fence. Impact: a document whose only code block is indented passes every
+ gate, so the leniency was invisible by construction rather than by accident —
+ and the failure it permits is the silent one, an indented run of backticks
+ swallowing every heading and table beneath it. The fix aligns the predicate
+ with `CommonMark` and the module's own doc-comment claim, which is also what
+ the shipped sibling reader in `tests/documentation_examples/mod.rs` does
+ (column zero only, stricter than `CommonMark`). The cost is named in the doc
+ comment rather than hidden: a fence nested in a list item is at the item's
+ content column and this line-level predicate will not recognize it. That
+ error is loud — the body is handed to the heading and table scans and
+ misparses — and the corpus has **zero** indented fences across all 23 scanned
+ files (the 22 under `docs/rfcs/` plus `docs/roadmap.md`, 206 fence lines,
+ none indented), so no document needs the nested form today. Three unit tests
+ in `mod fence_tests` pin the boundary, because a corpus with no instance of a
+ shape cannot test the predicate for it.
+
+## Decision log
+
+- Decision `D1`: eight child RFCs, one per roadmap phase-6 capability step 6.2
+ to 6.9. Step 6.1 gets no child RFC. Rationale: the roadmap's steps are a
+ complete, disjoint cover of RFC 0006 sections 8.1 to 8.10, and they cut on
+ the **purity seam**, which section 14's delivery slices do not. Slice 5
+ bundles pure lexical path helpers with filesystem-observing predicates; the
+ roadmap instead puts the lexical helpers and the pure `abs` test in step 6.6
+ and the observing helpers in step 6.7. Since section 5.1 of each child is a
+ purity registry, a purity-aligned partition makes seven of eight children
+ uniformly pure and one uniformly non-pure, and makes every clause about
+ capability, determinism, and manifest-query availability uniform within a
+ child. Slice alignment would produce a child straddling two roadmap steps and
+ mixing purity classes. Step 6.1 gets no child because its subject is the
+ shared machinery of sections 6 and 14.1, which is not a capability in section
+ 7's sense; such a child would own no helper and restate what RFC 0006 already
+ specifies. Date/Author: 2026-09-08, planning agent.
+
+- Decision `D2`: allocate RFC numbers 0013 to 0020 **lazily**, one per child at
+ the commit that creates it, after landing reservation rows in RFC 0006's
+ number-allocation table as part of `EP-M1`. Rationale: numbers become
+ irreversible on merge, and this repository has a measured collision base rate
+ — `adr-003`, `adr-004`, and `adr-014` are all duplicated on disk. A
+ reservation that lives only on this branch reserves nothing. The partition is
+ stated by slug, so nothing depends on contiguity. `EP-M1` also backfills 0007
+ to 0012 into that table and deletes its false claim that no RFC has been
+ merged to `main`. Date/Author: 2026-09-08, planning agent.
+
+- Decision `D3`: **RFC 0006 section 8 stays intact and normative.** Child RFCs
+ are additive. This reverses the first draft, which migrated the per-helper
+ contracts out. Rationale: five lines of evidence. The repository's
+ established convention is additive — RFCs 0009, 0010, and 0011 each declare an
+ `Amends` preamble bullet naming RFC 0001 and instruct that the amendment be
+ folded back into the parent before promotion, keeping the parent normative
+ throughout. Migration would leave 39 roadmap task bullets citing a stub, with
+ no link gate to notice. Two capability groups split across two milestones,
+ and the shared `dialect` block that section 8.6 defines is consumed by
+ helpers in both, so the intermediate states would be genuinely incoherent
+ rather than merely incomplete. Migration accounts for only 935 of roughly
+ 3500 lines of first-draft deliverable, so it buys little and risks most. And
+ the measured contributor read path gets worse, not better. What is lost: a
+ child RFC is not self-contained; a reader consults RFC 0006 section 8.N for
+ the contract. That is accepted, and the Purpose section no longer claims
+ otherwise. Date/Author: 2026-09-08, planning agent.
+
+- Decision `D4`: coverage is anchored on **tables, not headings**. Each child
+ RFC's section 5.1 is a five-column registry — helper, namespace,
+ registration, purity class, manifest query — with one row per helper the RFC
+ owns. That registry is the ownership signal. Rationale: heading anchoring is
+ unworkable, provably. `basename` and `dirname` have no heading, so two of the
+ three optioned helpers would be permanently unowned. Three headings name two
+ helpers each. Three headings under section 8 are prose. And RFC 0006's own
+ sections 10.3, 11.1, 11.4, 11.5, 11.7, 11.8, and 15.3 are headings containing
+ backticked helper names in non-normative contexts, so a heading scan produces
+ false duplicate-ownership and false forbidden-name hits against the parent
+ document. A table cell cannot be mentioned in passing. The registry is also
+ the artefact section 6.1 requires and which exists nowhere today, so it is
+ worth writing regardless of the test. Date/Author: 2026-09-08, planning agent.
+
+- Decision `D5`: the accepted and forbidden sets are **derived from RFC 0006
+ section 7's disposition tables and section 7.8's totals**, not hardcoded.
+ Rationale: section 7 states its own purpose as the index — every surveyed
+ name with an explicit disposition and, for accepted names, the owning section
+ 8 subsection. Hardcoding would create a third copy of the inventory in a
+ different language, drifting independently of the two it mirrors, which is
+ the exact failure this task exists to prevent. The evidence that hand
+ maintenance fails is that the first draft's list omitted sixteen names. Three
+ derivation rules, which the test must state:
+ 1. **Alias groups.** Section 7.8 notes that rows cover alias groups as single
+ entries. Split the name cell on its separator.
+ 2. **Renames.** Three accepted capabilities are registered under a Netsuke
+ name rather than the surveyed one: Ansible's `hash` becomes `text_hash`,
+ its `quote` becomes `shell_quote`, and its `win_splitdrive` becomes
+ `splitdrive` with a windows dialect. The rule is read off the disposition
+ cell, which for `hash` reads "Accept as `text_hash`" — **not** `Reject`, as
+ the first draft of this rule said — and `Reject` with a rename note for
+ the other two. A disposition beginning "Accept as" therefore names the
+ registered helper directly, and the renaming is not a special case the
+ parser needs to know about. Section 7.8 states the three renames in prose
+ and the test asserts there are exactly three.
+ 3. **Reject is overloaded.** The disposition cells, counted by `EP-M0`, are
+ `Accept` (54), "Accept as `text_hash`" (1), `Defer` (6), `Reject` (49),
+ and `Reject as a new name` (1). All 50 reject-dispositioned rows count as
+ rejected whatever their class, so the deny set is the complement of the
+ accepted set: 67 reject names plus 6 deferred names, less the 2 that are
+ themselves accepted Netsuke names. The class distinction is **not
+ derivable** from the document and is **not asserted**; `D10` records how
+ that was established and why it does not matter. No check reads a row's
+ class.
+ Date/Author: 2026-09-08, planning agent.
+
+- Decision `D6`: section 5 has five mandatory substantive clauses; the other
+ six may be a single declarative line. Rationale: seven of the eight children
+ own only pure helpers, so clauses 6.2 to 6.5 discharge to the same four
+ sentences in each — roughly 336 lines of literal restatement across the set,
+ with 6.5, 6.10, and 6.11 adding more. Mandating eleven prose subsections per
+ child is a vacuity generator. Substantive and per-helper: **5.1** the
+ registry, **5.6** type and error contract, **5.7** canonical value equality,
+ **5.8** resource bounds, and **5.9** diagnostics, which must give a code of
+ the form `netsuke::jinja::::` per error condition. Permitted
+ to be one line: 5.2, 5.3, 5.4, 5.5, 5.10, and 5.11. **Anti-vacuity rule**,
+ applied by a reviewer in one pass: every subsection states either a
+ group-specific consequence — a bound, a registry row, a diagnostic code, a
+ purity assignment, a named error condition — or the exact words "No
+ additional obligation beyond RFC 0006 section 6.N." A bare restatement of the
+ clause is a review reject. Date/Author: 2026-09-08, planning agent.
+
+- Decision `D7`: roadmap steps 6.10 and 6.11 get no child RFC.
+ Rationale: 6.10 covers section 9's deferred candidates, which the success
+ criterion requires be covered by none. 6.11 is outside RFC 0006's
+ Ansible-derived set and already has
+ `docs/git-change-detection-helpers-design.md` and
+ [ADR-015](../adr-015-use-bounded-git-cli-for-change-detection.md).
+ Date/Author: 2026-09-08, planning agent.
+
+- Decision `D8`: roadmap task 6.1.1 has been rewritten from "child issues" to
+ "child RFCs and accompanying roadmap tasks", and the same wording is to be
+ updated at the seven places inside RFC 0006 that say "child issue". **Done
+ for the roadmap**, on reviewer direction, in the commit that carries this
+ revision; RFC 0006's seven phrases remain `EP-M1` work. Rationale: leaving
+ either document saying "issues" while the tree contains eight child RFCs
+ would leave both describing work nobody did. RFC 0006 section 6 opens by
+ saying a child issue that does not satisfy every clause is not complete,
+ which after this change is the definition of a child RFC's section 5. The
+ reviewer also directed that work be tracked in committed documentation where
+ possible, which settles the open question about the lost burn-down: the
+ roadmap checkboxes in steps 6.2 to 6.9 are the tracker, and no child RFC gets
+ a GitHub issue. The amended task records that explicitly so a later reader
+ does not reintroduce one. The amended success criterion says "exactly one
+ child RFC and at least one accompanying roadmap task" rather than "exactly
+ one" of each, because `product` is already named by tasks 6.4.2 and 6.4.5.
+ Making the roadmap half "exactly one" would have required splitting existing
+ tasks for no benefit. Date/Author: 2026-09-08, planning agent; roadmap
+ wording only under `D8`, `EP-M1` and `EP-M11` doing the rest.
+
+- Decision `D11`: this branch's ADR is renumbered from 021 to **040**, the
+ lowest free number above the corpus ceiling, and `docs/contents.md` gains the
+ index entry it never had. Rationale: `main` published an ADR 021 of its own
+ on 2026-09-09 (`3348cc0a`), one day after `EP-M0` recorded "the highest
+ existing ADR is 020 … re-check before committing", and the branch's ADR is
+ dated 2026-09-11 and committed at `9730a880` with main's already in its
+ history. Two ADRs cannot both be 021 in one corpus. The plan's tolerance rule
+ is unambiguous about the disposition — "If a number is taken, stop and
+ escalate" — and the escalation is raised rather than assumed away. Of the two
+ admissible remedies, renumbering the unmerged branch is the one the corpus
+ already prescribes: main's number is cited by three inbound links including a
+ link-reference definition, so moving it would churn a published document to
+ fix an unpublished one. The ceiling is re-swept rather than remembered,
+ because the number that was free when this plan was written is the number
+ this defect is made of: 039 on `jm5/kani-change-scoped-gate` is the current
+ highest anywhere, so 040 is free, and `origin/main`'s own highest is 038.
+
+ Scope is eight edits, all mechanical, and the guard is what makes the last
+ two safe. The 17 `ADR-021` mentions in this plan were checked for `fetch`,
+ `trust`, `quarantin`, `network`, and `policy` before any replacement: none
+ matched, so every mention means this branch's ADR and a scoped global replace
+ cannot corrupt a reference to main's. Main's file and all three of its
+ citations are left untouched, and the result is verified by set-comparison
+ rather than by count, because 39 files against 39 entries is also what a swap
+ looks like. Date/Author: 2026-09-25, implementation agent. wording confirmed
+ by the reviewer.
+
+- Decision `D9`: record the convention in
+ `docs/adr-040-focused-child-rfcs-for-survey-rfcs.md`, scoped narrowly.
+ Rationale: allocating eight numbers under a particular partition is hard to
+ reverse. But the ADR must not claim to generalize. It applies to **survey
+ RFCs** — documents that enumerate a large candidate set with a per-candidate
+ disposition — and explicitly does **not** replace the `Amends` convention
+ that RFCs 0009 to 0011 use for normative amendments to RFC 0001. The first
+ draft claimed the convention would extend to RFC 0001; that would have
+ contradicted those three amendments' own fold-back instruction. The ADR must
+ also carry the **amendment procedure**: the ordered touchpoints to edit when
+ a helper is added, removed, or renamed after the split — the section 7 row,
+ the section 8 subsection, the section 14 coverage map, the owning child's
+ registry, and the roadmap task — so the coverage test is a guard rail rather
+ than a ratchet. The highest existing ADR is 020; three earlier numbers
+ collided, so re-check before committing. Date/Author: 2026-09-08, planning
+ agent.
+
+- Decision `D10`: the forbidden set is the **complement of the accepted set**
+ — every section 7 reject name and every section 9 deferred name, less the
+ registered Netsuke names of accepted helpers — which is **71 names**, not the
+ 34 this plan first asserted. The reject-class split of table 11 is neither
+ derived nor asserted; the deny set does not need it. Rationale: `EP-M0` found
+ that the plan's 34 reconciles only as a **row** count — 28 class-based
+ forbidden rows plus 6 deferred rows — and not as a name count under any
+ derivation; that its assertion that the deny set contains `is_file` cannot
+ hold under that same class reading, because the `file` / `is_file` row is
+ classed "already provides", so the plan's number and its membership list were
+ mutually inconsistent; and that the resolution-note prose does not
+ discriminate the three reject classes under any rule the document states. Two
+ independent attempts at a note-parsing rule produced 24/10/16 and 25/6/18
+ against table 11's 22/10/18; a third rule reproduced 22/10/18 exactly, but it
+ leans on the literal token "alias" and on a citation to §10.2, and neither is
+ a contract the parent document offers. The complement rule needs no prose
+ parsing at all, is strictly safer than any class-based reading because it
+ forbids a superset of what both readings forbid, and requires no normative
+ edit to RFC
+ 0006. Two consequences are accepted: `basename` and `dirname` are the only
+ excluded names, so `expanduser` — which the first draft would have permitted
+ — is forbidden; and the deny set does not shrink when section 7 gains a
+ reject row of the "already provides" class. Neither affects a child RFC,
+ because a registry row must name an accepted helper, and the accepted set is
+ checked independently by `COV-1`. The class split is **not** retained as a
+ witness. `EP-M1` re-derived it a third time against the rule recorded here
+ and got 8 alias / 24 exists / 18 principle, not 22/10/18, so the rule the
+ earlier draft believed reproduced the split does not do so. Recovering
+ 22/10/18 needs a rule that special-cases the two rename rows
+ (`win_splitdrive` and `fileglob`) out of *exists* while leaving `now` — also
+ a reject row naming an existing helper in backticked call form — inside it,
+ and that is curve-fitting, not derivation. RFC 0006 states no rule assigning
+ a reject row to a class, and section 10 groups only some of them. Accordingly
+ `COV-2` asserts the totals that *are* directly parseable — 55 accept rows, 6
+ defer rows, 50 reject rows, 111 surveyed entries — and asserts only that
+ table 11's three class counts **sum** to the derived reject-row count. That
+ is a real consistency check on the table without asserting an undecipherable
+ split. Date/Author: 2026-09-11, implementation agent, chosen by the reviewer
+ from three options; the class-split retraction was added by the same author
+ the same day, after `EP-M1` falsified the rule.
+
+## Alternatives considered
+
+The first draft had no such section. Two alternatives are live at the approval
+gate.
+
+**Stop after `EP-M1`.** The coverage test, `ADR-040`, the RFC 0006 defect
+corrections, and the roadmap rewrite together solve the mechanical half of the
+problem — the bijection nobody can check by reading — for roughly a tenth of
+the cost and none of the irreversibility. No RFC number is spent. The
+per-helper obligations would remain underived, which is the other half of the
+value, but they could later be added to RFC 0006 section 8 in place as a
+registry table per group, at roughly 140 lines rather than roughly 1800. If the
+reviewer judges eight child RFCs disproportionate, this is the fallback, and
+`EP-M0` and `EP-M3` are positioned so it can still be taken.
+
+**Six children rather than eight.** RFC 0020 owns two helpers and RFC 0018 owns
+one filter plus four tests and an option. Merging date and time into encoding
+and formatting, and the version predicate into collection algebra, would give
+six children with no group under about 120 lines of section 8 content. The cost
+is two broken one-to-one step mappings, which weakens the success criterion's
+phrasing. Cheaper, but not recommended.
+
+Rejected: aligning to section 14's ten delivery slices, for the purity-seam
+reason in `D1`. Rejected: unnumbered per-group design documents on the pattern
+of roadmap step 6.11 — genuinely cheaper and fully reversible, but the
+commissioned task asks for RFCs, and a normative contract discharge belongs in
+the RFC review process.
+
+## Outcomes & retrospective
+
+To be completed at `EP-M11`. Before marking `COMPLETE`, reconcile every
+discovery against RFC 0006, `docs/roadmap.md`, and the style guide, and confirm
+no disposition changed.
+
+## Context and orientation
+
+Netsuke reads a YAML manifest called a `Netsukefile`, expands Jinja templates
+in it with MiniJinja, and generates a Ninja build file. The "template standard
+library" is the set of filters, tests, and functions Netsuke registers with
+MiniJinja.
+
+Terms, defined once:
+
+- **Filter**: invoked as `value | name(args)`. **Test**: invoked as
+ `value is name(args)`. **Function**: invoked as `name(args)`. Jinja keeps the
+ three in separate namespaces.
+- **Manifest query**: the read-only environment serving `netsuke help targets`.
+ A helper that reads the clock, environment, filesystem, network, or a
+ subprocess is registered there only as a failing stub.
+- **Purity class**: RFC 0006 section 6.1's label — pure, clock-observing,
+ environment-observing, filesystem-observing, network-observing, or
+ subprocess-observing.
+- **Canonical key**: the RFC 8785 canonical JSON form of a value, giving a
+ deterministic equality relation so no helper's output order comes from a hash
+ table. RFC 0006 section 6.7.
+- **Survey RFC**: an RFC that enumerates a large candidate set with a recorded
+ disposition per candidate. RFC 0006 is the only one.
+- **Registry**: the five-column table at section 5.1 of each child RFC.
+
+Files that matter:
+
+- `docs/rfcs/0006-ansible-inspired-template-standard-library.md` — 2132 lines,
+ status `Proposed`. Section 6 is the eleven-clause contract; section 7 is the
+ disposition matrix and the derivation source; section 8 is the per-helper
+ contracts, which stay put; section 14 becomes the coverage map.
+- `docs/roadmap.md` — phase 6 spans lines 715 to 1267. Step 6.1 is shared
+ machinery; 6.2 to 6.9 are the capability groups; 6.10 is deferred; 6.11 is
+ Git change detection.
+- `docs/contents.md` — the `## Requests for comments` section at lines 35 to 82
+ is the only RFC index. Note how RFCs 0009 to 0011 are described there, as a
+ normative amendment adding something to RFC 0001; child RFCs need an
+ analogous phrasing.
+- `docs/documentation-style-guide.md:222-360` — RFC naming, required and
+ conditional sections, formatting guidance, and a literal template.
+- `tests/documentation_installation_tests.rs` — the precedent for splitting a
+ test binary across a sibling module.
+ `tests/integration_test_wiring_tests.rs` — the contract governing how the new
+ binary must be wired; note at lines 101 to 146 that a sibling module tree
+ must be explicitly declared or it is flagged orphaned, and that the
+ path-attribute form requires the module name to match the directory name.
+
+For wider architecture see `docs/netsuke-design.md`. For the testing idioms the
+child RFCs impose on their implementers, see
+`docs/rust-testing-with-rstest-fixtures.md`, `docs/rstest-bdd-users-guide.md`,
+`docs/reliable-testing-in-rust-via-dependency-injection.md`,
+`docs/rust-doctest-dry-guide.md`, and
+`docs/snapshot-testing-in-netsuke-using-insta.md`. When writing RFC 0018, load
+the `hexagonal-architecture` skill: `expandvars` and the filesystem predicates
+are this plan's only ports, and the boundary to preserve is between pure domain
+policy — path lexing, purity classification — and the injected adapters, the
+`cap_std` workspace handle and the environment reader, that RFC 0006 section
+6.4 and [ADR-008](../adr-008-environment-seam-taxonomy.md) mandate. Route Rust
+questions for the coverage test through `rust-router`, which points at
+`rust-unit-testing`. Use `scrutineer` for gate runs and `scribe` for the
+`docs/contents.md` entries and the roadmap citation retargeting; do not
+delegate any section 5, which is new normative content.
+
+### The partition
+
+Every later milestone is bookkeeping against this table. The ownership
+qualifiers are exact: the first draft wrote unqualified group numbers and
+thereby double-assigned seven helpers.
+
+| Child RFC | Title | Owns | Step |
+| --------- | ----------------------------------------------- | ------------------------------------ | ---- |
+| 0013 | Structured data interchange helpers | §8.1 | 6.2 |
+| 0014 | Mapping and sequence transform helpers | §8.2 | 6.3 |
+| 0015 | Ordered collection algebra and truth predicates | §8.3, §8.8 | 6.4 |
+| 0016 | Pattern and version predicates | §8.4, §8.5 | 6.5 |
+| 0017 | Lexical path composition | §8.6 except `expandvars`; §8.7 `abs` | 6.6 |
+| 0018 | Host-state predicates and environment expansion | §8.7 except `abs`; §8.6 `expandvars` | 6.7 |
+| 0019 | Encoding, identity, and formatting helpers | §8.9 | 6.8 |
+| 0020 | Date and time conversion helpers | §8.10 | 6.9 |
+
+*Table 1: Child RFC allocation against RFC 0006 groups and roadmap steps.*
+
+The two divisions are the roadmap's own and are affirmatively supported by RFC
+0006. Section 8.7 calls `abs` pure and lexical and says that because it is
+pure, it is available during manifest queries unlike the other four tests in
+its group; roadmap task 6.6.4 places it in step 6.6 for that reason. Section
+14.7 separates `expandvars` from the lexical helpers because it is the only
+environment-observing helper in the RFC and therefore the only one needing an
+injected reader, a manifest-query stub, and its own capability review. Together
+the cuts make RFC 0017 uniformly pure and RFC 0018 the only child with non-pure
+helpers.
+
+The registry contents, which are the coverage test's expected ownership:
+
+- RFC 0013, five filters: `from_json`, `from_yaml`, `from_yaml_all`, `to_yaml`,
+ `to_nice_json`.
+- RFC 0014, six filters: `combine`, `dict2items`, `items2dict`, `extract`,
+ `subelements`, `rekey_on_member`.
+- RFC 0015, eight filters — `union`, `intersect`, `difference`,
+ `symmetric_difference`, `product`, `combinations`, `permutations`,
+ `zip_longest` — and seven tests: `any`, `all`, `subset`, `superset`,
+ `contains`, `truthy`, `falsy`.
+- RFC 0016, four filters — `regex_replace`, `regex_search`, `regex_findall`,
+ `regex_escape` — and four tests: `match`, `search`, `regex`, `version`.
+- RFC 0017, six filters — `path_join`, `normpath`, `splitext`, `commonpath`,
+ `relpath`, `splitdrive` — one test, `abs`, and the optioned existing filters
+ `basename` and `dirname`.
+- RFC 0018, one filter, `expandvars`; four tests — `exists`, `link_exists`,
+ `same_file`, `mount` — and the optioned existing function `glob`.
+- RFC 0019, nine filters: `b64encode`, `b64decode`, `urldecode`, `to_uuid`,
+ `shell_quote`, `comment`, `human_readable`, `human_to_bytes`, `text_hash`.
+- RFC 0020, two filters: `to_datetime`, `strftime`.
+
+That is 41 new filters, 16 new tests, and 3 optioned existing helpers, matching
+table 11. Aggregating the purity columns must yield 52 pure, 4
+filesystem-observing, and 1 environment-observing, matching section 6.1 — an
+independent cross-check the coverage test performs as `COV-3`.
+
+RFC 0006's section 16 open questions distribute as follows. Question 1 on
+`to_nice_yaml` and question 4 on alias bounding go to RFC 0013; question 3 on a
+version prefix goes to RFC 0016; question 2 on the `abs` test name goes to RFC
+0017; question 6 on a truncating hash sibling goes to RFC 0019. Questions 5 and
+7 belong to no child: question 5 concerns the shared bounds of step 6.1, and
+question 7, on an injected clock, is owned by roadmap task 7.1.1, "Add the
+clock provider seam to the stdlib time module", on a separate reserved branch.
+
+**Six of the seven remain open, and question 7 does not.** This split does not
+close any of them — that is the point of carrying each into its owning child,
+unresolved — but question 7 was resolved independently of this task, by the
+merge of task 7.1.1 in `96aefc9c`, which is this branch's base. RFC 0006
+section 16 item 7 already reads "Resolved", recording that `now()` reads
+through a `ClockProvider` held by `StdlibConfig` and classified in the
+[ADR-008](../adr-008-environment-seam-taxonomy.md) addendum for 2026-09-11, and
+that RFC 0020 neither needs the seam nor depends on 7.1.1. Reading the section
+16 list as seven open questions would therefore contradict the document itself,
+so it is recorded here as six. `EP-M1` still annotates question 7 so a phase-6
+implementer does not adopt it by accident; the annotation is now redundant with
+the section's own text rather than the only pointer to it.
+
+### The child RFC template
+
+The template is committed to
+[ADR-040](../adr-040-focused-child-rfcs-for-survey-rfcs.md), under "The child
+RFC template", together with the registry row shape and each column's accepted
+vocabulary. It is not restated here: two copies of a parsed artefact drift, and
+the copy a child is written from must be the copy the test reads. `EP-M2` moved
+it there from this plan for exactly that reason. The template retains
+`## Current state` and `## Alternatives considered` as conditional-but-expected
+for RFCs 0017 and 0018, which must contrast `relpath` against the existing
+`relative_to` and `splitext` against `with_suffix`, and must carry RFC 0006
+section 15.5's analysis of the rejected Windows-specific filter family.
+
+## Conformance basis
+
+`docs/terms-of-reference.md` exists, and two of its parts bear on this split
+directly. Goal **G4** ("Deterministic plans") and goal **G6** ("Visible
+impurity") are the parent documents' statement of what RFC 0006 section 6 turns
+into a per-helper contract, and hard constraint 8.1's "project configuration
+cannot grant itself authority the operator has not granted" (ADR-021, ADR-026)
+is the rule clause 6.4 discharges as a capability boundary. No goal or
+constraint contradicts the split; the split's purpose is to make those
+obligations dischargeable per helper rather than in one survey document.
+
+Upstream artefacts:
+
+- `docs/terms-of-reference.md` at `origin/main` commit `96b89ca9`, goals G4 and
+ G6, and hard constraint 8.1. The pin differs from the one below because the
+ document postdates `924cb215`: it arrived on `main` in `96b89ca9` (PR #786),
+ and is unchanged on this branch.
+
+- `docs/rfcs/0006-ansible-inspired-template-standard-library.md` at
+ `origin/main` commit `924cb215`, status `Proposed`. Sections 6, 7, 7.8, 8, 9,
+ 10, 13.4, 14, and 16 are load-bearing.
+- `docs/roadmap.md` at the same commit, phase 6, task 6.1.1 and steps 6.2
+ to 6.9.
+- `docs/documentation-style-guide.md:222-360`.
+- [ADR-008](../adr-008-environment-seam-taxonomy.md) governs the `expandvars`
+ seam RFC 0018 discharges;
+ [ADR-010](../adr-010-scope-glob-capability-to-literal-prefix.md) governs the
+ `glob` option; and
+ [ADR-001](../adr-001-replace-serde-yml-with-serde-saphyr.md) governs the YAML
+ stack RFC 0013 discharges.
+- `docs/adr-040-focused-child-rfcs-for-survey-rfcs.md`, created at `EP-M1`.
+
+Trace links, one per obligation. `EP-M1` is the milestone that lands the check;
+the tests are named without their `netsuke-build::rfc_stdlib_coverage_tests::`
+prefix, which is the same for all of them and which would push every line past
+120 columns:
+
+```text
+RFC0006-S7 -> ROADMAP-6.1.1 -> EP-M1 -> COV-1 -> every_accepted_helper_has_exactly_one_owner
+RFC0006-S9 -> ROADMAP-6.1.1 -> EP-M1 -> COV-2 -> no_forbidden_helper_is_registered
+RFC0006-S6.1 -> ROADMAP-6.1.1 -> EP-M1 -> COV-3 -> totals_and_purity_aggregate_agree
+RFC0006-S14 -> ROADMAP-6.1.1 -> EP-M1 -> COV-4 -> coverage_map_status_is_reported
+ROADMAP-6.2..6.9 -> ROADMAP-6.1.1 -> EP-M1 -> COV-6 -> every_capability_has_a_roadmap_task
+RFC0006-S6 -> ROADMAP-6.1.1 -> EP-M3..EP-M10 -> CONF-1 -> every_child_discharges_every_clause
+ADR-040 -> EP-M1 -> docs/adr-040-focused-child-rfcs-for-survey-rfcs.md
+```
+
+## Verification plan
+
+### Review remediation local verification, 2026-10-01
+
+The successful integrated candidate had starting HEAD `1c35b1d0` and a complete
+tracked-and-untracked source fingerprint of
+`7058af8061276b28875ba959e1e335c8626ebc2f37c73fec2d05378637e9510d`. One
+scrutineer ran the gates sequentially, with logs under `/tmp`. Conflicting
+`FORCE_COLOR` was removed from the runner environment, retaining `NO_COLOR`, to
+avoid the tools' warning about both settings being present.
+
+| Command | Result |
+| ------------------------------- | ---------------------------------------------------------------------- |
+| `make fmt` | Passed. |
+| `make test-rfc-stdlib-coverage` | 272 passed. |
+| `make test-workflow-contracts` | 986 passed, 3 skipped. |
+| `make check-fmt` | Passed. |
+| `make lint` | Rustdoc, Clippy, Whitaker, Python and Actions checks passed. |
+| `make typecheck` | Rust and Python checks passed. |
+| `make markdownlint` | Spelling passed; 170 files, zero Markdown errors. |
+| `make doc-coverage` | 98.83%, above the 80% threshold. |
+| `make nixie` | Passed. |
+| `make test` | 3748 Nextest tests passed, 6 skipped; 128 doctests passed, 32 ignored. |
+
+The full Rust run printed
+`coverage map: 1 of 8 capability groups written; 7 remaining`. The coverage
+binary's 272 passes include its seven repository checks and direct fixtures and
+properties. This evidence does not complete the seven unwritten child RFCs or
+establish semantic adequacy of their prose. The final edit recording these
+results receives its own formatting, Markdown, focused coverage, and
+ExecPlan-status checks before commit. CI and reviewer confirmation are separate
+publication evidence.
+
+### Post-commit complexity refactor verification, 2026-10-01
+
+The separate refactor candidate is based on `e88d0d1a`; its complete source
+fingerprint after formatting is
+`6ebddad1369efe49bfc1eed8fde67414b5d303bf3cd7f30ebcbafff4cf8d46a6`. The two
+targeted `cs review` checks analysed the working files, not the immutable base
+commit. Both `links_tests.rs` and `nextest_success_output.py` scored 10.0 with
+an empty findings list. No rule was suppressed.
+
+One scrutineer repeated the preceding table's complete gate set sequentially.
+The focused suite passed 272 tests, workflow contracts passed 986 with 3
+skipped, and the full Rust suite passed 3748 tests with 6 skipped and 128
+doctests with 32 ignored. Doc-comment coverage remained 98.83%. The final
+ExecPlan evidence edit receives the same focused documentation checks before
+commit. Hosted CodeScene and CI results remain separate evidence for the
+published head; CodeRabbit confirmation at the base does not substitute for
+confirmation of these new fixes.
+
+### Review validation branch-to-test inventory, 2026-10-01
+
+The direct tests live beside the private implementation through test-only
+sibling modules. Each invalid fixture changes one condition and checks the
+existing diagnostic, including file and absolute line when the reader supplies
+that context. The seven repository checks retain their public names.
+
+| Implementation | Direct tests and guarded branches |
+| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `document.rs` | `document_tests.rs`: section boundaries, fence opacity, absolute lines, table/header/separator recognition, missing cells. |
+| `markdown.rs` | `markdown_property_tests.rs`: independent opening and closing predicates; delimiter, run, information, indentation, tabs, and later structure. Existing heading examples remain. |
+| `totals.rs` | `totals_tests.rs`: complete counts, missing/duplicate/ambiguous/non-numeric tally, missing count cell, purity evidence and wrapping. |
+| `section7.rs`, `inventory.rs` | `section7_tests.rs`: disposition and citation errors, narrow cells, namespace conflicts, rename/option witnesses, every table descriptor, ignored tables. |
+| `section8.rs` | `section8_tests.rs`: missing contract or citation, whole-token matching, exact section number, fenced boundaries. |
+| `survey.rs` | `survey_tests.rs`: isolated derivation, missing file/section, propagated failures, deny complement and namespace totals. |
+| `map.rs` | `map_tests.rs`: numbers, status and link disagreement, duplicate reservations/claims, ownership grammar and unknown references; independent set-operation properties. |
+| `registries.rs` | `registries_tests.rs`: missing data, filename and cell vocabularies, duplicate names, purity/query disagreement, reserved corpus selection and counts. |
+| `clauses.rs`, `deference.rs` | `clauses_tests.rs` and adjacent deference cases: malformed/duplicate IDs, empty bodies/cells, owned-helper evidence, explicit escape, Ansible deference and word boundaries. |
+| `roadmap.rs` | `roadmap_tests.rs`: missing/invalid steps, scheduled helpers, task headings, fence opacity and complete step order. |
+| `links.rs` | `links_tests.rs`: relative targets and source lines, missing files, above-root traversal; independent bounded traversal model, fragments and dot/empty segments. |
+| `partition.rs` | `partition_tests.rs`: missing/extra ownership, registry number/name/namespace/registration mismatches, denied registration. |
+| `assertions.rs` | `assertions_tests.rs`: every aggregate mismatch, deny count and required/forbidden witnesses. |
+| `progress.rs` | `progress_tests.rs`: partial upper bounds, complete helper/purity/option equalities, forbidden purity, status, schedule and discharge consistency. |
+| `mod.rs` | `repo_tests.rs`: isolated capability reads/listing, missing/non-UTF-8 documents, fixture open context, token and alias readers. |
+| Nextest contracts | `nextest_success_output_test.py`: exact override and declared name, valid fixture, ten single-condition mutations and mutation completeness. |
+
+Not every defensive return is input-reachable. Immutable offsets found in the
+same section cannot subsequently exceed its bounds; a tally lookup cannot
+vanish after its single-hit check; and ownership `only`/`except` token access
+cannot fail after the two-token arity check. These branches remain intact. The
+tests exercise the reachable rejection that precedes each defensive return
+rather than changing visibility or inventing an impossible fixture. Parsed
+Markdown rows always have a first cell, so the generic missing-cell accessor is
+tested directly while later missing columns are tested through readers. No
+claim of semantic proof or exhaustive random-input coverage is made: bounded
+properties supplement the explicit diagnostic cases.
+
+This change adds no runtime behaviour, so there is no invariant over program
+state. It introduces a combinatorial invariant over documents — a bijection
+between the accepted helper set and its owning child RFCs — which is statable
+and mechanically checkable, and a semantic obligation about the adequacy of the
+prose discharges, which is not.
+
+The plan is explicit about that asymmetry, because the first draft was not. The
+mechanical obligations cover the coverage bijection. The substantive product of
+this task is section 5's prose, and no test can establish that it says
+anything. Three controls bound the gap: `D6` cuts the clause count and states
+an anti-vacuity rule a reviewer applies in one pass; `CONF-1` mechanically
+rejects the three commonest vacuity shapes; and `EP-M3` is a hard go/no-go on
+the first completed child. The pull request must not claim more.
+
+Method selection: table-driven Rust tests enumerate the fixed document
+inventory and explicit rejection cases. Bounded property tests independently
+model the general fence, path, and ownership parsers, whose input domains are
+not that finite inventory. Both forms run in the same focused test binary.
+
+**Parser contract, common to all obligations.** Helper inventories come from
+Markdown table rows rather than helper headings. Structural headings delimit
+the named sections: RFC 0006's section 7 subtables and its section 14 coverage
+map, and each child's section 5.1 registry. It splits on the cell separator,
+trims, and strips one pair of backticks. It matches names by whole-token
+equality, never substring — the vocabulary contains `abs` against `is_abs`,
+`quote` against `shell_quote`, `hash` against `text_hash`, and `subset` against
+`issubset`, and substring matching would be wrong on all four. It records file
+and line for every parsed row so failures name a location.
+
+### Obligation `COV-1`: exactly one designated owner
+
+- Obligation: every helper in the accepted set derived from RFC 0006 section 7
+ appears in the section 5.1 registry of exactly one child RFC, and that RFC is
+ the one the section 14 coverage map designates.
+- Method: set comparison across three independently sourced signals — the
+ accepted set from section 7, the expected owner from the coverage map, and
+ the actual owner from the child registries.
+- Rationale: three signals from two documents, none hardcoded. A count-only
+ check would pass when a helper is assigned to the wrong child, leaving the
+ plan's most contestable decisions unguarded.
+- Domain: 57 new helpers plus 3 optioned existing helpers, against RFC 0006 and
+ RFCs 0013 to 0020.
+- Artefact: `tests/rfc_stdlib_coverage_tests.rs` and
+ `tests/rfc_stdlib_coverage/`.
+- Evidence: `cargo nextest run --test rfc_stdlib_coverage_tests`. Green at
+ `EP-M1` with every coverage-map row marked not yet written, and green at
+ every plateau thereafter as rows flip to a written child.
+- Non-vacuity: four seeded faults, each applied to a scratch copy and reverted,
+ with transcripts recorded. Delete the `combine` row from RFC 0014's registry
+ and expect a zero-owner failure naming `combine` and the file. Add `combine`
+ to RFC 0015's registry as well and expect a two-owner failure naming both.
+ **Move `combine` wholesale from RFC 0014 to RFC 0015** and expect a
+ wrong-owner failure — the control the first draft lacked, without which the
+ partition is untested. Corrupt one section 7 accept row and expect the
+ derived set to shrink and the failure to name the row. The test also asserts
+ the derived accepted set has exactly 60 members, so a parser returning
+ nothing cannot pass vacuously. Discharged at `EP-M1` for the row-corruption
+ control, and only for it: the three registry controls run at `EP-M4` and
+ `EP-M5`. Transcripts are in `Control transcripts`.
+
+### Obligation `COV-2`: no forbidden candidate is registered
+
+- Obligation: every name in any child RFC's registry is a member of the derived
+ accepted set. Equivalently, no name that RFC 0006 section 7 rejects under any
+ disposition, or that section 9 defers, appears in any child RFC's registry.
+- Method: derive the accepted set from section 7's accept rows, then the deny
+ set as the **complement** — every surveyed reject or defer name that is not
+ the registered Netsuke name of an accepted helper. See `D10`.
+- Rationale: a hand-maintained list is not a sound contract when the source of
+ truth is a tracked file in the same repository. Deriving means the set
+ tightens automatically when section 7 gains a row. Stating the check as a
+ complement rather than as a union of reject classes removes the need to parse
+ the resolution-note prose, which `D10` records as too fragile to be a
+ contract; it also makes the deny set a superset of any class-based reading,
+ so no name either reading forbids can slip through.
+- Domain: the derived forbidden set — 71 names, from 67 reject names and 6
+ deferred names, less `basename` and `dirname`, which are `Reject` rows whose
+ surveyed name is the registered Netsuke name of an accepted helper.
+- Artefact: as above.
+- Evidence: same command. Green from `EP-M1`, which is exactly why the controls
+ below are compulsory rather than optional.
+- Non-vacuity: this obligation is green before any child exists, so a pass
+ proves nothing on its own. Add a registry row for `shuffle` to a scratch copy
+ of RFC 0015 and expect a failure naming `shuffle` and the file. Add `is_dir`
+ and expect a failure naming the rejected alias. Assert the derived deny set
+ has exactly 71 members and contains `is_file`, `is_dir`, `is_link`, `quote`,
+ `fileglob`, `lookup`, `win_dirname`, and `expanduser`. The first four are the
+ names the first draft's handwritten list omitted; `is_dir`, `is_link`, and
+ `win_dirname` are aliases the plan's own risk names; and `expanduser` is the
+ name the first draft expected the deny set to **exclude**, so asserting it is
+ present is the direct test of `D10`'s complement rule against the class-based
+ reading it replaced. Assert it does **not** contain `basename`, `dirname`,
+ `abs`, `glob`, `shell_quote`, or `splitdrive`, which are accepted helpers;
+ without this assertion the complement's exclusion step is untested, and a bug
+ that denied the very helpers the registries must carry would pass.
+- Note: the reject-class split of table 11 — 22 already-provides, 10 redundant
+ alias, 18 on principle — is **not** what this obligation checks, because the
+ complement does not need it. Nor does `COV-2` assert the split itself: RFC
+ 0006 states no rule assigning a reject row to one of the three classes, and
+ `EP-M1` falsified the rule the earlier draft believed recovered 22/10/18 (it
+ yields 8/24/18). `COV-2` therefore asserts only that table 11's three class
+ counts **sum** to the derived reject-row count, which catches an edit that
+ breaks the table's arithmetic without claiming a derivation it does not have.
+ The totals that are directly parseable — 55 accept rows, 6 defer rows, 50
+ reject rows — are asserted exactly.
+
+### Obligation `COV-3`: totals and purity aggregate agree
+
+- Obligation: the derived accepted set contains 41 filters, 16 tests, and 3
+ optioned helpers, matching the totals parsed from table 11; and the
+ registries' purity columns, taken over rows whose registration is `New`,
+ aggregate to 52 pure, 4 filesystem-observing, and 1 environment-observing,
+ matching section 6.1.
+- Method: parsed count against parsed count.
+- Rationale: genuinely independent, unlike the first draft's version, which
+ compared a hardcoded inventory against a hardcoded literal transcribed from
+ the same table in the same sitting. Here the counts come from the child
+ registries and the expectations from RFC 0006, so neither can be adjusted to
+ match the other without editing a normative document. The purity aggregate is
+ the only check reconciling section 6.1 against the registries; without it a
+ wrong purity class rots silently.
+- Scoping note: the aggregate is taken over `New` rows only, because section
+ 6.1's 52/4/1 counts the 57 **proposed** helpers. The registries carry all 60
+ accepted helpers, and the three optioned rows include `glob`, which is
+ filesystem-observing; aggregating every row would yield 54 pure, 5
+ filesystem-observing, and 1 environment-observing and fail against a correct
+ document. `EP-M0` found this; see `Surprises & discoveries`.
+- Domain: three totals and three purity counts.
+- Artefact: as above.
+- Evidence: same command. The purity half is necessarily partial until every
+ child exists, and it is now partial in two forms rather than being deferred
+ whole. An upper bound on each purity class and on the optioned count runs at
+ every prefix of the split, and the exact equality runs only when the coverage
+ map has no unwritten row. The bound is sound because section 6.1's three
+ counts are a budget: a half-written split can spend too much of it but never
+ too little. The classes section 6.1 forbids outright — clock-observing,
+ network-observing, and subprocess-observing — are asserted as hard zeros at
+ every prefix, because "no proposed helper is" is not a budget.
+- Non-vacuity: change one registry row's purity class from pure to
+ filesystem-observing and expect a failure reporting 5 filesystem-observing
+ where section 6.1 states 4 in total; observed at `EP-M2`'s second review,
+ firing with one of eight groups written. Change a helper's namespace and
+ expect the filter and test totals to fail. Introduce a purity class not among
+ table 2's six values and expect a vocabulary failure. Set one row to a
+ forbidden class *consistently* — the class and its manifest-query cell
+ changed together — because changing the class alone is caught first by
+ `check_manifest_query`, which is a different and narrower rule; the new
+ aggregate check is only reachable on a row that is internally coherent.
+
+### Obligation `COV-4`: the coverage map reports progress honestly
+
+- Obligation: the section 14 coverage map has one row per capability group with
+ an explicit owner and status, and the test reports in its passing output how
+ many groups remain unwritten.
+- Method: parse and report.
+- Rationale: a split that stalls half-finished satisfies every other obligation
+ — the abandoned state and the finished state are indistinguishable to a
+ bijection check. This makes a stall announce itself on every test run rather
+ than waiting for someone to notice.
+- Artefact: as above.
+- Evidence: passing output includes a line naming the unwritten count. `EP-M11`
+ asserts it is zero.
+- Non-vacuity: at `EP-M1` the reported count must be 8, not 0. A count of 0
+ before any child exists means the map is not being read. Observed at `EP-M1`:
+ `coverage map: 0 of 8 capability groups written; 8 remaining`, printed on a
+ passing run because `.config/nextest.toml` raises this test's
+ `success-output` to `immediate`. Discharged further at `EP-M1` by removing
+ the map table itself, which fails six of the seven checks with "the coverage
+ map subsection contains no table"; both transcripts are in
+ `Control transcripts`.
+
+### Obligation `COV-5`: inter-document links resolve
+
+- Obligation: every relative Markdown link from a file in `docs/rfcs/` to
+ another repository path resolves to an existing file.
+- Method: path existence check over parsed links.
+- Rationale: this plan adds eight documents, eight coverage-map links, and
+ eight `docs/contents.md` entries, and retargets 39 roadmap citations.
+ `markdownlint-cli2` is configured with no link validation and there is no
+ link checker in the repository, so a stale link is caught by nothing. The
+ parser already reads every file in `docs/rfcs/`, so this is nearly free.
+- Artefact: as above.
+- Evidence: same command.
+- Non-vacuity: point a scratch copy's link at a non-existent file and expect a
+ failure naming the source line and the missing target. Discharged at `EP-M1`;
+ the observed message is in `Control transcripts`.
+
+### Obligation `COV-6`: every capability has an accompanying roadmap task
+
+- Obligation: every helper in the derived accepted set is named, in a backticked
+ span, by at least one task bullet under the roadmap phase-6 step that owns
+ its child RFC.
+- Method: parse phase-6 step and task bullets from `docs/roadmap.md`, then check
+ membership per helper against its owning step.
+- Rationale: this is the second half of the amended success criterion, and
+ without it only the RFC half is checked. It also guards the thing the
+ reviewer asked for — that work be tracked in committed documentation —
+ because it fails if a capability is given an RFC but no roadmap task to
+ deliver it. Verified before writing this obligation: all 60 helpers are
+ already named under their owning step, so `COV-6` is green from `EP-M1` on
+ the current roadmap and stays green unless a task is deleted or a helper is
+ reassigned across steps.
+- Domain: 57 new helpers plus 3 optioned existing helpers, against roadmap steps
+ 6.2 to 6.9.
+- Artefact: `tests/rfc_stdlib_coverage_tests.rs` and
+ `tests/rfc_stdlib_coverage/`.
+- Evidence: same command as the other obligations.
+- Non-vacuity: green from the start, so a pass proves nothing on its own and a
+ control is compulsory. Delete the `zip_longest` task bullet from a scratch
+ copy of step 6.4 and expect a failure naming `zip_longest` and step 6.4. Move
+ the `expandvars` bullet from step 6.7 to step 6.6 and expect a wrong-step
+ failure, since RFC 0018 owns it. Assert the parsed task-bullet corpus is
+ non-empty and that phase 6 yields exactly eight owning steps, so a parser
+ that matches nothing cannot pass. Both roadmap controls are discharged at
+ `EP-M1`, with transcripts in `Control transcripts`; the deleted-task message
+ now names the owning step as well as the helper.
+- Note the asymmetry with `COV-1`, and that it is deliberate: `COV-1` requires
+ exactly one owning RFC, whereas `COV-6` requires at least one task, because
+ `product` is legitimately named by both 6.4.2 and 6.4.5.
+
+### Obligation `CONF-1`: every child discharges every clause
+
+- Obligation: each child RFC contains one section 5 subsection per clause of
+ RFC 0006 section 6; each subsection's body is non-empty and either names at
+ least one helper that RFC owns or contains the exact escape phrase from `D6`;
+ and no subsection contains an Ansible-deference phrase.
+- Method: structural and lexical checks, plus reviewer judgement for the rest.
+- Rationale: the mechanical half catches the three commonest vacuity shapes —
+ the empty stub, the generic discharge naming no helper, and the appeal to
+ Ansible. The last is literally this plan's own acceptance criterion and is a
+ substring search, so leaving it to review would be indefensible. The clause
+ list is derived by parsing section 6's subsection headings rather than
+ hardcoding eleven, so a future section 6.12 does not break eight documents.
+ Subsections are located by position under section 5 and by title match, not
+ by literal numerals, because two independent formatters in this repository
+ are empowered to renumber.
+- Domain: RFCs 0013 to 0020 against section 6's clause list.
+- Artefact: as above.
+- Evidence: same command, scoped to children that exist. Also checked: the two
+ id sets must be equal, so a section 5 subsection with no discharge-table row
+ — or a row with no subsection — fails even though each is well-formed alone.
+- **Amendment (2026-09-24): the mechanical half was implemented at `EP-M2`, six
+ days after it was specified, and until then only the table half existed.**
+ The obligation above was written as prose and the code compared id sets, so
+ the empty stub, the generic discharge, and the deference appeal were
+ unenforced; every non-vacuity control below would have passed, because none
+ was implemented either. See `Surprises & discoveries` for how it was found
+ and why a green suite one obligation short of its plan is indistinguishable
+ from a complete one.
+- Non-vacuity: all four controls were run at `EP-M2`'s second review, against a
+ probe child RFC mounted from the `ADR-040` worked specimen with the coverage
+ map's row `0013` flipped to written. Deleting subsection 5.8's body failed
+ with "is subsection 5.8. Resource bounds of section 5 with an empty body".
+ Replacing 5.7's body with "This group meets the clause by construction"
+ failed as a generic discharge, naming the subsection and quoting the `D6`
+ escape phrase it did not carry. Inserting "as Ansible does" failed with
+ "justifies a helper by appealing to Ansible". A duplicated 5.7 heading and a
+ duplicated `6.7` table row each failed as a second subsection or row for that
+ clause. The probe is the `EP-M3` rehearsal as well: with the specimen mounted
+ and the map row flipped, all ten tests passed, which is the state `EP-M3`
+ must reach.
+- **The specimen itself failed this check, and the check is right.** Subsection
+ 5.9 of the `ADR-040` worked section named thirteen diagnostic codes and no
+ helper. It was corrected by naming all five helpers alongside the codes, not
+ by weakening the check; a rule written before the document it grades is the
+ only version that can surprise its author, which is why the two were read
+ against each other here rather than at `EP-M3`.
+- **Residual gap.** Whether section 5.7 genuinely discharges canonical equality
+ for `subset`, as opposed to restating clause 6.7, is a judgement no test
+ makes. This is the substantive product of the task and it rests on review,
+ bounded by `D6`'s anti-vacuity rule and by the `EP-M3` go/no-go. Do not claim
+ otherwise in the pull request.
+- **Amendment (2026-09-19).** The discharge is checked as a two-column
+ `Clause | Discharge` table under a `### Clause discharge` subsection, which
+ closes section 5 after the eleven clause subsections. It was first specified
+ as `### 5.6. Clause discharge`, which CodeRabbit showed collides with the
+ template's own fourth bullet: 5.6 is "Type and error contract", the title RFC
+ 0006 clause 6.6 carries, so a table row reading `6.6` sat under a heading
+ reading "Type and error contract" and implied the two were the same thing.
+ Two readings were available — keep the template's 5.6 title and move the
+ table elsewhere, or keep the table and duplicate the id — and the first is
+ smaller and contradicts nothing already written, so the subsection lost its
+ number rather than gaining a rival. `CLAUSES_HEADING` and the new
+ `TABLE_HEADING` follow it, matching on the heading directly above the table
+ rather than on the subsection's first table, so a table placed under one of
+ the eleven clause subsections cannot be mistaken for the discharge. The
+ `EP-M2` worked example fixes the exact placement before any child RFC is
+ written from it, which is why this is settled now: eight documents would
+ otherwise inherit the ambiguity. Restating the eleven clause titles under
+ section 5 is the third option, and it is rejected — `D6` already argues that
+ literal restatement across eight documents is a vacuity generator, and the
+ ids, not the titles, are what the table matches on.
+
+### Control transcripts (2026-09-11)
+
+`/tmp/rfc-coverage-controls.sh` (scratch, not tracked) seeds one fault at a
+time into the working tree, runs `cargo test --test rfc_stdlib_coverage_tests`,
+prints the seeded diff and the failure, and restores both documents from a
+backup taken before the first control. It deliberately does not use
+`git checkout --`, which would discard the uncommitted milestone under test. It
+fingerprints the two documents before and after and aborts on a partial
+restore; the run finished with the same two hashes it started with, and the
+baseline after the last revert was 7 passed.
+
+Six controls are runnable at `EP-M1`, before any child RFC exists. Each was
+run, and each failed for its own reason. The quoted messages are wrapped for
+width; nextest wraps them the same way at a terminal.
+
+- `COV-4`, red state. The section 14.13 coverage map table is deleted, leaving
+ its prose and caption behind. This is the red half of the red-green evidence
+ the Validation and acceptance section asks for, and it is what shows the
+ reported count is read from the table rather than defaulted to zero:
+
+ ```text
+ Error: the coverage map subsection contains no table
+ ```
+
+ All six dependents fail on it. The line is also why `map::parse` insists on
+ eight rows rather than accepting an empty table: a map that parses to nothing
+ would otherwise satisfy "no unwritten group remains" vacuously.
+- `COV-1`, corrupted accept row. RFC 0006:469, the `from_json` row, loses its
+ `§8.1` citation, leaving the resolution cell reading "see section 8". All six
+ dependent checks fail, naming the file and the line:
+
+ ```text
+ Error: accept row at docs/rfcs/0006-ansible-inspired-template-standard-library.md:469
+ cites no section 8 subsection
+ ```
+
+ The three registry controls this obligation also specifies — delete,
+ duplicate, and move the `combine` row — need a registry to corrupt and are
+ deferred to `EP-M4` and `EP-M5`.
+- `COV-5`, dangling link. The `ADR-040` link in section 14.13 is repointed at a
+ file that does not exist:
+
+ ```text
+ Error: dangling inter-document links:
+ ["docs/rfcs/0006-ansible-inspired-template-standard-library.md:2055 links to
+ ../adr-021-no-such-file.md which resolves to
+ docs/adr-021-no-such-file.md, and no such file exists"]
+ ```
+
+- `COV-6`, deleted task. Roadmap line 935, the `zip_longest` bullet under step
+ 6.4.3, is deleted:
+
+ ```text
+ Error: accepted helpers ["zip_longest (RFC 0015, step 6.4)"] are named in no
+ roadmap capability step at all, so nothing schedules them
+ ```
+
+ The owning step in that message was added at `EP-M1` in response to this
+ control: naming only the helper left the reader to work out where it belonged.
+- `COV-6`, wrong step. The `expandvars` bullet moves from step 6.7 to 6.6:
+
+ ```text
+ Error: the coverage map gives RFC 0018 roadmap step 6.7, but that step names
+ none of ["expandvars"]
+ ```
+
+- Fence blindness, run separately by `/tmp/rfc-fence-control.sh`. A fenced
+ `text` block carrying a `# not a heading` line and a `{{ a | b }}` example is
+ inserted directly after the section 8.9 heading in RFC 0006. Before the fix
+ this failed **six of the seven checks**, and the diagnostic misattributed the
+ cause: the fenced `#` line read as a depth-1 heading, truncated section 8.9
+ at the fence, and left its helpers looking unspecified:
+
+ ```text
+ test every_child_discharges_every_clause ... FAILED
+ test every_capability_has_a_roadmap_task ... FAILED
+ test coverage_map_status_is_reported ... FAILED
+ test every_accepted_helper_has_exactly_one_owner ... FAILED
+ test totals_and_purity_aggregate_agree ... FAILED
+ test no_forbidden_helper_is_registered ... FAILED
+ Error: accepted helpers ["strftime", "to_datetime"] name no RFC 0006 section 8
+ subsection, so they have no contract to implement
+ ```
+
+ The two named helpers are exactly those section 8.10 specifies; nothing in
+ the message points at the code block that actually broke the parse. This is
+ the most dangerous control in the set, because the fault is one a child RFC
+ will legitimately contain: every child's section 5 carries example fences.
+ After the fix the same seeded fault leaves the suite at 7 passed. The control
+ restores the document from a backup and verifies its sha256 before and after.
+
+`COV-2`, `COV-3`, and `CONF-1` are green before any child exists, so their
+controls need something to corrupt — a registry row and a clause body. The
+first milestone that supplies both is `EP-M3`, not `EP-M4` as this section
+first said: `EP-M3` delivers RFC 0013, which owns five registry rows and
+discharges all eleven clauses.
+
+`EP-M2`'s second review ran them ahead of `EP-M3`, by mounting the `ADR-040`
+worked specimen as a probe child RFC and flipping coverage map row `0013` to
+written. All four `CONF-1` controls fired, as did the `COV-3` partial-purity
+bound and both new link-number checks; the transcripts are in
+`Control transcripts (2026-09-24)`. The probe is also the rehearsal for
+`EP-M3`: with the specimen mounted, all ten tests passed, which is the state
+`EP-M3` has to reach with a real child.
+
+### Control transcripts (2026-09-24)
+
+The `EP-M2` review's controls were run through a scratch harness
+(`/tmp/probe.sh`, not tracked) that restores both the survey and the probe
+child from pristine copies, applies one Python mutation, runs
+`cargo nextest run --test rfc_stdlib_coverage_tests`, and prints the distinct
+error lines. Each ran against the probe child, and each failed for its own
+reason. Quoted messages are wrapped for width.
+
+- `CONF-1`, empty body. The body of subsection 5.8 is deleted, leaving the
+ heading:
+
+ ```text
+ Error: docs/rfcs/0013-structured-data-interchange-helpers.md:134 is
+ subsection 5.8. Resource bounds of section 5 with an empty body
+ ```
+
+- `CONF-1`, generic discharge. Subsection 5.7's body is replaced with "This
+ group meets the clause by construction", which names no owned helper and does
+ not carry the `D6` escape phrase:
+
+ ```text
+ Error: docs/rfcs/0013-structured-data-interchange-helpers.md:112 is
+ subsection 5.7. Canonical value equality, whose body names none of the
+ helpers this RFC owns and does not read "No additional obligation beyond RFC
+ 0006 section 6.". A reviewer cannot tell it apart from a restatement of the
+ clause it discharges
+ ```
+
+- `CONF-1`, deference. "as Ansible does" is inserted into subsection 5.8:
+
+ ```text
+ Error: docs/rfcs/0013-structured-data-interchange-helpers.md:134 is
+ subsection 5.8. Resource bounds, which justifies a helper by appealing to
+ Ansible ("as Ansible"). RFC 0006 surveys Ansible; it does not adopt its
+ choices
+ ```
+
+- `CONF-1`, duplicate clause. A second `### 5.7.` subsection is inserted before
+ the discharge table, and separately a second `|`6.7`|` row is added to it.
+ The two are distinct checks and each fires alone:
+
+ ```text
+ Error: docs/rfcs/0013-structured-data-interchange-helpers.md:190 is a second
+ section 5 subsection for clause 6.7
+
+ Error: docs/rfcs/0013-structured-data-interchange-helpers.md:233 discharges
+ clause 6.7 a second time
+ ```
+
+- `COV-3`, partial purity bound. All five registry rows are set to
+ filesystem-observing with their manifest-query cells changed to `No`, at one
+ of eight groups written. Changing the class *alone* fires
+ `check_manifest_query` instead, so the probe must keep the row internally
+ coherent to reach the new check:
+
+ ```text
+ Error: the registries already declare 0 pure / 5 filesystem / 0 environment;
+ RFC 0006 section 6.1 states only 52/4/1 in total
+ ```
+
+- `COV-3`, forbidden class as a hard zero. One row is set to
+ subprocess-observing with its cell at `No`:
+
+ ```text
+ Error: the registries declare 1 helper(s) `subprocess-observing`; RFC 0006
+ section 6.1 states no proposed helper is
+ ```
+
+- Link-target number. Row `0013`'s link is retargeted to `0014-….md`, and
+ separately its link *text* is changed to `0014` while the target is
+ unchanged. Both fail, naming the disagreement:
+
+ ```text
+ Error: coverage map row for RFC 0013 at
+ docs/rfcs/0006-ansible-inspired-template-standard-library.md:2070 links to
+ 0014-mapping-and-sequence-transform-helpers.md, whose number is 0014
+
+ Error: coverage map row for RFC 0014 at
+ docs/rfcs/0006-ansible-inspired-template-standard-library.md:2070 links to
+ 0013-structured-data-interchange-helpers.md, whose number is 0013
+ ```
+
+ Restoring the link but reverting the cell to a bare `` `0013` `` while the
+ status stays `written` fails as "is marked written but its child RFC cell is
+ not a link".
+
+- Green baseline. With the probe child mounted and the map row flipped, and no
+ fault seeded, all ten tests pass and the reported count reads
+ `coverage map: 1 of 8 capability groups written; 7 remaining`. This is the
+ state `EP-M3` must reach.
+
+The harness restored both documents from their pristine copies after every
+probe, and the working tree carried only the nine intended files afterwards;
+`git diff` on the survey showed the two prose edits and nothing else.
+
+### Axioms
+
+- RFC 0006's dispositions are correct. This task partitions them.
+- Table 11's totals of 41, 16, and 3, and section 6.1's aggregate of 52, 4, and
+ 1, are correct. `EP-M0` re-derives both and must agree.
+- `markdownlint-cli2`, `typos`, and `mdtablefix` behave as configured.
+- Section 7's note column **does not** reliably discriminate the three reject
+ classes. This axiom is stated in the negative because `EP-M0` and `EP-M1`
+ falsified the positive form: no rule fitted to the document recovers table
+ 11's 22/10/18, and the best attempt yields 8/24/18. `D10` therefore takes the
+ deny set from the complement rule and `COV-2` does not assert the split. `D5`
+ rule 3 states the remedy — a discriminating column — if the split is ever
+ wanted as a contract.
+- Jinja namespaces are separate, so a filter and a test may share a name. No
+ name in the accepted set does; `abs` collides only with a pre-existing
+ MiniJinja built-in that is not in the inventory. The registry's namespace
+ column is carried for failure-message quality and forward insurance, not
+ because it disambiguates anything today.
+
+## Milestones and plateaus
+
+Because section 8 is not moved, every plateau is coherent by construction: RFC
+0006 is unchanged as a specification throughout, and each child adds a
+conformance record. An abandoned split leaves a repository that is incomplete,
+never one that is inconsistent. This is a materially weaker claim than the
+first draft made, and it is true.
+
+### `EP-M0` — audit and derivation rules. Go/no-go
+
+- Outcome: the partition and the `D5` derivation rules are confirmed against
+ the document, with two corrections adopted (`D10`).
+- Acceptance evidence: recorded in `Surprises & discoveries` — the heading
+ recount with its four reconciliation adjustments stated explicitly, since the
+ naive count is 58 and never 57; the finding that section 7's note column does
+ **not** discriminate the three reject classes, because no rule fitted to the
+ document recovers table 11's 22/10/18, so under `D10` the split is not
+ derived and is not asserted; the derived accepted set at 60 and forbidden set
+ at **71** rather than the 34 first written; the purity aggregate at 52, 4,
+ and 1 once scoped to `New` rows; and confirmation that RFC numbers 0013
+ upward are free on `origin/main` and every active remote branch.
+- Conformance check: no tracked file modified except this plan.
+- Recovery: nothing to revert.
+- Corrections adopted: `D10` (deny set is the complement of the accepted set,
+ not a union of reject classes), the `D5` rule 2 rewording for `hash`, the
+ `COV-3` purity scoping, and the `COV-2` member assertions — `is_file` is
+ forbidden, but by the complement rule rather than by its reject class.
+- **Go/no-go.** Stop if any derived count disagrees, or if the reviewer prefers
+ a fallback from `Alternatives considered`. The note column not discriminating
+ is **not** a stop condition: `EP-M0` and `EP-M1` established that it does
+ not, and `D10` answers it with the complement rule rather than with prose
+ parsing.
+
+### `EP-M1` — coverage test, ADR, corrections, roadmap rewrite
+
+Land as commits in PR #697, not as a self-contained pull request. The earlier
+draft asked for a separate pull request on the grounds that the milestone is
+independently valuable and is the fallback if nothing else proceeds; the
+reviewer's instruction names one pull request, and PR #697 already carries the
+task title, so the milestones stack there and each lands as its own commit. The
+same correction was applied to this milestone's own Progress entry when it
+shipped (see the `EP-M1` entry in `Progress`), and this paragraph had been left
+stating the superseded model.
+
+- Outcome: `tests/rfc_stdlib_coverage_tests.rs` green with all eight groups
+ unwritten and `COV-4` reporting 8. `ADR-040` records the convention, its
+ narrow scope, and the amendment procedure. RFC 0006 gains the section 14
+ coverage map and reservation rows for 0013 to 0020, has its number-allocation
+ table backfilled and its false claim that no RFC has been merged removed, has
+ the section 8.1 and section 8.6 defects corrected, has its seven "child
+ issue" phrases updated, and has section 16 question 7 annotated with a
+ pointer to roadmap task 7.1.1. Roadmap 6.1.1 was already rewritten per `D8`,
+ so `EP-M1` only has to confirm it still matches the delivered artefacts.
+- Acceptance evidence: `COV-1` through `COV-6` green; all seeded-fault
+ transcripts recorded; every gate green.
+- Conformance check: no disposition changed; no `src/` file touched; no new
+ dependency.
+- Recovery: revert; nothing depends on it.
+- Compatibility decision: none. RFC 0006 is a pre-1.0 internal document.
+
+### `EP-M2` — the template and one worked section 5
+
+- Outcome: the literal skeleton is committed into `ADR-040` — chosen over the
+ developers' guide because the template is part of the convention the ADR
+ already states in four parts, and the ADR is where a child's author is
+ already sent — and one complete worked section 5 exists for review, for RFC
+ 0013, the group `EP-M3` delivers. 0013 is the *first* group, not the
+ smallest: it owns 5 registry rows and 94 lines of section 8 against RFC
+ 0020's 2 rows and 80 lines, so "smallest" was wrong in this plan's own risk
+ entry as well. The choice of 0013 stands regardless, because the worked
+ example earns its keep by being the document the go/no-go is spent on, not by
+ being cheap to write.
+- Where it landed: the worked section sits in `ADR-040` under **The worked
+ section**, immediately after the template it fills in, and is a fenced
+ specimen rather than a ninth RFC. It could not be a file under `docs/rfcs/`:
+ `registries::parse_all` scans that directory and takes every file whose
+ number the coverage map reserves, so a specimen named `0013-…` would be
+ parsed as RFC 0013 itself and `every_accepted_helper_has_exactly_one_owner`
+ would accept a document that is not the child. The ADR is the safe home, and
+ it is also the natural one, since the template this completes already lives
+ there and neither can now be edited without the other in view.
+- Mechanical evidence, because the specimen is a copy-source and a wrong one
+ costs eight documents: the registry heading matches `REGISTRY_HEADING`
+ literally, the five rows parse to `new`/`pure`/`yes` under `registries.rs`'s
+ cell vocabularies, and the discharge table yields exactly `6.1` through
+ `6.11` in document order under `clauses.rs`. Checked by extracting the fence
+ and running both parsers over it, not by reading it.
+- Acceptance evidence: a reviewer reads the worked section 5 and can state one
+ thing it told them that RFC 0006 section 6 did not.
+- Recovery: revert.
+
+### `EP-M3` — RFC 0013, structured data interchange. Hard go/no-go
+
+- Outcome: `docs/rfcs/0013-structured-data-interchange-helpers.md` exists,
+ complete, listed in `docs/contents.md`, named in the coverage map, and
+ referenced from roadmap step 6.2 and its three tasks.
+- Acceptance evidence: `COV-1` shows five helpers owned by RFC 0013; `CONF-1`
+ green for it; `COV-4` reports 7 unwritten; every gate green.
+- Conformance check: no disposition changed; open questions 1 and 4 carried
+ unresolved; the anti-vacuity rule satisfied for all eleven subsections.
+- **Go/no-go.** This is the most important checkpoint in the plan. Everything
+ before it is reversible and spends no number that matters. Everything after
+ commits seven more numbers to a pattern nobody has yet seen finished. If
+ section 5 for five simple pure filters does not tell a reviewer something
+ they did not already know, the pattern does not work and the remaining seven
+ must not be written. Fall back to `Alternatives considered`.
+- Recovery: revert the commit; the coverage-map row returns to unwritten.
+
+### `EP-M4` to `EP-M10` — one capability RFC each
+
+Identical in shape, so stated once. For child `00NN` owning the groups in
+`Table 1` for roadmap step `6.S`:
+
+- Outcome: `docs/rfcs/00NN-.md` exists per the template in `ADR-040`; the
+ coverage map names it; `docs/contents.md` lists it; roadmap step `6.S` and
+ each of its tasks cite it.
+- Acceptance evidence: `COV-1` shows exactly that RFC's registry helpers owned
+ by it; `CONF-1` green; `COV-4`'s unwritten count decremented; gates green.
+- Conformance check: no disposition changed; `COV-2` proves mechanically that
+ no forbidden name was introduced; open questions match the assignment.
+- Recovery: revert the single commit. Because section 8 is untouched, the prior
+ plateau is fully coherent.
+- Compatibility decision: none.
+
+Order: `EP-M4` RFC 0014, `EP-M5` RFC 0015, `EP-M6` RFC 0016, `EP-M7` RFC 0017,
+`EP-M8` RFC 0018, `EP-M9` RFC 0019, `EP-M10` RFC 0020. RFC 0017 precedes RFC
+0018 because `expandvars` and `abs` both depend on the dialect mechanism that
+section 8.6 defines and RFC 0017 discharges. Ship `EP-M4` to `EP-M7` as one
+pull request and `EP-M8` to `EP-M11` as another, so no reviewer faces the whole
+set at once.
+
+### `EP-M11` — reconcile and close
+
+- Outcome: all 39 roadmap task-level section 8 citations additionally cite
+ their child RFC; `COV-4` reports 0 unwritten; task 6.1.1 marked done; this
+ plan `COMPLETE`.
+- Acceptance evidence: `make check-fmt`, `make lint`, `make doc-coverage`,
+ `make test`, `make markdownlint`, and `make nixie` green on the merge commit,
+ captured under `/tmp`. `COV-5` green over every new link.
+- Conformance check: reconcile every `Surprises & discoveries` entry. Both RFC
+ 0006 defect corrections present. No disposition changed. Re-enumerate remote
+ heads a final time to confirm no allocated number collided.
+- Recovery: not applicable.
+- Remaining gaps: none. Implementing the helpers is steps 6.2 to 6.9.
+
+## Plan of work
+
+### Stage A — understand and propose
+
+Read RFC 0006 sections 6, 7, 7.8, 8, 9, 10, 13.4, 14, and 16. Perform the
+`EP-M0` derivations. Confirm numbers are free:
+
+```bash
+git ls-remote --heads origin | grep -oP 'refs/heads/\K.*' | sort
+git ls-tree --name-only origin/main docs/rfcs/
+```
+
+Ends at the go/no-go.
+
+### Stage B — red
+
+Write the coverage test before any child RFC. Wire it per
+`tests/integration_test_wiring_tests.rs`, following the plain module
+declaration precedent in `tests/documentation_installation_tests.rs`, not the
+path-attribute precedent in `tests/dependabot_config_tests.rs`, which sidesteps
+the orphan check rather than satisfying it.
+
+Split across `tests/rfc_stdlib_coverage_tests.rs` and
+`tests/rfc_stdlib_coverage/` **from the start**, not if it grows. The
+repository caps code files at 400 lines and both comparable precedents already
+exceed it at 411 and 419. Because `D5` derives the inventory from RFC 0006
+there is no large data table to hold, so the split is a parser module plus an
+assertions module.
+
+Run it and confirm it fails for the expected reason — the coverage map does not
+exist. Add the map, confirm green. Then apply every seeded fault from the
+`Verification plan` in turn, confirm the expected failure, revert, and capture
+each transcript.
+
+Lint constraints that shape the code: `unwrap_used` and `expect_used` are
+denied outside test bodies, so every parser helper returns `anyhow::Result`;
+`panic_in_result_fn` is denied; and `missing_docs_in_private_items` is denied,
+covering struct fields and enum variants, not merely types.
+
+### Stage C — implementation
+
+`EP-M1` as commits in PR #697, then `EP-M2`, then one commit per child. For
+each child:
+
+1. Create `docs/rfcs/00NN-.md` from the literal template in `ADR-040`.
+2. Write section 4 as a list of the group's helpers with one-line purposes and
+ links into RFC 0006 section 8.N. Do not restate a contract.
+3. Write section 5.1's registry, then 5.6, 5.7, 5.8, and 5.9. Apply the
+ anti-vacuity rule to 5.2 through 5.5, 5.10, and 5.11. Close section 5 with
+ the clause-discharge table, and complete it rather than copying it forward:
+ it is the one place a reader sees all eleven clauses resolved.
+4. Flip the child's row in the coverage map from unwritten to the new number.
+5. Add the `docs/contents.md` entry, phrased like the RFC 0009 to 0011 entries.
+6. Add a `See RFC 00NN` sub-bullet to roadmap step `6.S`, and the same to each
+ of the step's tasks alongside the existing section 8 citation.
+7. Re-enumerate remote heads, run `make fmt`, run the gates, commit.
+
+### Stage D — reconcile
+
+`EP-M11`. Sweep the remaining citations, confirm `COV-4` reports zero, run the
+full gate set sequentially.
+
+## Concrete steps
+
+Run everything from the worktree root. Gate commands, sequential and tee'd:
+
+```bash
+make fmt
+make check-fmt 2>&1 | tee /tmp/check-fmt-netsuke-$(git branch --show-current).out
+make lint 2>&1 | tee /tmp/lint-netsuke-$(git branch --show-current).out
+make test 2>&1 | tee /tmp/test-netsuke-$(git branch --show-current).out
+make markdownlint 2>&1 | tee /tmp/markdownlint-netsuke-$(git branch --show-current).out
+make nixie 2>&1 | tee /tmp/nixie-netsuke-$(git branch --show-current).out
+```
+
+Focused test during Stage B:
+
+```bash
+cargo nextest run --test rfc_stdlib_coverage_tests 2>&1 \
+ | tee /tmp/covtest-netsuke-$(git branch --show-current).out
+```
+
+Observed red transcript, the `COV-4` control state with the coverage map table
+removed. The order varies between runs, and `inter_document_links_resolve`
+alone stays green because nothing else it does touches the map:
+
+```plaintext
+PASS [ 0.007s] (1/7) netsuke-build::rfc_stdlib_coverage_tests inter_document_links_resolve
+FAIL [ 0.019s] (2/7) netsuke-build::rfc_stdlib_coverage_tests every_accepted_helper_has_exactly_one_owner
+ Error: the coverage map subsection contains no table
+FAIL [ 0.019s] (3/7) netsuke-build::rfc_stdlib_coverage_tests coverage_map_status_is_reported
+ Error: the coverage map subsection contains no table
+ … four more FAIL lines, each with the same error …
+error: test run failed
+```
+
+Observed green transcript at `EP-M1`:
+
+```plaintext
+PASS [ 0.006s] (1/7) netsuke-build::rfc_stdlib_coverage_tests inter_document_links_resolve
+PASS [ 0.020s] (2/7) netsuke-build::rfc_stdlib_coverage_tests coverage_map_status_is_reported
+ coverage map: 0 of 8 capability groups written; 8 remaining
+PASS [ 0.020s] (3/7) netsuke-build::rfc_stdlib_coverage_tests every_capability_has_a_roadmap_task
+PASS [ 0.021s] (4/7) netsuke-build::rfc_stdlib_coverage_tests every_child_discharges_every_clause
+PASS [ 0.021s] (5/7) netsuke-build::rfc_stdlib_coverage_tests every_accepted_helper_has_exactly_one_owner
+PASS [ 0.022s] (6/7) netsuke-build::rfc_stdlib_coverage_tests totals_and_purity_aggregate_agree
+PASS [ 0.022s] (7/7) netsuke-build::rfc_stdlib_coverage_tests no_forbidden_helper_is_registered
+Summary [ 0.022s] 7 tests run: 7 passed, 0 skipped
+```
+
+Predicted wrong-owner seeded-fault transcript, the control the first draft
+lacked. No registry exists yet, so this one has no observed counterpart:
+
+```plaintext
+FAIL [ 0.012s] (1/7) netsuke-build::rfc_stdlib_coverage_tests every_accepted_helper_has_exactly_one_owner
+ Error: helper combine (filter): coverage map designates RFC 0014, registry found in
+ RFC 0015 at docs/rfcs/0015-ordered-collection-algebra-and-truth-predicates.md:73
+```
+
+Commit messages use `git commit -F`. The per-child template:
+
+```plaintext
+Add RFC 00NN for the helpers (6.1.1)
+
+Records the RFC 0006 section 6 contract obligations for the helpers in
+section 8.N, with a purity and manifest-query registry, and links the group
+to roadmap step 6.S.
+
+RFC 0006 section 8 is unchanged; this RFC is additive.
+
+Co-Authored-By: Claude Opus 5 (1M context)
+```
+
+Pull request titles carry the roadmap number in parentheses, as the repository
+convention requires: for example `Add the RFC 0006 coverage contract (6.1.1)`.
+
+Delegate gate runs to `scrutineer` and the `docs/contents.md` and roadmap
+citation work to `scribe`. Do not delegate any section 5.
+
+## Validation and acceptance
+
+Acceptance is behavioural.
+
+1. Open any child RFC's section 5.1 and read a table giving every helper in the
+ group its purity class and manifest-query availability. Confirm no such
+ table exists anywhere in the repository today — that is the artefact this
+ task creates.
+2. Read that child's sections 5.6 through 5.9 and find, for each, a
+ group-specific consequence: a named bound from RFC 0006 table 3, a
+ diagnostic code, a named error condition. Nowhere does a justification
+ reduce to matching Ansible.
+3. Run `make test` and observe `rfc_stdlib_coverage_tests` pass with eighteen
+ tests — seven obligation checks, six `markdown.rs` heading and fence tests,
+ five `deference.rs` appeal tests — and a line reporting how many capability
+ groups remain unwritten. Delete one row from RFC 0013's section 5.1
+ registry, re-run, and observe a failure naming that helper and reporting
+ zero owners. Restore the row.
+4. Move a registry row from one child to another, re-run, and observe a
+ wrong-owner failure naming both the designated and the actual RFC.
+5. Search the registries in `docs/rfcs/` for `shuffle`, `is_dir`, `is_file`,
+ `quote`, and `fileglob`, and find none of them. Each remains discussed in
+ RFC 0006 sections 9, 10, and 11, and each may be named as a non-goal in a
+ child's section 3 — the check is scoped to registry tables, which is what
+ `COV-2` parses.
+6. Open `docs/roadmap.md` at step 6.5 and find both the existing section 8.4
+ citation and a new RFC 0016 citation on each task.
+7. Confirm `docs/contents.md` lists all eight new RFCs.
+
+Red-Green-Refactor evidence:
+
+- Red: the focused test fails because RFC 0006 section 14 has no coverage map.
+- Green: it passes once the map lands, with all eight groups unwritten.
+- Refactor: it still passes at `EP-M11` with zero groups unwritten, and the
+ reported remaining count has gone 8, 7, and so on to 0 across the milestones.
+
+No behaviour-driven scenarios apply: this changes no observable tool behaviour,
+adds no command-line surface, and touches no persistence or network boundary.
+`docs/users-guide.md` is therefore not updated; RFC status is not user-facing
+behaviour. Recorded so the omission is a decision, not an oversight.
+`ortho_config` is likewise not used: it governs layered configuration surfaces
+and this change adds none. If a later milestone unexpectedly introduces one —
+for instance by resolving RFC 0006 open question 5 to expose the section 6.8
+bounds through `StdlibConfig` — that is a tolerance breach requiring escalation.
+
+Quality criteria:
+
+- Tests: `make test` green, including the seven coverage tests.
+- Verification: `COV-1` through `COV-6` and `CONF-1` discharged, with every
+ seeded-fault transcript recorded. `CONF-1`'s semantic residual gap stated
+ plainly and not overclaimed.
+- Lint: `make lint`, `make markdownlint`, `make check-fmt`, and `make nixie`
+ green. Note `make doc-coverage` does not measure integration tests; the
+ governing gate is `missing_docs_in_private_items` under `make lint`.
+- Performance and security: not applicable; no capability, dependency, or trust
+ boundary changes.
+
+## Idempotence and recovery
+
+Every step is re-runnable; `make fmt` is idempotent. Each milestone is one
+commit.
+
+Rollback is genuinely simple here, and only because of `D3`. Since RFC 0006
+section 8 is never modified, reverting any child-RFC commit removes a
+conformance record and returns a coverage-map row to unwritten. No contract is
+lost and no citation breaks. Reverts conflict only on the coverage map and
+`docs/contents.md`, both single-line edits.
+
+The one irreversible act is allocating an RFC number, and only on merge. `D2`
+allocates lazily, so an abandoned split spends only the numbers it used. Before
+merge, renumbering is a rename plus a link sweep; `COV-5` catches a stale link
+that `make markdownlint` cannot.
+
+Do not use bare `git stash`; the stash stack is shared across worktrees. Use a
+temporary work-in-progress commit.
+
+## Artefacts and notes
+
+To be filled during implementation. Record at minimum: the `EP-M0` derivation
+results with the heading-count reconciliation stated; every seeded-fault
+transcript; the `git diff --stat` per child commit; and the final gate log
+paths under `/tmp`.
+
+## Interfaces and dependencies
+
+Files created:
+
+- `docs/rfcs/0013-structured-data-interchange-helpers.md`
+- `docs/rfcs/0014-mapping-and-sequence-transform-helpers.md`
+- `docs/rfcs/0015-ordered-collection-algebra-and-truth-predicates.md`
+- `docs/rfcs/0016-pattern-and-version-predicates.md`
+- `docs/rfcs/0017-lexical-path-composition.md`
+- `docs/rfcs/0018-host-state-predicates-and-environment-expansion.md`
+- `docs/rfcs/0019-encoding-identity-and-formatting-helpers.md`
+- `docs/rfcs/0020-date-and-time-conversion-helpers.md`
+- `docs/adr-040-focused-child-rfcs-for-survey-rfcs.md`
+- `tests/rfc_stdlib_coverage_tests.rs`
+- `tests/rfc_stdlib_coverage/mod.rs` and its submodules
+
+Files modified:
+
+- `docs/rfcs/0006-ansible-inspired-template-standard-library.md`
+- `docs/roadmap.md` — task 6.1.1 already amended; steps 6.2 to 6.9 gain a
+ child-RFC citation per child
+- `docs/contents.md`
+- this ExecPlan
+- `.gitignore` — adds `uv.lock`, paired with `git rm --cached uv.lock`. The
+ lockfile is never tracked on `origin/main`; it entered this branch by
+ accident at `a94a3006`, whose `git add -A` swept its own verification entry
+ and the working-tree file in together, and it left again at `f42202a4`. The
+ net diff against `origin/main` for that path is therefore **empty**, and the
+ `.gitignore` line is the only surviving trace. It is named here because a
+ reader who runs `git diff origin/main...HEAD -- uv.lock` sees nothing and
+ would otherwise not know the change exists.
+
+ **Authorized separately from this split, and not part of its scope.** The
+ `.gitignore` line and the `git rm --cached` were requested explicitly for
+ this branch rather than arrived at by the execplan, so a reviewer finding
+ them outside `EP-M4`–`EP-M11` is looking at a deliberate, separately-approved
+ change and not at scope creep. The reasoning that makes the change correct —
+ a lockfile that locks nothing, an ignore rule that would be inert while the
+ path stays tracked, and the measurements showing no test reads the file — is
+ recorded in the Progress entry dated 2026-09-28 that begins "A second change
+ rides on this head". Its placement in this list is descriptive: the file
+ changes on this branch, so the interface list names it.
+
+No other file changes. No `src/` change. No `Cargo.toml` change: `googletest`
+0.14.3, `pretty_assertions` 1.4.1, and `regex` 1.12.2 are dev-dependencies, and
+`anyhow` is a normal dependency, which integration tests link against. The
+`.gitignore` and `uv.lock` change above is the one exception to "no other file
+changes", and it is confined to repository hygiene — no build input.
+
+The test's private shape, in `tests/rfc_stdlib_coverage/mod.rs`. Every field
+and variant needs a documentation comment, since
+`missing_docs_in_private_items` is denied.
+
+```rust
+/// Jinja namespace a helper occupies.
+enum Namespace {
+ /// Invoked as a filter on a piped value.
+ Filter,
+ /// Invoked as a test after `is`.
+ Test,
+ /// Invoked as a bare function call.
+ Function,
+}
+
+/// Whether a registry row introduces a helper or adds an option to one.
+enum Registration {
+ /// One of the 57 new helpers.
+ New,
+ /// One of the 3 existing helpers gaining an option.
+ OptionAdded,
+}
+
+/// A surveyed name's disposition, derived from RFC 0006 section 7.
+enum Disposition {
+ /// Accepted, with the owning section 8 subsection.
+ Accept,
+ /// Deferred by section 9.
+ Defer,
+ /// Rejected by section 10, for any of the three reasons table 11 counts.
+ Reject,
+}
+
+/// One accepted helper, as the document that established it records it.
+struct Row {
+ /// Registered helper name, backticks stripped.
+ name: String,
+ /// Namespace the helper occupies.
+ namespace: Namespace,
+}
+```
+
+## Revision note
+
+Revised 2026-09-08 after a six-lens design review. The review verified the
+partition — all 57 helpers, none lost, duplicated, or misassigned — and
+rejected almost everything around it.
+
+The plan no longer migrates RFC 0006 section 8 into the children (`D3`): the
+repository's convention is additive, migration would strand 39 roadmap
+citations with no link gate to notice, two groups split across two milestones
+would leave genuinely incoherent intermediate states, and the measured
+contributor read path would have got worse rather than better. The Purpose
+section was rewritten because its original premise, that a contributor must
+read 2132 lines, was false; the roadmap already deep-links every task. The
+coverage test is now anchored on tables and derived from RFC 0006's own section
+7 rather than on headings and hardcoded constants (`D4`, `D5`): heading
+anchoring cannot see `basename` or `dirname`, miscounts three compound
+headings, and false-positives on the parent's own sections 10, 11, and 15,
+while the handwritten forbidden list had already omitted sixteen names. A ninth
+child RFC for roadmap step 6.1 was dropped as empty. Section 5 was cut from
+eleven mandatory prose clauses to five substantive ones with an anti-vacuity
+rule (`D6`), because seven of eight children own only pure helpers and the
+original structure was a vacuity generator. Numbers are now allocated lazily
+(`D2`) against a measured in-repository collision base rate of three. A hard
+go/no-go was added after the first completed child, an
+`Alternatives considered` section was added with an `EP-M1`-only fallback, and
+five tolerances were added covering aggregate volume, effort, gate false
+positives, number collision, and vacuity. Two further RFC 0006 defects were
+recorded, and several factual errors corrected: issues #596 and #594 are
+closed, the naive section 8 heading count is 58 rather than 57,
+`make doc-coverage` does not measure integration tests, and `basename` and
+`dirname` carry a reject disposition the derivation rules must not confuse with
+the forbidden set.
+
+Nothing is implemented; the plan awaits approval.
+
+Revised again 2026-09-08 on reviewer direction. Roadmap task 6.1.1 has been
+rewritten in place to say "child RFCs and accompanying roadmap tasks", and to
+record that delivery is tracked through the roadmap checkboxes in steps 6.2 to
+6.9 rather than through separate issues, so progress stays in committed
+documentation. The plan's `Scope divergence` section is therefore retired and
+replaced by `Scope: settled`, which keeps the reasoning on the record without
+presenting it as an open question. `D8` is updated accordingly, and the lost
+burn-down it worried about is resolved rather than merely noted.
+
+Two precision fixes came out of amending the roadmap. The success criterion now
+reads "exactly one child RFC and by at least one accompanying roadmap task",
+because `product` is already named by both task 6.4.2 and task 6.4.5, so
+requiring exactly one task would have meant splitting existing tasks for no
+benefit. And a new obligation `COV-6` checks the roadmap half of the criterion
+mechanically, which the plan previously left unchecked: every accepted helper
+must be named by at least one task under the step that owns its child RFC. That
+was verified before the obligation was written — all 60 helpers already are — so
+`COV-6` is green on the current roadmap and fails only if a task is deleted or
+a helper is reassigned across steps.
+
+Nothing is implemented; the plan awaits approval.
+
+Revised again 2026-09-11 after `EP-M0`. The plan is approved and in progress:
+the implementation agent was asked to proceed, and `EP-M0` ran as the first
+task, as the plan requires. The audit confirmed every derived count except one,
+and the exception was instructive. The plan's 34-member forbidden set was a row
+count wearing a name count's clothes, and it could not coexist with the plan's
+own `is_file` and `expanduser` assertions — under a faithful class reading
+`is_file` is not forbidden, and under the row count that yields 34 neither is
+it. `D10` resolves the inconsistency by stating the deny set as the
+**complement of the accepted set** — 71 names — which keeps the four membership
+witnesses the first draft wanted, gives up only the `expanduser` exclusion, and
+needs no normative edit to RFC 0006. Everything else held: the partition, the
+accepted set at 60, the 41/16/3 totals, the purity aggregate, the naive heading
+count of 58 with its four reconciliation adjustments, and the availability of
+RFC numbers 0013 to 0020 on `origin/main` and every active remote branch.
+
+Three smaller corrections followed. `D5` rule 2 asserted that all three rename
+rows say `Reject`; `hash`'s row actually reads "Accept as `text_hash`", so the
+rule is read off the disposition cell and the rename is not a parser special
+case. `COV-3`'s purity aggregate has to be scoped to `New` rows, because the
+registries carry all 60 accepted helpers including the filesystem-observing
+`glob` and would otherwise aggregate to 54/5/1 against section 6.1's 52/4/1. And
+`COV-2`'s member list was restated against the complement, with `is_dir`,
+`is_link`, `win_dirname`, and `expanduser` added to its non-vacuity assertions
+and the `expanduser` exclusion dropped.
+
+One `D5` rule 3 remedy is now **in scope, but deferred by choice**. The plan
+provided that if section 7's note column did not discriminate the three reject
+classes, `EP-M1` would add a discriminating column. `EP-M0` believed it did
+discriminate — a reject row being *alias* if its resolution cell contains
+"alias" or cites §10.2, *exists* if it begins "Exists" or names a provider in
+backticks, and *principle* otherwise, which was recorded as reproducing table
+11's 22/10/18. `EP-M1` re-derived that rule and got 8 alias / 24 exists / 18
+principle. The *principle* third is correct and every principle row is
+backtick-free, but the remaining 32 rows split 8/24 where table 11 says 10/22;
+the difference is exactly the two rename rows `win_splitdrive` and `fileglob`,
+which §7.8 says are "registered under a Netsuke name rather than the surveyed
+one" and which table 11 therefore counts as aliases while the rule counts them
+as exists. Recovering 22/10/18 needs those two special-cased out of *exists*
+while `now` — also a reject row naming an existing helper in backticked call
+form — stays inside, and the document offers nothing to distinguish them. The
+split is therefore **not derivable from the tables as they stand**, and `COV-2`
+does not assert it: it asserts the directly parseable totals, plus that table
+11's three class counts sum to the derived reject-row count. Adding the
+discriminating column remains the remedy if the split is ever wanted as a
+contract; `D10` keeps it off the deny set's critical path either way, so no
+normative edit to RFC 0006 is made now.
+
+- [x] (2026-09-28) **The `2db228aa` review triaged: seven local CodeRabbit
+ findings, five in scope, plus the verification that the scrutineer's two
+ "posted-but-unaddressed" comments were already fixed.** The seven local
+ targets were reviewed against this branch's actual diff, which is 60 commits
+ over 27 files. Note the local `--agent` pass diffed `6499fc48` — a commit
+ that is not an ancestor of HEAD, is not on `origin/main`, and is reached by
+ no ref. Nine of its fourteen findings land outside the PR diff entirely (kani
+ tests, `.github/workflows/ci.yml`, `Makefile`, `pylint_tier_test.py`, two
+ foreign ExecPlans, `resolver_telemetry_boundary_tests.rs`) and are not this
+ branch's to fix. The five in scope are applied in the commit carrying this
+ entry.
+
+ Two of the five are corrections of *this plan's* text and one of those
+ contradicts a number the plan states eight other times:
+
+ - `bijection over fifty-seven names` is wrong twice over. The bijection is
+ over the accepted set, which is **sixty** — 57 new helpers plus the 3
+ existing ones gaining an option — and the plan's own totals table already
+ reads `| Accepted set | 60 | 60 | agree |`. Both occurrences were corrected,
+ with the arithmetic shown so the 57 is not simply erased: it is the
+ *new-helper* figure, not the accepted-set figure.
+ - Three first-person pronouns were removed from the plan, per
+ `documentation-style-guide.md` line 39. The sweep found **three**, not the
+ two the finding named: the phrase `My own build attempt`, in the entry
+ about the package-cache deadlock, was outside the finding's citation and
+ would have been missed by applying it literally.
+ A corpus probe bounds the rule honestly — `docs/` carries 149 first-person
+ occurrences, so the rule is stated rather than universally observed, and
+ this plan is now among the compliant files rather than the rest.
+ - The `.gitignore` / `uv.lock` paragraph now records that the change was
+ authorized separately from the split and is not part of its scope, which is
+ what the finding asks for. The change itself is untouched: it was requested
+ for this branch in its own right, and is correct for reasons recorded in the
+ earlier entry beginning "A second change rides on this head". What was
+ missing was the *record of authorization*, not the change.
+
+ The remaining two are normative edits that reach beyond this plan, and each
+ was mirrored into its other copy in the same commit, per the rule this plan
+ already records — *when a correction is applied to an artefact, grep for its
+ other copies in the same commit*:
+
+ - **RFC 0013 section 5.6 omitted `output_too_large` from both serializer
+ bullets.** The code is real, is in section 5.9's table, and is *enforced*
+ by section 5.8's length pass — but section 5.6 is the per-helper
+ enumeration a reviewer reads to learn what each helper rejects, and it
+ listed four conditions for `to_yaml` and repeated "the same four" for
+ `to_nice_json`. Both bullets now name five. The count was checked against
+ the table afterwards rather than asserted: `to_yaml` now names
+ `undefined_input`, `indent_out_of_range`, `unsupported_key`,
+ `unsupported_kind`, and `output_too_large`, and section 5.9 carries a row
+ for each. **This changes no code and no count elsewhere** — fourteen codes
+ was already correct.
+ - **Section 5.7's rendered-key collision named no code.** It said
+ `to_nice_json` "rejects a mapping whose rendered keys are not distinct"
+ while the adjacent sentence named `duplicate_key` for `from_json`'s
+ rejection of the same collision. The serializer reuses that code — the same
+ key problem detected at the other end of the round trip — so naming it keeps
+ the code set at fourteen and leaves both discharge rows and section 5.9's
+ table untouched. Introducing a fifteenth code was the alternative and was
+ rejected as the larger change for no gain in precision.
+
+ **The scrutineer's "Next Action" was wrong and was not acted on.** It
+ reported two "posted-but-unaddressed findings" —
+ `tests/rfc_stdlib_coverage/map.rs:173` (duplicate child RFC reservations) and
+ `tests/rfc_stdlib_coverage/markdown.rs:154` (fence indentation) — and told
+ the next agent to review them first. Both guards are present and correct:
+ `map.rs` rejects a repeated reservation at lines 120-139, and `markdown.rs`
+ bounds fence indentation at 136-154. The posted comments carry
+ `commit_id = 2db228aa` only because GitHub re-anchors a review comment's line
+ and position on push; the bodies describe defects fixed in commits merged
+ before it. **A comment's `commit_id` is not evidence that the code it points
+ at is still broken.** This is a second instance of the same re-anchoring
+ illusion already recorded for this PR, and the verification cost three file
+ reads — cheaper than the alternative, which was to re-fix two live guards.
+
+ One further false premise was rejected before it reached the tree. The
+ scrutineer's report also read the `coverage map: 1 of 8 …` line as not being
+ a progress report at all. It is correct that COV-4 captures the *test's own
+ stdout* rather than a live count, but the plan has consistently read it as
+ the reported state of the map and that reading is the one the test asserts
+ on; the observation changes no disposition.
+
+ **A `make fmt` run was required and is recorded rather than assumed.** Gate
+ one (`make check-fmt`) reded on the first attempt of this change:
+ hand-wrapped prose is not `mdtablefix`'s canonical wrap, and four of the six
+ edited files were flagged. That is the canonicalization trap this plan has
+ already met once, and it is a formatting failure rather than a content one —
+ the formatter's own `--fix` was the remedy, and the resulting reflow was
+ verified not to have dropped content by comparing word counts against HEAD
+ (all six files grew, none shrank) and by re-reading every edited passage in
+ the reflowed text. Two `grep` probes returned empty during that verification
+ for phrases that were in fact present, because `mdtablefix` had moved them
+ across a line break: **a phrase-level `grep` is not evidence of absence in a
+ wrapped document** — read the paragraph.
+
+ **The gate run then reded a second time, and again the defect was this
+ branch's own prose.** `make markdownlint` aborted in its `spelling`
+ prerequisite on `canonicalisation` — a bare `-ise` form written into the very
+ paragraph describing the canonicalization trap, which is the *same* defect
+ class this plan already records at the entry about `feed5192`, three hundred
+ lines above the new text. The lesson repeats rather than extending: the
+ recurrence is not evidence that the earlier entry was wrong, it is evidence
+ that the trap is live in new prose and that writing *about* a spelling rule
+ in an unbackticked word is itself the hazard. `markdownlint-cli2` never ran,
+ so mdlint had no verdict — that state was reported as UNKNOWN rather than as
+ a pass, per the distinction already recorded above.
+
+ Two side effects of the failed gate are worth recording, because each would
+ otherwise have entered the commit unexamined:
+
+ - **`typos.toml` is rewritten by the gate, and the rewrite was reverted.**
+ `make spelling` regenerates the file from the pinned shared dictionary
+ (`typos-config-builder` `v0.1.1`) on every run, and the run moved one
+ `extend-ignore-re` entry — narrowing `\bvar\.iamge_id\b` to a longer
+ backticked phrase and re-sorting it. The file is tool output, not hand
+ prose, so the question is not whether the new text is nicer but whether a
+ stale file fails anything. **It does not.** The probe is decisive: with
+ HEAD's version restored, `make spelling` exits **0**, prints `current:
+ typos.toml`, and silently rewrites the file to the same 44-entry text on
+ each run, byte-identical across two consecutive runs. So the modification is
+ a gate side effect that CI reproduces on its own, and committing it would
+ widen this branch's diff by a file the ExecPlan does not list for the sake
+ of text that is regenerated rather than authored. It was reverted, and the
+ tree is back to the six files this change touches. The one thing that does
+ bite is a *stale committed* file being invisible in review: nothing fails,
+ so nobody learns the dictionary moved.
+ - **A `grep` for `-ise` forms on added lines found no others.** Reporting the
+ negative matters here because the spelling gate stops at the first error, so
+ a single fix is not evidence that the rest of the new prose is clean. The
+ check was run against the added lines specifically — `canonicalis`,
+ `normalis`, `serialis`, `initialis`, `organis`, `recognis`, `analys`, and
+ their kin — and the only surviving matches were correct English words
+ (`collision`, `diagnosis`, `premise`, `consistently`), none of them a `-ise`
+ variant a reviewer would need to weigh.
+
+ Concern counts after triage: **high 0, medium 0, low 2** — the two CodeRabbit
+ findings in `tests/rfc_stdlib_coverage/markdown.rs` and `map.rs` that this
+ session verified as already-fixed, and which are therefore closed rather than
+ carried.
diff --git a/docs/rfcs/0006-ansible-inspired-template-standard-library.md b/docs/rfcs/0006-ansible-inspired-template-standard-library.md
index 96a636504..7b8bd663e 100644
--- a/docs/rfcs/0006-ansible-inspired-template-standard-library.md
+++ b/docs/rfcs/0006-ansible-inspired-template-standard-library.md
@@ -13,20 +13,32 @@
### Number allocation
-No RFC has been merged to `main` yet, so the `docs/rfcs/` sequence is defined
-entirely by in-flight branches. This RFC takes `0006` and treats the numbers
-below it as reserved, per the "gaps are acceptable when numbers are reserved,
-drafted on another branch, or intentionally skipped" rule in
+Numbers `0001` to `0012` are merged to `main` and are therefore taken. This RFC
+takes `0006`, and reserves `0013` to `0020` for the capability child RFCs
+section 14.13 allocates, per the "gaps are acceptable when numbers are
+reserved, drafted on another branch, or intentionally skipped" rule in
[the documentation style guide](../documentation-style-guide.md).
-| Numbers | Reserved for | Current state |
-| ------------ | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
-| 0001 | Structured command blocks, plus the two amendments | Drafted in [#573](https://github.com/leynos/netsuke/pull/573) and superseded by [#600](https://github.com/leynos/netsuke/pull/600) |
-| 0002 to 0004 | Manifest composition: repository-relative includes, versioned local bundles, digest-pinned external bundles | Drafted in [#600](https://github.com/leynos/netsuke/pull/600) |
-| 0005 | Release integrity and admission | Proposed in [#556](https://github.com/leynos/netsuke/pull/556) |
-| 0006 | This RFC | Proposed |
-
-_Table 1: RFC sequence reservations across in-flight branches._
+| Numbers | Reserved for | Current state |
+| ------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
+| 0001 | Structured command blocks | Merged in [#573](https://github.com/leynos/netsuke/pull/573) |
+| 0002 to 0004 | Manifest composition: repository-relative includes, versioned local bundles, digest-pinned external bundles | Merged in [#600](https://github.com/leynos/netsuke/pull/600) |
+| 0005 | Release integrity and admission | Merged in [#556](https://github.com/leynos/netsuke/pull/556) |
+| 0006 | This RFC | Merged in [#602](https://github.com/leynos/netsuke/pull/602) |
+| 0007 | Netsukefile testing framework | Merged in [#566](https://github.com/leynos/netsuke/pull/566) |
+| 0008 | Repository-wide code-health contracts and fuzzing | Merged in [#556](https://github.com/leynos/netsuke/pull/556) |
+| 0009 to 0010 | Structured-command amendments: per-command working directories, and runtime bindings and secure tempdirs | Merged in [#600](https://github.com/leynos/netsuke/pull/600) |
+| 0011 | Allow-listed structured command shells | Merged in [#653](https://github.com/leynos/netsuke/pull/653) |
+| 0012 | Netsukefile property testing | Merged in [#654](https://github.com/leynos/netsuke/pull/654) |
+| 0013 to 0020 | Capability child RFCs this RFC's accepted set is split into | Reserved here; allocated in section 14.13 |
+
+_Table 1: RFC number allocation and merge state, and the numbers reserved here._
+
+Every row of table 1 records a merge state, and `Proposed` is not one: it is the
+`Status` this RFC's own preamble carries, and every merged RFC in the corpus
+carries it too — a merged RFC's status stays `Proposed` until its capability
+has shipped. `0006` is therefore merged and `Proposed`, and the row states the
+merge because that is the fact the column is about.
## 1. Summary
@@ -48,7 +60,7 @@ existing Netsuke surface, and a delivery sequence of ten focused slices.
This RFC specifies behaviour only. It contains no implementation, and it is not
itself a request to merge one large standard-library change. Accepted groups
-become focused child issues after v0.1.0 final.
+become focused child RFCs after v0.1.0 final.
## 2. Problem
@@ -194,12 +206,13 @@ the third has since been closed.
- A generic plugin or lookup dispatcher. See section 10.5.
- Any change to the v0.1.0 release scope.
- Any change to the existing `hash` contract. See section 11.1.
- - Implementation. This RFC specifies behaviour; child issues implement it.
+ - Implementation. This RFC specifies behaviour; child RFCs carry it, and the
+ roadmap tasks in steps 6.2 to 6.9 are what schedule the work.
## 5. Licensing and provenance boundary
Ansible is licensed GPL-3.0-or-later. Netsuke is licensed ISC. The following
-rules are normative for every child issue arising from this RFC.
+rules are normative for every child RFC arising from this RFC.
1. Names, concepts, documented signatures, and independently verified
observable behaviour may be borrowed.
@@ -224,8 +237,8 @@ and
## 6. Cross-cutting contract
-This section is normative. A child issue that does not satisfy every clause
-below for every helper it adds is not complete.
+This section is normative. A child RFC that does not satisfy every clause below
+for every helper it adds is not complete.
### 6.1. Purity classes
@@ -365,6 +378,12 @@ values through a hash set.
order-preserving map keyed on the canonical key; they must not expose the
map's iteration order.
+The canonical-JSON domain is the set of values for which RFC 8785 produces a
+canonical JSON representation, with string keys at every mapping level and no
+value excluded above. Round-trip claims in section 8.1 are limited to that
+domain. A serializer may accept additional native values, but converting them
+does not establish canonical equality with the original value.
+
### 6.8. Resource bounds
Every parser, combinatorial helper, regular-expression operation, and
@@ -424,8 +443,11 @@ Netsuke registers exactly **one** name per capability.
### 6.11. Documentation and testing obligations
-Each accepted helper requires all of the following before its child issue
-closes.
+Each accepted helper requires all of the following before its roadmap task in
+the owning child RFC's step is complete. The obligations attach to the helper,
+not to the child RFC document: a child RFC states each helper's contract and
+enumerates these obligations, while the checklist that tracks them is the
+roadmap task the helper is scheduled under.
1. An entry in `docs/stdlib-yaml-and-jinja-guide.md` giving its signature,
purity label, prose contract, edge cases, and an example, in the format the
@@ -650,7 +672,7 @@ are optional keyword arguments; arguments shown without one are required.
### 8.1. Structured data interchange
-All six helpers in this group are pure. They exist so that manifests can
+All five helpers in this group are pure. They exist so that manifests can
consume compiler metadata, package manifests, and generated configuration
fragments without a subprocess.
@@ -723,9 +745,9 @@ Deterministically serializes a native value as block-style YAML.
timestamp, or an empty value, and whenever it has leading or trailing
whitespace. This explicitly covers the YAML 1.1 spellings `yes`, `no`, `on`,
`off`, `y`, and `n`, so the Norway problem cannot reach a generated file.
-- Round trip: `value | to_yaml | from_yaml` returns a value equal to `value`
- under section 6.7 canonical equality, for every value expressible in YAML.
- This is a property test.
+- Round trip: for values in the section 6.7 canonical-JSON domain that are
+ expressible in YAML, `value | to_yaml | from_yaml` returns a value equal to
+ `value` under section 6.7 canonical equality. This is a property test.
- Undefined input is an error.
#### `value | to_nice_json(indent=2, sort_keys=false)`
@@ -739,8 +761,19 @@ Pretty-prints JSON. MiniJinja's `tojson` remains the compact serializer; no
- Output uses LF line endings and does **not** end with a trailing newline, so
the result composes inside a larger document.
- Integer and boolean mapping keys are rendered in their canonical string
- form. Other key kinds are rejected rather than coerced.
-- Round trip: `value | to_nice_json | from_json` returns an equal value.
+ form. Other key kinds are rejected rather than coerced. This conversion is
+ lossy: `from_json` reads the rendered key as a string, so the original key
+ type is not preserved.
+- Distinct source keys that render to the same string are rejected with
+ `duplicate_key`. For example, a mapping containing integer key `1` and string
+ key `"1"`, or boolean key `true` and string key `"true"`, is rejected.
+- Round trip: for values in the section 6.7 canonical-JSON domain,
+ `value | to_nice_json | from_json` returns a value equal to `value`. Inputs
+ with converted integer or boolean keys are outside this guarantee, including
+ such mappings nested in sequences.
+
+The normative amendment and its acceptance cases are recorded in
+[RFC 0013](0013-structured-data-interchange-helpers.md).
### 8.2. Mapping and sequence transforms
@@ -1099,10 +1132,12 @@ the existing `basename` and `dirname` filters, which keep their present
host-native behaviour when it is omitted, so no shipped manifest changes
meaning.
-Every helper in this group is **pure and lexical**. None touches the
-filesystem, none resolves symbolic links, and none grants authority. Normalizing
-`../../etc/passwd` produces text, not access; containment remains the
-capability layer's responsibility, per section 6.4.
+Every helper in this group except `expandvars` is **pure and lexical**. None
+touches the filesystem, none resolves symbolic links, and none grants
+authority. Normalizing `../../etc/passwd` produces text, not access;
+containment remains the capability layer's responsibility, per section 6.4.
+`expandvars` is the one environment-observing helper in this RFC, and section
+14.7 separates it from the lexical helpers for that reason.
#### `parts | path_join(dialect='host')`
@@ -1883,7 +1918,7 @@ re-verifies rather than assuming.
## 14. Delivery slices and sequencing
This RFC is deliberately not one implementation change. Each slice below
-becomes a focused child issue that can ship, be reviewed, and be reverted
+becomes a focused child RFC that can ship, be reviewed, and be reverted
independently, and each carries the full cross-cutting contract from section 6
rather than saying only "match Ansible".
@@ -2034,6 +2069,51 @@ Every slice must satisfy all of the following before it merges:
- green `make check-fmt`, `make lint`, `make doc-coverage`, `make test`,
`make markdownlint`, and `make nixie` on the merge commit.
+### 14.13. Coverage map
+
+Each row below allocates one or more capability groups to one focused child
+RFC, which carries the full cross-cutting contract from section 6 for the
+helpers it owns. The map's boundaries are the roadmap's rather than section
+8's, and the two do not coincide in either direction: rows `0015` and `0016`
+each own a pair of whole groups, because one roadmap step delivers both, while
+rows `0017` and `0018` divide two groups between them, because section 8.6 and
+section 8.7 sit on opposite sides of the pure/observing boundary. The rows
+partition the accepted set: every helper section 8 specifies — including
+`basename`, `dirname`, and `glob`, which gain an option rather than being
+introduced — appears in exactly one row, and no row claims a helper section 8
+does not specify. Delivery is tracked by the roadmap step each row names, per
+roadmap task 6.1.1.
+
+The `Owns` column is a grammar rather than a list, so the allocation cannot
+drift from section 8 as it is edited:
+
+- `` `8.6` `` claims every helper section 8.6 specifies;
+- `` `8.6` except `expandvars` `` claims all of them but the named one;
+- `` `8.7` only `abs` `` claims the named one alone; and
+- clauses separated by `;` are unioned.
+
+The `Status` column reads `unwritten` while the child RFC does not exist and
+`written` once it does, and the `Child RFC` cell — not `Status` — carries the
+relative link to it. A repository test asserts that the two agree, that the
+rows partition the accepted set, that no rejected or deferred name reaches a
+child registry, and that no helper in this map is missing from the roadmap step
+that owns it. The convention and its amendment procedure are recorded in
+[ADR-040](../adr-040-focused-child-rfcs-for-survey-rfcs.md).
+
+| Child RFC | Title | Owns | Optioned | Roadmap step | Status |
+| --------------------------------------------------- | ----------------------------------------------- | ------------------------------------------- | --------------------- | ------------ | --------- |
+| [0013](0013-structured-data-interchange-helpers.md) | Structured data interchange helpers | `8.1` | — | 6.2 | written |
+| `0014` | Mapping and sequence transform helpers | `8.2` | — | 6.3 | unwritten |
+| `0015` | Ordered collection algebra and truth predicates | `8.3`; `8.8` | — | 6.4 | unwritten |
+| `0016` | Pattern and version predicates | `8.4`; `8.5` | — | 6.5 | unwritten |
+| `0017` | Lexical path composition | `8.6` except `expandvars`; `8.7` only `abs` | `basename`; `dirname` | 6.6 | unwritten |
+| `0018` | Host-state predicates and environment expansion | `8.7` except `abs`; `8.6` only `expandvars` | `glob` | 6.7 | unwritten |
+| `0019` | Encoding, identity, and formatting helpers | `8.9` | — | 6.8 | unwritten |
+| `0020` | Date and time conversion helpers | `8.10` | — | 6.9 | unwritten |
+
+_Table 16: Allocation of the accepted set to focused child RFCs and roadmap
+steps._
+
## 15. Alternatives considered
### 15.1. Adopt the Ansible surface wholesale, names and all
@@ -2117,12 +2197,14 @@ Windows host needs when generating paths for a Unix target.
`strftime` are pure. Roadmap item 7.1.1 supplied it: `now()` reads through a
`ClockProvider` held by `StdlibConfig`, classified in the
[ADR-008](../adr-008-environment-seam-taxonomy.md) addendum for 2026-09-11.
+ The seam is answered there rather than here, and RFC 0020 — which owns
+ `to_datetime` and `strftime` — neither needs it nor depends on 7.1.1.
## 17. Recommendation
Adopt this RFC as the specification for Netsuke's Ansible-inspired
standard-library expansion, and schedule the slices in section 14 as focused
-child issues after v0.1.0 final.
+child RFCs after v0.1.0 final.
The case for adopting rather than deferring is that the gaps in section 2 are
not stylistic. Each one currently resolves to `shell()`, and each such
diff --git a/docs/rfcs/0007-netsukefile-testing-framework.md b/docs/rfcs/0007-netsukefile-testing-framework.md
index b095edf7c..39957e1e8 100644
--- a/docs/rfcs/0007-netsukefile-testing-framework.md
+++ b/docs/rfcs/0007-netsukefile-testing-framework.md
@@ -59,10 +59,11 @@ parallel one; the technical design records the consequences.
What is missing: a mechanism to substitute manifest macros, any test dialect,
discovery, mock engine, fixture lifecycle, or `test` subcommand. (The clock
seam for `now()` that this section originally listed was supplied by roadmap
-item 7.1.1; the [technical design](netsuke-test-framework-technical-design.md)
-§5.2 records the implemented shape.) The manifest schema rejects unknown
-top-level keys, so the proposed `tests` configuration block is a schema
-addition with compatibility consequences (see below).
+item 7.1.1; the
+[technical design](../netsuke-test-framework-technical-design.md) §5.2 records
+the implemented shape.) 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
diff --git a/docs/rfcs/0013-structured-data-interchange-helpers.md b/docs/rfcs/0013-structured-data-interchange-helpers.md
new file mode 100644
index 000000000..a75cb0325
--- /dev/null
+++ b/docs/rfcs/0013-structured-data-interchange-helpers.md
@@ -0,0 +1,470 @@
+# RFC 0013: Structured data interchange helpers
+
+## Preamble
+
+- **RFC number:** 0013
+- **Amends:** RFC 0006, sections 6.7 and 8.1
+- **Status:** Proposed
+- **Created:** 2026-09-24
+- **Parent RFC:** [RFC 0006, Ansible-inspired template standard-library
+ expansion](0006-ansible-inspired-template-standard-library.md)
+- **Roadmap step:** 6.2
+- **Originating issue:** [#596](https://github.com/leynos/netsuke/issues/596)
+ (closed)
+- **Release target:** v0.1.x or later; must not widen the v0.1.0 hardening
+ release defined by [#594](https://github.com/leynos/netsuke/issues/594)
+
+## 1. Summary
+
+This RFC specifies the structured data interchange group: `from_json`,
+`from_yaml`, `from_yaml_all`, `to_yaml`, and `to_nice_json`. Together they let
+a manifest read the metadata `cargo metadata` emits, a compiler's JSON output,
+a YAML package manifest, or a generated configuration fragment, and write a
+fragment back, without invoking `jq`, `yq`, or a scripting runtime. All five
+are pure, so all five are available to manifest queries as well as to target
+recipes, and none needs a capability handle. RFC 0006 section 14.2 defines this
+group as slice 1, and section 14.11's recommended first wave places slice 0
+ahead of it, because the remaining groups read their inputs in the forms it
+produces.
+
+## 2. Problem
+
+A manifest that needs one field from a compiler's JSON metadata has, today, one
+route: `shell()` out to `jq`, `yq`, Python, or Ruby and capture the output. RFC
+0006 section 2 records the cost. Each such call converts a pure, cacheable,
+capability-free planning expression into a subprocess with ambient authority,
+imports a host dependency that the build description never declared, and adds
+an escaping surface between the tool's output and the template that consumes
+it. The same applies in reverse: a manifest that generates a configuration
+fragment has no way to emit it but to interpolate text with no guarantee about
+quoting, so a value reading `no` can silently become a YAML boolean.
+
+The contortion is not incidental to build manifests; it is the ordinary case.
+Compiler metadata is JSON, package manifests and generated configuration are
+YAML in most of the ecosystems Netsuke targets, and Rust's own `cargo metadata`
+is JSON. RFC 0006 section 15.2 considers and rejects adding nothing here, on
+the grounds that it leaves `netsuke help targets` unable to answer questions it
+should be able to answer purely.
+
+This group addresses the JSON and YAML halves. A TOML package manifest is
+outside it: RFC 0006 accepts no TOML parser, and Cargo metadata is available as
+JSON without one, so a manifest that needs it reads
+`cargo metadata --format-version 1` through `fetch` rather than parsing
+`Cargo.toml` directly.
+
+## 3. Goals and non-goals
+
+- Goals:
+ - Read one JSON document into native MiniJinja values, preserving object
+ order, and reject a duplicate key rather than let the last value win.
+ - Read one YAML document, or a multi-document stream materialized in full,
+ over the `serde-saphyr` stack [ADR-001](../adr-001-replace-serde-yml-with-serde-saphyr.md)
+ already adopts.
+ - Serialize deterministically to block-style YAML and to pretty-printed JSON,
+ with string scalars quoted so they cannot be read back as another type.
+ - Test both round trips under RFC 0006 section 6.7 canonical equality for
+ values in its canonical-JSON domain, rather than relying on examples.
+- Non-goals:
+ - `to_json`: rejected in RFC 0006 section 7 as redundant with MiniJinja's
+ `tojson`, which remains the compact serializer.
+ - `to_nice_yaml`: rejected in RFC 0006 section 10.2 as differing from
+ `to_yaml` only in a default indent. Whether that rejection is expressed as
+ outright absence or as a diagnostic-raising registration is carried as open
+ question 1.
+ - `from_ini`, `from_csv`, and every other parser for a format no consumer has
+ named. RFC 0006 section 9 defers by evidence bar, and none of these has one.
+ - Ansible's loader behaviour: unsafe strings, vault tags, and the order-
+ dependent duplicate-key tolerance RFC 0006 section 13.3 lists.
+ - Merge keys (`<<`), which are ambiguous with `combine`'s explicit list policy
+ and recursion flag, and are rejected rather than merged.
+ - A lazy iterator over a YAML stream. `from_yaml_all` materializes.
+
+## 4. Capability set
+
+Five helpers, all filters, all pure, all newly registered.
+
+- `from_json` — parse one JSON document into native values. Contract in
+ [RFC 0006 section 8.1](0006-ansible-inspired-template-standard-library.md#81-structured-data-interchange).
+- `from_yaml` — parse exactly one YAML document into native values. Contract in
+ the same subsection.
+- `from_yaml_all` — parse a multi-document YAML stream into a materialized
+ sequence. Contract in the same subsection.
+- `to_yaml` — serialize a value as deterministic block-style YAML. Contract in
+ the same subsection.
+- `to_nice_json` — pretty-print a value as JSON. Contract in the same
+ subsection.
+
+This section does not restate any contract. Each helper's kinds, options,
+rejection conditions, and bounds are specified in RFC 0006 section 8.1, and
+what follows in section 5 is how this group meets the cross-cutting clauses
+rather than what the helpers do.
+
+## 5. Cross-cutting contract conformance
+
+This section discharges RFC 0006 section 6 for the five helpers section 4
+lists. Where a clause's consequence follows from RFC 0006 alone and is the same
+for every group, the subsection says so in one line; where this group forces a
+decision, the subsection states the decision.
+
+### 5.1. Registry
+
+| Helper | Namespace | Registration | Purity class | Manifest query |
+| --------------- | --------- | ------------ | ------------ | -------------- |
+| `from_json` | Filter | New | Pure | Yes |
+| `from_yaml` | Filter | New | Pure | Yes |
+| `from_yaml_all` | Filter | New | Pure | Yes |
+| `to_yaml` | Filter | New | Pure | Yes |
+| `to_nice_json` | Filter | New | Pure | Yes |
+
+All five rows are `New`: this group introduces five helpers and extends none.
+All five are pure, so all five fall inside RFC 0006 section 6.1's 52, and this
+RFC accounts for 5 of them.
+
+### 5.2. Manifest-query availability
+
+All five are pure, so `from_json`, `from_yaml`, `from_yaml_all`, `to_yaml`, and
+`to_nice_json` all register in `register_query_helpers`, and none registers in
+`register_disabled_query_helpers` as a stub. The consequence is that
+`netsuke help targets` gains five working helpers and no new always-failing
+stub. One caveat, carried rather than resolved here: if RFC 0006 section 16
+question 1 is answered by registering `to_nice_yaml` solely to raise a
+diagnostic naming `to_yaml(indent=4)`, that registration introduces no accepted
+helper and so is not a section 5.1 row; it is decided in section 8.
+
+### 5.3. Determinism
+
+Two group-specific obligations follow from clause 6.3, and both are testable
+without reading prose.
+
+- **Key order is an output, not a side effect.** `from_json` preserves object
+ order because `serde_json` is built with `preserve_order`; `from_yaml`
+ preserves mapping order; `to_yaml` and `to_nice_json` emit insertion order
+ unless `sort_keys=true`. A round trip therefore returns a mapping in the
+ order it went in, and the tests assert order, not merely equality.
+- **Trailing-newline behaviour is a contract, not a convention.** `to_yaml`
+ ends with exactly one trailing newline and `to_nice_json` with none, so one
+ composes as a whole document and the other composes inside a larger one. The
+ asymmetry is deliberate and is pinned by a test at each end.
+
+### 5.4. Capability boundary
+
+No additional obligation beyond RFC 0006 section 6.4. The clause's substantive
+rules all address helpers that reach outside their arguments, and no helper in
+this group does: none takes a `cap_std` handle, none takes an injected reader,
+and none can violate the clause's trapdoor rule about a filesystem predicate
+reporting `false` for an out-of-scope path.
+
+### 5.5. Platform contract
+
+No additional obligation beyond RFC 0006 section 6.5. The clause's obligations
+attach to helpers whose behaviour varies by platform or that parse another
+platform's syntax: no helper here takes a `dialect` argument, none can fail
+with a platform diagnostic, and all five emit LF on every platform.
+
+### 5.6. Type and error contract
+
+RFC 0006 section 8.1 specifies the kinds each helper accepts. What follows is
+the conditions under which a kind, a key, or an option value is rejected, each
+carrying a code from section 5.9. Per helper, because the three parsing helpers
+and the two serializers do not share a rejection set.
+
+- `from_json` accepts a string. It rejects `wrong_kind`, `syntax`,
+ `duplicate_key`, `depth_exceeded`, and `length_exceeded`.
+- `from_yaml` accepts a string. It rejects every `from_json` condition, and adds
+ `unsupported_key` for a sequence or mapping key, `special_tag`, `merge_key`,
+ `alias_budget`, and `document_count` for a stream that is not exactly one
+ document.
+- `from_yaml_all` accepts a string and applies every per-document condition from
+ `from_yaml` to each document. `document_count` is the one condition it does
+ **not** inherit: RFC 0006 section 8.1 makes a stream of zero documents an
+ empty sequence, not an error, and multi-document input is the whole point of
+ the helper. The input-length and node budgets apply to the whole stream
+ rather than to each document.
+- `to_yaml` accepts any value except undefined. It rejects `undefined_input`
+ and `indent_out_of_range` outright, plus `unsupported_key` when
+ `sort_keys=true` meets a mapping key with no canonical JSON form,
+ `unsupported_kind` for a value that has none, and `output_too_large` when the
+ rendered output would exceed the ceiling section 5.8 enforces.
+- `to_nice_json` accepts any value except undefined, and rejects the same five
+ conditions as `to_yaml`, with the difference that section 8.1 states its key
+ rule directly: integer and boolean keys are rendered in canonical string form
+ and every other key kind is rejected rather than coerced. It also rejects
+ distinct source keys that render to one JSON key, with `duplicate_key`.
+
+Three decisions this group adds:
+
+- **`none` is accepted; undefined is not.** Clause 6.6 makes undefined an error
+ and makes `none` a value. Every helper here follows that split, and the line
+ between them is where a manifest author is most likely to be surprised, so
+ each of the five documents it explicitly rather than leaving the clause to
+ imply it.
+- **A duplicate key is rejected with a position.** `from_json` and `from_yaml`
+ name the duplicated key and the offset of its second occurrence. This is the
+ one place the group adds detection the underlying parser does not perform,
+ and the reason is that last-key-wins is a silent data-loss trapdoor in a
+ build manifest rather than a tolerable convenience.
+- **The two `indent` ranges differ on purpose.** `to_yaml` accepts 1 to 8 and
+ `to_nice_json` accepts 0 to 8. Zero is meaningful for JSON, where it means
+ compact output, and meaningless for block YAML, where it would mean no
+ indentation at all. Both reject an unknown value with an error enumerating
+ the accepted range, satisfying clause 6.6's enumerated-option rule and
+ roadmap task 3.15.5.
+
+### 5.7. Canonical value equality
+
+Clause 6.7 governs serializer key sorting and canonical round-trip claims. The
+obligation here is to say which of the clause's exclusions this group can meet,
+and to define the domain of its round-trip guarantees. This RFC amends RFC 0006
+sections 6.7 and 8.1 to make that domain explicit.
+
+- `sort_keys=true` sorts mapping keys by their RFC 8785 canonical key, so two
+ mappings that differ only in insertion order serialize identically. That is
+ clause 6.7's relation applied to a key-ordering decision, and it is why the
+ option can promise deterministic output at all.
+- The clause excludes undefined, callables, and the `now()` timestamp object,
+ because none has a canonical JSON form. Undefined is already rejected on
+ input by clause 6.6; the other two are rejected on output as
+ `unsupported_kind`, because a manifest can hold a callable or the result of
+ `now()` and pass it to a serializer, and clause 6.7 makes that a typed error
+ naming the value kind rather than a silent rendering.
+- The group defines **no second equality relation** for round-trip testing.
+ Both `value | to_yaml | from_yaml` and `value | to_nice_json | from_json` are
+ asserted equal under clause 6.7 only for values in its canonical-JSON domain:
+ JSON-compatible values whose mapping keys are strings at every nesting level.
+ The serializer may accept other values, but their conversion does not promise
+ canonical equality.
+- **Integer and boolean JSON keys convert lossily.** A single integer key `1`
+ serializes as the JSON property `"1"`; a single boolean key `true` serializes
+ as `"true"`. `from_json` reads either as a string key, so neither input is
+ within the JSON round-trip guarantee. The same applies when a mapping with
+ such keys is nested inside a sequence. String-keyed mappings, including
+ nested mappings, remain within the guarantee.
+- **Rendered-key collisions remain errors.** A mapping containing integer key
+ `1` alongside string key `"1"`, or boolean key `true` alongside string key
+ `"true"`, is rejected with `duplicate_key`, naming both source keys. The
+ check is on rendered keys because that is where the collision occurs.
+
+The acceptance cases make the boundary concrete:
+
+| Input mapping | Result | Canonical round trip |
+| ------------------------------- | ----------------------------- | -------------------- |
+| `{"name": "value"}` | String key preserved | Equal |
+| `{"outer": {"inner": "value"}}` | Nested string keys preserved | Equal |
+| `{1: "value"}` | Key becomes `"1"` | Lossy; excluded |
+| `{true: "value"}` | Key becomes `"true"` | Lossy; excluded |
+| `[{1: "value"}]` | Nested key becomes `"1"` | Lossy; excluded |
+| `{1: "a", "1": "b"}` | Rejected with `duplicate_key` | Not serialized |
+| `{true: "a", "true": "b"}` | Rejected with `duplicate_key` | Not serialized |
+
+### 5.8. Resource bounds
+
+The bounds are RFC 0006 table 3's, applied through checked comparison before
+allocation. What this group adds is where each one is enforced, because the
+JSON and YAML parsers do not share a code path.
+
+| Helper | Bounds enforced |
+| --------------- | ---------------------------------------------------- |
+| `from_json` | input 8 MiB; nesting depth 128 |
+| `from_yaml` | input 8 MiB; depth 128; alias expansion 100000 nodes |
+| `from_yaml_all` | input 8 MiB; depth 128; alias expansion 100000 nodes |
+| | — the same three, applied to the stream as a whole |
+| `to_yaml` | output 8 MiB; checked before the result is returned |
+| `to_nice_json` | output 8 MiB; checked before the result is returned |
+
+Three consequences this group decides:
+
+- **`from_yaml_all`'s budget is stream-wide.** A stream of many small documents
+ is bounded in total, so a caller cannot evade the input ceiling by splitting
+ an expansion bomb across document boundaries. The diagnostic reports the
+ stream total rather than a per-document figure, which is what makes the bound
+ auditable after the fact.
+- **The alias budget is the one bound that can fail the implementation slice
+ rather than the input.** Clause 6.8 requires the expansion to be rejected
+ before allocating, and if `serde-saphyr` cannot bound alias expansion then
+ this RFC rejects aliases outright and records that in the guide, per RFC 0006
+ section 8.1 and roadmap task 6.2.2. That choice is carried as section 8's
+ open question 4 and is not resolved here.
+- **The serializers bound their output because a shared value is not a bounded
+ input.** An earlier draft of this group read "none" into both rows, on the
+ reasoning that a serializer allocates proportionally to nothing but the value
+ it is handed. That reasoning is wrong, and the runtime shows how: MiniJinja
+ values are reference-counted, so a value built by repeated doubling is a
+ graph whose *logical* size is exponential in its construction depth, while
+ its in-memory footprint stays linear. One recursive macro with twenty
+ doublings emits `{{ v | to_nice_json }}` as **4,194,301** bytes — 2× per
+ level, from a four-line template with no large input anywhere. Clause 6.8
+ names "materialized output" precisely for this and requires the rejection
+ *before* allocating, so counting bytes as they are written is not enough.
+ Each serializer counts first: a pass walks the value computing the output
+ length with checked arithmetic, abandoning the walk the moment the running
+ total passes the ceiling, so a doubled value stops after 8 MiB of *logical*
+ nodes instead of expanding. Only a value that fits is then written. The
+ failure is `output_too_large`, at the same 8 MiB ceiling the parsers apply to
+ input.
+
+### 5.9. Diagnostics and localization
+
+The group defines one private domain error enum, `InterchangeError`, and
+exactly one `impl From for minijinja::Error`, per clause 6.9.
+Every message is a Fluent key and every error carries a machine code. The codes
+are this group's contribution to clause 6.9's policy, so they are enumerated
+rather than described.
+
+| Condition | Code |
+| -------------- | -------------------------------------------------- |
+| not a string | `netsuke::jinja::interchange::wrong_kind` |
+| syntax | `netsuke::jinja::interchange::syntax` |
+| duplicate key | `netsuke::jinja::interchange::duplicate_key` |
+| key kind | `netsuke::jinja::interchange::unsupported_key` |
+| special tag | `netsuke::jinja::interchange::special_tag` |
+| merge key | `netsuke::jinja::interchange::merge_key` |
+| alias budget | `netsuke::jinja::interchange::alias_budget` |
+| document count | `netsuke::jinja::interchange::document_count` |
+| depth | `netsuke::jinja::interchange::depth_exceeded` |
+| length | `netsuke::jinja::interchange::length_exceeded` |
+| undefined | `netsuke::jinja::interchange::undefined_input` |
+| indent | `netsuke::jinja::interchange::indent_out_of_range` |
+| value kind | `netsuke::jinja::interchange::unsupported_kind` |
+| output length | `netsuke::jinja::interchange::output_too_large` |
+
+Each code's Fluent key is the code's reason in upper snake case under
+`STDLIB_INTERCHANGE_`, so `wrong_kind` pairs with
+`STDLIB_INTERCHANGE_WRONG_KIND` and `indent_out_of_range` with
+`STDLIB_INTERCHANGE_INDENT_OUT_OF_RANGE`, per clause 6.9's
+`keys::STDLIB__` form.
+
+The module segment is `interchange` rather than `json` or `yaml`, because one
+enum serves both parsers and both serializers and the code names the capability
+group, not the syntax. All five helpers — `from_json`, `from_yaml`,
+`from_yaml_all`, `to_yaml`, and `to_nice_json` — reach their errors through
+this enum: every `Error::new` call in the group's leaf functions is replaced by
+a variant of it, so a caller can tell an interchange failure from a manifest
+diagnostic by the code alone. Clause 6.9 rejects ad hoc construction at this
+scale, and a group with fourteen conditions is the case it names.
+
+### 5.10. Naming and alias policy
+
+No additional obligation beyond RFC 0006 section 6.10. The clause registers one
+name per capability and this group adds five, none an alias. The group does,
+however, put the clause under real pressure in a way no other group does, and a
+reviewer should see why the outcome is still five names rather than six or four.
+
+`to_json` is **rejected** by RFC 0006 section 10.2 as an alias of MiniJinja's
+existing `tojson`, while `to_nice_json` is **accepted** in section 8.1. The
+obvious reading — that `to_nice_json` is the pretty-printer for a helper this
+set does not add — is wrong, and the reason is worth stating once. `tojson` and
+`to_nice_json` differ in *kind*, not in degree: `tojson` is compact and
+whitespace-free by construction, and `to_nice_json(indent=…)` is the same
+serialization with a layout parameter `tojson` does not accept. A parameterless
+`to_json` would therefore be a true alias and is correctly rejected, whereas
+`to_nice_json` carries an argument surface that makes it a distinct capability.
+The near-miss is that `indent=0` *does* reproduce `tojson` exactly, so the two
+overlap at one point of that parameter space. That overlap is deliberate and
+harmless — it is what makes `indent=0` a usable "compact, but reachable through
+the Netsuke helper" path — but it is the reason clause 6.10's one-name-per-
+capability line cannot be read as one-*output*-per-capability.
+
+The clause's other live case is unsettled rather than resolved: `to_nice_yaml`
+is rejected by RFC 0006 section 10.2 as redundant with `to_yaml(indent=...)`,
+and whether that rejection is expressed as outright absence or as a
+diagnostic-raising registration is RFC 0006 section 16 question 1, carried
+unresolved to section 8 and decided at roadmap task 6.2.3. Note that the
+asymmetry with JSON is intended and not an inconsistency: `to_nice_yaml`'s
+redundancy is with `to_yaml`, a helper this standard library *already* adds, so
+the rejection costs a caller nothing it cannot already reach; `to_json`'s
+redundancy is with a MiniJinja builtin that is likewise already reachable.
+
+### 5.11. Documentation and testing obligations
+
+No additional obligation beyond RFC 0006 section 6.11. The clause's seven
+obligations apply unmodified. One group-specific note rather than a
+restatement: the two round-trip properties required by clause 6.11.4 use the
+canonical-JSON domain stated in section 5.7. The serializer's lossy integer and
+boolean key cases and collision errors are separate acceptance tests. The
+serialization-determinism property it names is the same proposition as section
+5.3's key-order requirement, tested from the other side.
+
+### Clause discharge
+
+| Clause | Discharge |
+| ------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `6.1` | Five pure `New` helpers; the five section 5.1 rows are 5 of 52. |
+| `6.2` | All five pure, so all register in `register_query_helpers`, none stubbed. |
+| `6.3` | Mapping order in and out; one trailing newline for `to_yaml`, none for `to_nice_json`. |
+| `6.4` | No filesystem, environment, or subprocess access; no handle taken. |
+| `6.5` | No `dialect` argument; all five emit LF everywhere. |
+| `6.6` | Undefined rejected, `none` accepted; duplicates rejected positionally; both `indent` ranges enumerated. |
+| `6.7` | `sort_keys` sorts by canonical key; round trips use the canonical-JSON domain; converted JSON keys are lossy and excluded. |
+| `6.8` | Table 3's input and depth bounds, stream-wide for `from_yaml_all`, plus the alias budget; both serializers pre-check their output length. |
+| `6.9` | One enum, one `From` impl, fourteen `netsuke::jinja::interchange::*` codes. |
+| `6.10` | Five new names, no alias family, none reused across namespaces. |
+| `6.11` | The clause's seven obligations, plus the two round trips and the determinism property. |
+
+## 6. Dependencies
+
+No new crate. This group is the one RFC 0006 section 13.4 adds nothing for: it
+uses `serde_json` with `preserve_order` for the JSON half and the existing
+`serde-saphyr` stack for the YAML half, both of which Netsuke already carries,
+plus `serde_json_canonicalizer` for `sort_keys=true`'s canonical key. Adding no
+dependency is one reason the group can lead the wave.
+
+Within the RFC set it requires the shared contract RFC 0006 section 14.1's
+"slice 0" describes, which roadmap steps 6.1.3 and 6.1.4 deliver: the
+bounded-parser helper the two parsers share. Sections 14.2, 14.3, 14.4 and 14.9
+are the four that state a slice 0 requirement; sections 14.5 to 14.8 and 14.10
+state none. It requires no other child RFC, and no other child RFC requires it.
+
+## 7. Delivery
+
+Roadmap step 6.2, which implements this RFC in three tasks:
+
+- 6.2.1. `from_json`, with duplicate-key rejection and source offsets.
+- 6.2.2. `from_yaml` and `from_yaml_all`, over the existing safe YAML stack.
+- 6.2.3. `to_yaml` and `to_nice_json`, with pinned output.
+
+Each task carries the acceptance criteria that make this RFC checkable: the
+duplicate-key diagnostic naming the key and its second offset, the
+alias-expansion bomb failing with a bounded-resource diagnostic rather than
+exhausting memory, and round-trip property tests over the canonical-JSON domain
+alongside the YAML 1.1 `yes`, `no`, `on`, and `off` spellings being unable to
+reach a generated file unquoted.
+
+## 8. Open questions
+
+Two of RFC 0006 section 16's questions are assigned to this group and are
+carried unresolved rather than answered here.
+
+- **Question 1: is rejecting `to_nice_yaml` correct?** RFC 0006 section 10.2
+ rejects it as redundant with `to_yaml(indent=4)`. The counter-argument is
+ discoverability: an Ansible-literate author will reach for the name and
+ MiniJinja's "unknown filter" error will not help them. The intermediate
+ option is to register it solely to raise a typed diagnostic naming
+ `to_yaml(indent=4)`. This RFC does not settle it; roadmap task 6.2.3 does,
+ before the serializer registers. Section 5.2 records the consequence either
+ way: the diagnostic-raising registration introduces no accepted helper, so it
+ is not a section 5.1 row.
+- **Question 4: can `serde-saphyr` bound alias expansion?** RFC 0006 section
+ 8.1 requires either a bounded expansion budget or outright rejection of
+ aliases. Which applies is a fact about the dependency and must be established
+ during the implementation slice, not assumed here. Section 5.8 records both
+ outcomes as obligations; roadmap task 6.2.2 establishes which holds and
+ records it in the standard-library guide.
+
+Both questions were open when RFC 0006 was written and neither is a defect in
+it: each names work the implementing slice must do, and each is answered by a
+roadmap task rather than by a child RFC.
+
+## 9. Recommendation
+
+This group should be implemented first, at v0.1.x or later. RFC 0006 section
+14.11's recommended first wave names slice 0 ahead of it and places `from_json`
+sixth of its seven entries; this group leads the wave because it is the only
+one whose prerequisite is slice 0 alone, it adds no crate, and it removes the
+most common reason a manifest reaches for `shell()` at all. The group's five
+helpers are also the ones whose absence is least defensible in a build tool:
+reading a JSON field is not a template language feature request, it is the
+minimum needed to consume the metadata that compilers and package managers
+already emit. Implementing it first also exercises the whole contract at its
+smallest: five pure filters with no capability handle, no platform variation,
+and no dialect, which is exactly the shape that shows whether the cross-cutting
+clauses in section 5 carry content or merely restate RFC 0006.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 9d982c283..7bb73ae3d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -835,14 +835,21 @@ boundary, can carry every later helper, or whether each capability group needs
its own. Its outcome decides whether steps 6.2 to 6.9 can be reviewed as
ordinary additions or need individual design passes. See RFC 0006 §§6 and 14.1.
-- [ ] 6.1.1. Split the RFC 0006 accepted set into focused child issues.
- - See RFC 0006 §14.
- - Give each issue the full cross-cutting contract from RFC 0006 §6 rather
- than a reference to Ansible.
- - Record a release target of v0.1.x or later for each issue.
+- [ ] 6.1.1. Split the RFC 0006 accepted set into focused child RFCs and
+ accompanying roadmap tasks.
+ - See RFC 0006 §14 and
+ [the execplan](execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md).
+ - Give each RFC the full cross-cutting contract from RFC 0006 §6 rather
+ than a reference to Ansible. Discharging each clause for the RFC's own
+ helpers satisfies this; citing §6 without discharging it does not.
+ - Record a release target of v0.1.x or later for each RFC.
+ - Track delivery through the roadmap tasks in steps 6.2 to 6.9 rather than
+ through separate issues, so progress stays in committed documentation.
- Success: every accepted capability in RFC 0006 §7 is covered by exactly
- one open child issue, and every deferred or rejected candidate is covered
- by none.
+ one child RFC and by at least one accompanying roadmap task, and every
+ deferred or rejected candidate is covered by neither. A capability may be
+ named by more than one roadmap task, as `product` already is by 6.4.2 and
+ 6.4.5, so only the RFC count is exactly one.
- [ ] 6.1.2. Implement the canonical value key and equality relation.
- See RFC 0006 §6.7.
- Derive the key with the existing `serde_json_canonicalizer` dependency and
@@ -912,19 +919,22 @@ This step answers whether a manifest can consume compiler metadata, package
manifests, and generated configuration fragments directly, or whether `jq`,
`yq`, and a scripting runtime remain unavoidable host assumptions. Its outcome
determines how much surrounding toolchain a `Netsukefile` still has to assume.
-See RFC 0006 §8.1 and
+See RFC 0006 §8.1, delivered by
+[RFC 0013](rfcs/0013-structured-data-interchange-helpers.md), and
[adr-001-replace-serde-yml-with-serde-saphyr.md](adr-001-replace-serde-yml-with-serde-saphyr.md).
- [ ] 6.2.1. Add `from_json` with duplicate-key rejection and source offsets.
Requires 6.1.3 and 6.1.4.
- - See RFC 0006 §8.1.
+ - See RFC 0006 §8.1 and
+ [RFC 0013](rfcs/0013-structured-data-interchange-helpers.md) §7.
- Preserve object order and report line, column, and byte offset on failure.
- Success: a document with a repeated object key fails naming the key and
the offset of its second occurrence, rather than silently keeping the last
value.
- [ ] 6.2.2. Add `from_yaml` and `from_yaml_all` over the existing safe YAML
stack. Requires 6.2.1.
- - See RFC 0006 §8.1.
+ - See RFC 0006 §8.1 and
+ [RFC 0013](rfcs/0013-structured-data-interchange-helpers.md) §§5.8 and 7.
- Reject non-standard tags, duplicate keys, and merge keys, and materialize
the multi-document result rather than exposing a lazy iterator.
- Establish whether `serde-saphyr` can bound alias expansion; if it cannot,
@@ -933,7 +943,9 @@ See RFC 0006 §8.1 and
instead of exhausting memory.
- [ ] 6.2.3. Add the deterministic `to_yaml` and `to_nice_json` serializers.
Requires 6.2.2.
- - See RFC 0006 §§6.3 and 8.1.
+ - See RFC 0006 §§6.3 and 8.1, and
+ [RFC 0013](rfcs/0013-structured-data-interchange-helpers.md) §§5.3, 5.7, and
+ 8.
- Pin key ordering, indentation, scalar quoting, line endings, and
trailing-newline behaviour, quoting every scalar that could be read back
as a boolean, null, number, or timestamp.
diff --git a/tests/makefile_test_target.rs b/tests/makefile_test_target.rs
index f0af6d511..d40c663d6 100644
--- a/tests/makefile_test_target.rs
+++ b/tests/makefile_test_target.rs
@@ -37,7 +37,8 @@ use toml::Value;
/// runs on its own because that suite needs a per-user systemd manager rather
/// than the whole workspace. `test-kani-mutations` runs the same runner over
/// the `#[ignore]`-gated mutation compile gate, which is too expensive for the
-/// default profile. A contributor sets the bounds once and expects them
+/// default profile. `test-rfc-stdlib-coverage` runs the RFC parser and coverage
+/// contract in isolation. A contributor sets the bounds once and expects them
/// honoured wherever nextest runs, so the list is a contract rather than a
/// note of what happens to be true today.
///
@@ -45,10 +46,11 @@ use toml::Value;
/// [`behavioural_nextest_targets_forward_both_worker_bounds`] discovers the
/// targets that actually invoke the runner and fails when the two disagree, so
/// a new recipe joins the contract or breaks the build.
-const NEXTEST_TARGETS: [&str; 3] = [
+const NEXTEST_TARGETS: [&str; 4] = [
"test-kani-mutations",
"test-kani-scope-wrapper",
"test-nextest",
+ "test-rfc-stdlib-coverage",
];
/// True when `line` is a tab-indented recipe line that invokes the nextest
diff --git a/tests/rfc_stdlib_coverage/assertions.rs b/tests/rfc_stdlib_coverage/assertions.rs
new file mode 100644
index 000000000..8f5ffac19
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/assertions.rs
@@ -0,0 +1,158 @@
+//! Cross-checks between the derived sets and the words RFC 0006 states them in.
+//!
+//! These are part of the derivation rather than a separate check, because every
+//! one of them compares two places the RFC states the same thing: the row counts
+//! against table 11, the class counts against the reject-row count, the
+//! new-filter and new-test counts against the tables, and the deny set against
+//! the non-vacuity witnesses. A parse that silently returns nothing must fail
+//! here.
+//!
+//! One comparison in this module is not of that shape. The deny set's size is
+//! checked against the literal 71, because the complement rule that produces the
+//! deny set has no second statement in the document to read a figure from: the
+//! rule is this plan's decision `D10`, and 71 is what it yields over RFC 0006's
+//! own reject and defer rows. That literal is the assertion. It is not a
+//! transcription of a sentence somewhere that a rebase could leave behind, so
+//! it is deliberately not moved into the table-11 reads above with the counts
+//! that are.
+
+use anyhow::{Result, ensure};
+
+use super::{Namespace, name_set, survey::Survey};
+
+/// Assert the derived totals agree with what RFC 0006 states.
+pub(super) fn check_against_document(survey: &Survey) -> Result<()> {
+ // The accept and defer counts are compared against table 11's own rows
+ // rather than against transcribed literals, because the table is what the
+ // comparison is between: a literal here would make this an assertion about
+ // a constant, and would have to be edited by hand every time the survey
+ // legitimately grew. Table 11 is expected to agree with section 7, and if
+ // it stops agreeing that is the finding, not a reason to re-read the table.
+ ensure!(
+ survey.accept_rows == survey.stated_accept,
+ "derived {} accept rows in RFC 0006 section 7; table 11 states {}",
+ survey.accept_rows,
+ survey.stated_accept
+ );
+ ensure!(
+ survey.defer_rows == survey.stated_defer,
+ "derived {} defer rows in RFC 0006 section 7; table 11 states {}",
+ survey.defer_rows,
+ survey.stated_defer
+ );
+ // The reject count is the one row count with no table 11 row of its own:
+ // table 11 splits rejections across three classes, so the sum of those three
+ // rows is what section 7's reject count has to equal. Both sides of the
+ // comparison are read from the document, which is why this needs no
+ // literal — and it is also why the message names both figures rather than
+ // asserting the equality of one of them to a constant.
+ let class_sum: usize = survey.reject_classes.iter().sum();
+ ensure!(
+ survey.reject_rows == class_sum,
+ "derived {} reject rows in RFC 0006 section 7; table 11's classes {:?} sum to {class_sum}",
+ survey.reject_rows,
+ survey.reject_classes
+ );
+ ensure!(
+ survey.new_by_namespace(Namespace::Filter) == survey.new_filters,
+ "derived {} new filters; table 11 states {}",
+ survey.new_by_namespace(Namespace::Filter),
+ survey.new_filters
+ );
+ ensure!(
+ survey.new_by_namespace(Namespace::Test) == survey.new_tests,
+ "derived {} new tests; table 11 states {}",
+ survey.new_by_namespace(Namespace::Test),
+ survey.new_tests
+ );
+ ensure!(
+ survey.new_rows.len() == survey.proposed,
+ "derived {} new helpers; section 6.1 states {}",
+ survey.new_rows.len(),
+ survey.proposed
+ );
+ ensure!(
+ survey.optioned.len() == survey.optioned_total,
+ "derived {} existing helpers gaining an option; table 11 states {}",
+ survey.optioned.len(),
+ survey.optioned_total
+ );
+ ensure!(
+ survey.accepted.len() == survey.new_rows.len() + survey.optioned.len(),
+ "the accepted set has {} members, but {} new plus {} optioned is {}",
+ survey.accepted.len(),
+ survey.new_rows.len(),
+ survey.optioned.len(),
+ survey.new_rows.len() + survey.optioned.len()
+ );
+ Ok(())
+}
+
+/// Assert the deny set is what the complement rule says it is.
+pub(super) fn check_denied(survey: &Survey) -> Result<()> {
+ ensure!(
+ survey.denied.len() == 71,
+ "derived {} forbidden names; the complement rule gives 71",
+ survey.denied.len()
+ );
+ let must_deny = [
+ "is_file",
+ "is_dir",
+ "is_link",
+ "quote",
+ "fileglob",
+ "lookup",
+ "win_dirname",
+ "expanduser",
+ ];
+ let missing = name_set(must_deny)
+ .difference(&survey.denied)
+ .cloned()
+ .collect::>();
+ ensure!(
+ missing.is_empty(),
+ "the deny set omits {missing:?}, which section 7 or section 9 forbids"
+ );
+ let must_allow = [
+ "basename",
+ "dirname",
+ "abs",
+ "glob",
+ "shell_quote",
+ "splitdrive",
+ "text_hash",
+ ];
+ let wrongly_denied = must_allow
+ .iter()
+ .filter(|name| survey.denied.contains(**name))
+ .collect::>();
+ ensure!(
+ wrongly_denied.is_empty(),
+ "the deny set forbids {wrongly_denied:?}, which are accepted helpers the registries must \
+ carry"
+ );
+ // Both sets, not just `accepted`: `hash` is a *surveyed* spelling whose
+ // registered name is `text_hash`, so it is not in the accepted set by
+ // construction. What the message below claims is that the surveyed spelling
+ // reached neither set, and a future reject row naming `hash` would put it in
+ // `denied` — which is the case a check on `accepted` alone cannot see.
+ let hash_sets = [
+ (survey.accepted.contains_key("hash"), "the accepted set"),
+ (survey.denied.contains("hash"), "the deny set"),
+ ];
+ let present = hash_sets
+ .iter()
+ .filter_map(|(present, name)| present.then_some(*name))
+ .collect::>();
+ ensure!(
+ present.is_empty(),
+ "`hash` is an existing Netsuke helper that RFC 0006 leaves unchanged, so it must be \
+ neither accepted nor denied; it is in {}",
+ present.join(" and ")
+ );
+ Ok(())
+}
+
+#[cfg(test)]
+#[path = "assertions_tests.rs"]
+mod tests;
diff --git a/tests/rfc_stdlib_coverage/assertions_tests.rs b/tests/rfc_stdlib_coverage/assertions_tests.rs
new file mode 100644
index 000000000..6ee69a002
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/assertions_tests.rs
@@ -0,0 +1,85 @@
+//! Exercise each document-total and deny-set assertion independently.
+
+use super::*;
+use crate::rfc_stdlib_coverage::validation_fixtures as fixture;
+use rstest::rstest;
+
+#[test]
+fn accepts_consistent_totals_and_complement() -> Result<()> {
+ check_against_document(&fixture::survey())?;
+ check_denied(&fixture::survey())
+}
+
+#[rstest]
+#[case::accept("accept", "derived 1 accept rows")]
+#[case::defer("defer", "derived 0 defer rows")]
+#[case::reject("reject", "table 11's classes [1, 0, 0] sum to 1")]
+#[case::filters("filters", "derived 1 new filters")]
+#[case::tests("tests", "derived 0 new tests")]
+#[case::proposed("proposed", "derived 1 new helpers")]
+#[case::optioned("optioned", "derived 0 existing helpers gaining an option")]
+#[case::accepted("accepted", "accepted set has 0 members")]
+fn rejects_inconsistent_document_totals(#[case] mutation: &str, #[case] diagnostic: &str) {
+ let mut survey = fixture::survey();
+ match mutation {
+ "accept" => survey.stated_accept += 1,
+ "defer" => survey.stated_defer += 1,
+ "reject" => {
+ *survey
+ .reject_classes
+ .first_mut()
+ .expect("fixture reject class") += 1;
+ }
+ "filters" => survey.new_filters += 1,
+ "tests" => survey.new_tests += 1,
+ "proposed" => survey.proposed += 1,
+ "optioned" => survey.optioned_total += 1,
+ "accepted" => survey.accepted.clear(),
+ _ => {}
+ }
+ let error = check_against_document(&survey).expect_err("one total changed");
+ assert!(error.to_string().contains(diagnostic), "{error:#}");
+}
+
+#[rstest]
+#[case::count("count", "derived 70 forbidden names")]
+#[case::missing("missing", "deny set omits [\"is_file\"]")]
+#[case::accepted("accepted", "deny set forbids [\"basename\"]")]
+#[case::hash_accepted("hash_accepted", "it is in the accepted set")]
+#[case::hash_denied("hash_denied", "it is in the deny set")]
+#[case::hash_both("hash_both", "it is in the accepted set and the deny set")]
+fn rejects_invalid_complement(#[case] mutation: &str, #[case] diagnostic: &str) {
+ let mut survey = fixture::survey();
+ match mutation {
+ "count" => {
+ survey.denied.remove("denied0");
+ }
+ "missing" => {
+ survey.denied.remove("is_file");
+ survey.denied.insert("replacement".into());
+ }
+ "accepted" => {
+ survey.denied.remove("denied0");
+ survey.denied.insert("basename".into());
+ }
+ "hash_accepted" => {
+ survey
+ .accepted
+ .insert("hash".into(), fixture::helper("hash"));
+ }
+ "hash_denied" => {
+ survey.denied.remove("denied0");
+ survey.denied.insert("hash".into());
+ }
+ "hash_both" => {
+ survey
+ .accepted
+ .insert("hash".into(), fixture::helper("hash"));
+ survey.denied.remove("denied0");
+ survey.denied.insert("hash".into());
+ }
+ _ => {}
+ }
+ let error = check_denied(&survey).expect_err("one complement condition changed");
+ assert!(error.to_string().contains(diagnostic), "{error:#}");
+}
diff --git a/tests/rfc_stdlib_coverage/clauses.rs b/tests/rfc_stdlib_coverage/clauses.rs
new file mode 100644
index 000000000..8e46e2eb5
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/clauses.rs
@@ -0,0 +1,262 @@
+//! The cross-cutting contract clauses and their discharge in child RFCs.
+//!
+//! RFC 0006 section 6 is normative and says so: "A child RFC that does not
+//! satisfy every clause below for every helper it adds is not complete." The
+//! clause list is therefore derived from the document rather than transcribed —
+//! each `### 6.N.` subsection is a clause — and each child RFC must discharge
+//! every one of them.
+//!
+//! The discharge is recorded in a child's section 5 as a two-column table,
+//! `Clause | Discharge`, whose clause column holds the clause id in backticks.
+//! The set of ids must equal section 6's subsection ids exactly. That is the
+//! smallest contract that makes "discharges every clause" checkable without
+//! pretending a test can read prose, and it is deliberately not a subsection
+//! apiece: the eleven clause subsections record the group's own contract, and
+//! the table records how each clause is met.
+//!
+//! The third vacuity shape `CONF-1` names — a body that justifies a helper by
+//! appealing to Ansible — is a judgement about prose rather than about document
+//! structure, and it lives in `deference`, whose predicate this module calls
+//! through `super`.
+//!
+//! Split at that seam, and `deference` out of this file, to keep this module
+//! under Whitaker's 400-line `module_max_lines` ceiling; the judgement is a
+//! different kind of thing from the grading below, which is why the seam is
+//! there rather than at an arbitrary line count.
+
+use std::collections::BTreeSet;
+
+use anyhow::{Context, Result, ensure};
+
+use super::{RFC_0006, Repo, Section, backticked, deference::deference_phrase, strip_backticks};
+
+/// The heading of a child RFC's clause-discharge subsection.
+///
+/// Unnumbered on purpose. The table resolves all eleven clause subsections, and
+/// numbering it `5.6` collided with the clause's own subsection title, "Type and
+/// error contract" — the same title RFC 0006 clause 6.6 carries — leaving a
+/// reader to reconcile a subsection titled one way with a table row reading
+/// `6.6`. It closes section 5 as `### Clause discharge`.
+const CLAUSES_HEADING: &str = "### Clause discharge";
+
+/// The same heading as `tables()` reports it, without its `###`.
+///
+/// The parser resolves the table by the heading directly above it rather than
+/// taking the subsection's first table, so a child that places a table under
+/// one of the eleven clause subsections cannot have it mistaken for the
+/// discharge table.
+const TABLE_HEADING: &str = "Clause discharge";
+
+/// RFC 0006 section 6's clause ids, in document order.
+///
+/// Read through [`Section::subsections`], which is fence-aware, rather than off
+/// the section's raw lines. A fenced example quoting a `### 6.12. …` heading
+/// would be read as a clause by a raw-line scan, and since every child RFC's
+/// discharge table must equal this set exactly, that would fail every child
+/// RFC for a heading the document does not actually state. Section 6 carries no
+/// fences today; the scan goes through the structure layer so that stays a fact
+/// about the document rather than a precondition of this function.
+pub(super) fn clause_ids(repo: &Repo) -> Result> {
+ let text = repo.read(RFC_0006)?;
+ let document = Section::whole(&text);
+ let section = document
+ .subsection("## 6. Cross-cutting contract")
+ .context("RFC 0006 has no section 6")?;
+ let mut ids: Vec = Vec::new();
+ for found in section.subsections() {
+ let id = found
+ .heading
+ .split('.')
+ .take(2)
+ .collect::>()
+ .join(".");
+ if id.starts_with("6.") && !ids.contains(&id) {
+ ids.push(id);
+ }
+ }
+ ensure!(
+ !ids.is_empty(),
+ "RFC 0006 section 6 has no numbered subsections, so it states no clauses"
+ );
+ Ok(ids)
+}
+
+/// The clause ids a child RFC discharges.
+pub(super) fn discharged(repo: &Repo, file: &str) -> Result> {
+ let text = repo.read(file)?;
+ let document = Section::whole(&text);
+ let section = document
+ .subsection(CLAUSES_HEADING)
+ .with_context(|| format!("{file} has no clause-discharge table at {CLAUSES_HEADING}"))?;
+ // Matched on the heading text rather than by taking the first table found:
+ // the subsection is located by its own heading, and pairing the table with
+ // the heading directly above it is what distinguishes it from any table a
+ // child places under one of the eleven clause subsections above.
+ let Some((_, rows)) = section
+ .tables()
+ .into_iter()
+ .find(|(heading, rows)| !rows.is_empty() && heading == TABLE_HEADING)
+ else {
+ return Err(anyhow::anyhow!(
+ "the clause-discharge subsection of {file} contains no table"
+ ));
+ };
+ let mut ids = BTreeSet::new();
+ for row in &rows {
+ let cell = row.cell(0, "clause")?;
+ let id = strip_backticks(cell).trim().to_owned();
+ ensure!(
+ id.starts_with("6."),
+ "{file}:{} names clause {id:?}, which is not a section 6 clause",
+ row.line
+ );
+ ensure!(
+ !strip_backticks(row.cell(1, "discharge")?).trim().is_empty(),
+ "{file}:{} discharges {id} with an empty cell",
+ row.line
+ );
+ // Two rows for one clause would leave the *other* ten clauses covered by
+ // a set that looks complete, so the duplicate is rejected rather than
+ // absorbed. `clause_ids` already de-duplicates on the same rule.
+ ensure!(
+ ids.insert(id.clone()),
+ "{file}:{} discharges clause {id} a second time",
+ row.line
+ );
+ }
+ Ok(ids)
+}
+
+/// The heading of a child RFC's section 5.
+///
+/// The child RFCs carry the same section 5 title RFC 0006's own capability-group
+/// sections use, because a child RFC restates one group's section 5 in full.
+const SECTION_5_HEADING: &str = "## 5. Cross-cutting contract conformance";
+
+/// Locate a child RFC's section 5, and check each of its subsections.
+///
+/// Split from [`check_subsections`] so the caller does not have to hold the
+/// document open: the text is read here, scanned, and dropped.
+pub(super) fn check_section_five(
+ repo: &Repo,
+ file: &str,
+ owned: &BTreeSet,
+) -> Result> {
+ let text = repo.read(file)?;
+ let document = Section::whole(&text);
+ let section = document
+ .subsection(SECTION_5_HEADING)
+ .with_context(|| format!("{file} has no section 5 at {SECTION_5_HEADING}"))?;
+ check_subsections(file, §ion, owned)
+}
+
+/// The subsection heading that closes section 5.
+///
+/// [`CLAUSES_HEADING`] carries its `### ` because a caller locates the
+/// subsection by full heading text; the scans below compare against heading text
+/// alone, so the marker is trimmed once here rather than at four call sites.
+const CLAUSES_TITLE: &str = "Clause discharge";
+
+/// The exact words `D6` permits in place of a group-specific consequence.
+///
+/// The plan's anti-vacuity rule admits one escape: a subsection that has nothing
+/// group-specific to add says so, naming the clause it defers to. That sentence
+/// is fixed, so it is matched as a fixed string. Only the prefix is compared,
+/// because the clause number it names depends on which clause the subsection
+/// discharges.
+const NO_ADDITIONAL_OBLIGATION: &str = "No additional obligation beyond RFC 0006 section 6.";
+
+/// Check one child RFC's section 5 against the `CONF-1` obligations.
+///
+/// Three shapes of vacuity are mechanical, and each is caught here. The first is
+/// the empty stub: a subsection whose body is present but says nothing. The
+/// second is the generic discharge, which discusses the clause but never names a
+/// helper the RFC owns — the anti-vacuity rule's own test, since a consequence
+/// specific to a group of helpers has to name one of them. The third is the
+/// appeal to Ansible, which is the survey's subject rather than its conclusion.
+///
+/// `owned` is the set the child's registry claims, so a subsection may name any
+/// helper it registers and no other. A subsection that names a helper outside
+/// that set is the generic discharge wearing a plausible name.
+pub(super) fn check_subsections(
+ file: &str,
+ section_five: &Section<'_>,
+ owned: &BTreeSet,
+) -> Result> {
+ let mut ids = BTreeSet::new();
+ for found in section_five.subsections() {
+ // The discharge table closes section 5 and is graded by `discharged`,
+ // which reads its own content. Excluding it here is what keeps the two
+ // checks from disagreeing about one subsection.
+ if found.heading == CLAUSES_TITLE {
+ continue;
+ }
+ let Some(id) = clause_id_of(&found.heading) else {
+ continue;
+ };
+ ensure!(
+ ids.insert(id.clone()),
+ "{file}:{} is a second section 5 subsection for clause {id}",
+ found.line
+ );
+ let body = found.body.join("\n");
+ ensure!(
+ !body.trim().is_empty(),
+ "{file}:{} is subsection {} of section 5 with an empty body",
+ found.line,
+ found.heading
+ );
+ let escape = body.contains(NO_ADDITIONAL_OBLIGATION);
+ let named = names_an_owned_helper(&body, owned);
+ ensure!(
+ escape || named,
+ "{file}:{} is subsection {}, whose body names none of the helpers this RFC owns \
+ and does not read {:?}. A reviewer cannot tell it apart from a restatement of \
+ the clause it discharges",
+ found.line,
+ found.heading,
+ NO_ADDITIONAL_OBLIGATION
+ );
+ if let Some(phrase) = deference_phrase(&body) {
+ return Err(anyhow::anyhow!(
+ "{file}:{} is subsection {}, which justifies a helper by appealing to Ansible \
+ ({phrase:?}). RFC 0006 surveys Ansible; it does not adopt its choices",
+ found.line,
+ found.heading
+ ));
+ }
+ }
+ Ok(ids)
+}
+
+/// The clause id a section 5 subsection title names, if it names one.
+///
+/// The id is the leading `N.M` token, and requiring that shape is what keeps the
+/// scan off a subsection that is not a clause: `5.1. Registry` resolves the
+/// clause its title names, whereas a subsection titled `Worked example` resolves
+/// nothing and is body, not a clause discharge.
+fn clause_id_of(heading: &str) -> Option {
+ let id = heading.split(' ').next()?.trim_end_matches('.');
+ let mut parts = id.split('.');
+ let (major, minor) = (parts.next()?, parts.next()?);
+ (parts.next().is_none() && major.chars().all(|ch| ch.is_ascii_digit()))
+ .then(|| format!("6.{minor}"))
+ .filter(|_| minor.chars().all(|ch| ch.is_ascii_digit()))
+}
+
+/// Whether `body` names at least one helper in `owned`.
+///
+/// A backticked span rather than a bare word: the registry's helper names are
+/// written as code throughout the corpus, and a bare-word scan would read the
+/// English words `size`, `hash`, `shell`, and `contents` as helper names in any
+/// sentence that happened to use them. Requiring the code span is the whole-token
+/// test — a word is either entirely a name or it is not a token this matches.
+fn names_an_owned_helper(body: &str, owned: &BTreeSet) -> bool {
+ backticked(body)
+ .iter()
+ .any(|name| owned.contains(name.trim()))
+}
+
+#[cfg(test)]
+#[path = "clauses_tests.rs"]
+mod tests;
diff --git a/tests/rfc_stdlib_coverage/clauses_tests.rs b/tests/rfc_stdlib_coverage/clauses_tests.rs
new file mode 100644
index 000000000..a9a2a2db0
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/clauses_tests.rs
@@ -0,0 +1,200 @@
+//! Direct clause-discharge, anti-vacuity, and whole-token diagnostics.
+
+use super::*;
+use rstest::rstest;
+
+const FILE: &str = "docs/rfcs/0013-child.md";
+
+/// Write an isolated parent or child contract document.
+fn fixture(file: &str, text: &str) -> Result<(tempfile::TempDir, Repo)> {
+ let temp = tempfile::tempdir()?;
+ let root = camino::Utf8Path::from_path(temp.path()).context("UTF-8 fixture path")?;
+ let repo = Repo::fixture(root)?;
+ repo.dir.create_dir_all("docs/rfcs")?;
+ repo.dir.write(file, text)?;
+ Ok((temp, repo))
+}
+
+#[rstest]
+#[case::missing("## 7. Survey", "has no section 6")]
+#[case::no_clauses("## 6. Cross-cutting contract\nProse", "has no numbered subsections")]
+fn parent_clause_structure_is_required(#[case] text: &str, #[case] expected: &str) -> Result<()> {
+ let (_temp, repo) = fixture(RFC_0006, text)?;
+ let error = clause_ids(&repo).expect_err("missing clauses must fail");
+ ensure!(error.to_string().contains(expected), "{error}");
+ Ok(())
+}
+
+#[test]
+fn parent_clauses_ignore_fences_other_ids_and_repeated_ids() -> Result<()> {
+ let text = concat!(
+ "## 6. Cross-cutting contract\n### 6.1. Registry\n",
+ "```markdown\n### 6.99. Example\n```\n",
+ "### 6.1. Repeated\n### Other heading\n### 6.2. Namespace\n",
+ "## 7. Survey\n### 6.3. Outside\n",
+ );
+ let (_temp, repo) = fixture(RFC_0006, text)?;
+ {
+ let actual = clause_ids(&repo)?;
+ let expected_value = ["6.1", "6.2"];
+ ensure!(
+ actual == expected_value,
+ "expected {expected_value:?}, found {actual:?}"
+ );
+ };
+ Ok(())
+}
+
+#[rstest]
+#[case::missing_heading("# Child", "no clause-discharge table")]
+#[case::no_table("### Clause discharge\nProse", "contains no table")]
+fn discharge_structure_is_required(#[case] text: &str, #[case] expected: &str) -> Result<()> {
+ let (_temp, repo) = fixture(FILE, text)?;
+ let error = discharged(&repo, FILE).expect_err("missing discharge must fail");
+ ensure!(error.to_string().contains(expected), "{error}");
+ ensure!(error.to_string().contains(FILE), "{error}");
+ Ok(())
+}
+
+#[rstest]
+#[case::bad_id("| `5.1` | `alpha` is pure. |", "not a section 6 clause", 4)]
+#[case::empty("| `6.1` | `` |", "empty cell", 4)]
+#[case::duplicate(
+ "| `6.1` | `alpha` is pure. |\n| `6.1` | `alpha` is pure. |",
+ "a second time",
+ 5
+)]
+#[case::missing_cell("| `6.1` |", "has no discharge column", 4)]
+fn bad_discharge_rows_report_source_lines(
+ #[case] rows: &str,
+ #[case] expected: &str,
+ #[case] line: usize,
+) -> Result<()> {
+ let text = format!("### Clause discharge\n| Clause | Discharge |\n| --- | --- |\n{rows}\n");
+ let (_temp, repo) = fixture(FILE, &text)?;
+ let error = discharged(&repo, FILE).expect_err("bad discharge must fail");
+ let diagnostic = format!("{error:#}");
+ ensure!(diagnostic.contains(expected), "{diagnostic}");
+ if expected == "has no discharge column" {
+ ensure!(diagnostic.contains(&format!("line {line}")), "{diagnostic}");
+ } else {
+ ensure!(
+ diagnostic.contains(&format!("{FILE}:{line}")),
+ "{diagnostic}"
+ );
+ }
+ Ok(())
+}
+
+#[test]
+fn a_discharge_returns_the_clause_set() -> Result<()> {
+ let (_temp, repo) = fixture(
+ FILE,
+ "### Clause discharge\n| Clause | Discharge |\n| --- | --- |\n| `6.1` | `alpha` is pure. |",
+ )?;
+ {
+ let actual = discharged(&repo, FILE)?;
+ let expected_value = BTreeSet::from(["6.1".into()]);
+ ensure!(
+ actual == expected_value,
+ "expected {expected_value:?}, found {actual:?}"
+ );
+ };
+ Ok(())
+}
+
+#[rstest]
+#[case::empty("### 5.1. Registry\n", "empty body", 2)]
+#[case::generic("### 5.1. Registry\nGeneric obligations apply.", "names none", 2)]
+#[case::foreign_helper("### 5.1. Registry\n`beta` is pure.", "names none", 2)]
+#[case::fenced_helper(
+ "### 5.1. Registry\n```\n`alpha`\n```\nGeneric prose.",
+ "names none",
+ 2
+)]
+#[case::ansible(
+ "### 5.1. Registry\n`alpha` behaves as Ansible does.",
+ "appealing to Ansible",
+ 2
+)]
+#[case::duplicate(
+ "### 5.1. Registry\n`alpha` is pure.\n### 5.1. Duplicate\n`alpha` is pure.",
+ "second section 5 subsection",
+ 4
+)]
+fn vacuous_subsections_report_source_lines(
+ #[case] body: &str,
+ #[case] expected: &str,
+ #[case] line: usize,
+) -> Result<()> {
+ let owned = BTreeSet::from(["alpha".into()]);
+ let text = format!("## 5. Cross-cutting contract conformance\n{body}\n");
+ let (_temp, repo) = fixture(FILE, &text)?;
+ let error = check_section_five(&repo, FILE, &owned).expect_err("vacuity must fail");
+ let diagnostic = error.to_string();
+ ensure!(diagnostic.contains(expected), "{diagnostic}");
+ ensure!(
+ diagnostic.contains(&format!("{FILE}:{line}")),
+ "{diagnostic}"
+ );
+ Ok(())
+}
+
+#[test]
+fn missing_section_five_is_rejected() -> Result<()> {
+ let (_temp, repo) = fixture(FILE, "## 4. Design")?;
+ let error =
+ check_section_five(&repo, FILE, &BTreeSet::new()).expect_err("missing section must fail");
+ ensure!(error.to_string().contains("has no section 5"), "{error}");
+ Ok(())
+}
+
+#[test]
+fn explicit_escape_and_substantive_owned_helper_bodies_are_accepted() -> Result<()> {
+ let text = concat!(
+ "## 5. Cross-cutting contract conformance\n",
+ "### 5.1. Registry\n`alpha` is pure.\n",
+ "### 5.2. Namespace\nNo additional obligation beyond RFC 0006 section 6.2.\n",
+ "### Worked example\nNot a numbered obligation.\n",
+ "### Clause discharge\nThe separately checked table.\n",
+ );
+ let owned = BTreeSet::from(["alpha".into()]);
+ let (_temp, repo) = fixture(FILE, text)?;
+ {
+ let actual = check_section_five(&repo, FILE, &owned)?;
+ let expected_value = BTreeSet::from(["6.1".into(), "6.2".into()]);
+ ensure!(
+ actual == expected_value,
+ "expected {expected_value:?}, found {actual:?}"
+ );
+ };
+ Ok(())
+}
+
+#[rstest]
+#[case("5.1. Registry", Some("6.1"))]
+#[case("Worked example", None)]
+#[case("5.1.2. Nested", None)]
+#[case("five.1. Registry", None)]
+#[case("5.one. Registry", None)]
+#[case("5 Registry", None)]
+fn subsection_ids_require_numeric_pair_shape(
+ #[case] heading: &str,
+ #[case] expected: Option<&str>,
+) {
+ assert_eq!(clause_id_of(heading).as_deref(), expected);
+}
+
+#[rstest]
+#[case("`alpha` is pure", true)]
+#[case("` alpha ` is pure", true)]
+#[case("alpha is pure", false)]
+#[case("`alphabet` is pure", false)]
+#[case("`alpha()` is pure", false)]
+#[case("`beta` is pure", false)]
+fn helper_justification_requires_whole_code_tokens(#[case] body: &str, #[case] expected: bool) {
+ assert_eq!(
+ names_an_owned_helper(body, &BTreeSet::from(["alpha".into()])),
+ expected
+ );
+}
diff --git a/tests/rfc_stdlib_coverage/deference.rs b/tests/rfc_stdlib_coverage/deference.rs
new file mode 100644
index 000000000..ac9ee08f6
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/deference.rs
@@ -0,0 +1,216 @@
+//! The lexical half of the `CONF-1` anti-vacuity rule.
+//!
+//! `CONF-1` asks that a child RFC's section 5 discharge be group-specific rather
+//! than a restatement of the clause it discharges. Two of the three vacuity
+//! shapes the rule names are structural, and `clauses` grades them: an empty
+//! body, and a body naming none of the helpers its RFC owns. The third — a body
+//! that justifies a helper by appealing to Ansible — is a judgement about prose
+//! rather than about document structure, and it is made here.
+//!
+//! Split from `clauses` to keep that module under Whitaker's 400-line
+//! `module_max_lines` ceiling, not as a new resolution boundary: `clauses` reads
+//! [`deference_phrase`] through `super`, exactly as it read the function when
+//! both lived in one file. The seam is the kind of judgement rather than the
+//! size of the code, which is why the unit tests below travel with it: they pin
+//! the predicate against sentences, and every other test in this module tree is
+//! exercised through a document.
+
+/// The claim that a helper exists because Ansible has one.
+///
+/// This is literally the acceptance criterion the task exists to enforce: RFC
+/// 0006 is a survey of Ansible's standard library, and a child RFC that
+/// justifies a helper by appealing to Ansible rather than to Netsuke's own
+/// contract has not made a decision, only deferred one. It is a substring
+/// search, so leaving it to review would be indefensible.
+///
+/// Each phrase is matched at a leading word boundary and case-insensitively.
+/// The boundary keeps `Unlike Ansible` — which contains `like Ansible` and says
+/// the opposite of what this check looks for — from being flagged as the appeal
+/// it argues against. Case is folded because the appeal is as likely to open a
+/// sentence as to sit inside one: `As Ansible does` and `as Ansible does` are
+/// one phrase, and a case-sensitive search would catch only the second.
+///
+/// A leading boundary alone does not clear `such as Ansible`, because `as` is
+/// its own word there — the phrase is the tail of the compound preposition —
+/// and the sentence introduces an example rather than a justification. That
+/// shape is excluded by name in [`deference_phrase`]. A trailing boundary is
+/// deliberately *not* required: `as Ansible does` is the shape the check exists
+/// to catch, and it is this plan's own recorded seeded fault, so tightening the
+/// right-hand side would delete a control that has been proven to fire.
+///
+/// Each entry is a `(pattern, display)` pair. The pattern is the lowercase form
+/// the folded body is searched for; the display form is what the diagnostic
+/// quotes, because `as ansible` in an error message reads as a typo rather than
+/// as the phrase the check names.
+const DEFERENCE_PHRASES: [(&str, &str); 2] = [
+ ("as ansible", "as Ansible"),
+ ("like ansible", "like Ansible"),
+];
+
+/// The exemplar idiom that contains `as Ansible` without deferring to it.
+///
+/// `such as Ansible` names a source of examples, not a reason to have a helper.
+/// Matched immediately before the phrase, on the lowercased text, so the
+/// exclusion cannot swallow a genuine appeal that happens to follow one of
+/// these words elsewhere in the sentence, and so `Such as Ansible` is cleared
+/// as its lowercase form is.
+///
+/// Only `such ` is listed. An earlier draft also carried `with ` and `in `, for
+/// the shapes `as with Ansible` and `as in Ansible`; both were unreachable. The
+/// scan finds `as Ansible` or `like Ansible` and then reads the text *before*
+/// the match, so the prefix it tests is the text preceding `as` — for those two
+/// shapes that is the empty string, never `with ` or `in `. The entries could
+/// not change any answer, and their presence made the doc comment look as
+/// though the two shapes were handled when only the boundary rule was doing any
+/// work. `as with Ansible` and `as in Ansible` are cleared because neither
+/// contains the substring `as Ansible` or `like Ansible` at all.
+const EXEMPLAR_PREFIXES: [&str; 1] = ["such "];
+
+/// Whether `body` justifies a helper by appeal to Ansible.
+///
+/// Returns the phrase's display form, so the diagnostic can quote it. That form
+/// is canonical rather than the text as it appeared: the match is made against
+/// a folded copy of the body, so a sentence-initial `As Ansible does` would
+/// otherwise be quoted back in whatever case the child RFC happened to use, and
+/// a diagnostic naming three different spellings of one phrase as it fires
+/// three times is harder to read than one naming a single shape.
+pub(super) fn deference_phrase(body: &str) -> Option<&'static str> {
+ // Lowercased once per call rather than per candidate phrase. The offsets
+ // this yields are offsets into the folded text, which is what the
+ // surrounding-token tests below read; `to_lowercase` is not guaranteed to
+ // preserve byte length for every script, but the phrases and the context
+ // that decides their boundaries are ASCII throughout this corpus.
+ let folded = body.to_lowercase();
+ DEFERENCE_PHRASES
+ .into_iter()
+ .find(|(pattern, _)| {
+ folded.match_indices(*pattern).any(|(at, _)| {
+ let Some(prefix) = folded.get(..at) else {
+ return false;
+ };
+ // A boundary is the start of the text, or a character that cannot
+ // continue a word. `is_alphanumeric` alone would treat the `_` in
+ // `unlike_ansible` as a break, and the underscore is a word
+ // character in every identifier this corpus uses.
+ let bounded = !prefix
+ .chars()
+ .next_back()
+ .is_some_and(|ch| ch.is_alphanumeric() || ch == '_');
+ let exemplar = EXEMPLAR_PREFIXES
+ .iter()
+ .any(|prefix_word| prefix.ends_with(prefix_word));
+ bounded && !exemplar
+ })
+ })
+ .map(|(_, display)| display)
+}
+
+#[cfg(test)]
+mod deference_tests {
+ //! Unit tests for [`deference_phrase`](super::deference_phrase).
+ //!
+ //! The predicate is the mechanical half of this plan's own acceptance
+ //! criterion, and it is a lexical search over prose: the two false shapes
+ //! below are sentences a child RFC would plausibly write, and both read as
+ //! the reverse of the appeal it exists to catch.
+
+ use super::deference_phrase;
+
+ /// An appeal to Ansible is caught, and quoted back in the diagnostic.
+ ///
+ /// `as Ansible does` is the recorded seeded fault for `CONF-1`, so it is
+ /// pinned here: a change that stopped catching it would leave that control
+ /// green and empty.
+ #[test]
+ fn an_appeal_is_flagged() {
+ assert_eq!(deference_phrase("as Ansible does"), Some("as Ansible"));
+ assert_eq!(deference_phrase("as Ansible"), Some("as Ansible"));
+ assert_eq!(
+ deference_phrase("like Ansible's fileglob"),
+ Some("like Ansible")
+ );
+ assert_eq!(
+ deference_phrase("This is like Ansible"),
+ Some("like Ansible")
+ );
+ }
+
+ /// The same appeal opening a sentence is caught too.
+ ///
+ /// The phrases are matched case-insensitively, so capitalizing the first
+ /// word does not hide the appeal. Each case asserts the canonical lowercase
+ /// phrase is what comes back, because that is what the diagnostic quotes.
+ #[test]
+ fn a_capitalized_appeal_is_flagged() {
+ assert_eq!(deference_phrase("As Ansible does"), Some("as Ansible"));
+ assert_eq!(deference_phrase("Like Ansible"), Some("like Ansible"));
+ assert_eq!(
+ deference_phrase("AS ANSIBLE DOES"),
+ Some("as Ansible"),
+ "a shouted appeal is the same appeal"
+ );
+ }
+
+ /// A sentence that argues *against* the appeal is not an appeal.
+ ///
+ /// `Unlike Ansible` contains `like Ansible`, and flagging it would fail a
+ /// subsection that has made exactly the decision this check demands.
+ #[test]
+ fn the_reverse_of_an_appeal_is_not_flagged() {
+ assert_eq!(deference_phrase("Unlike Ansible, Netsuke has none"), None);
+ assert_eq!(deference_phrase("unlike_ansible"), None);
+ assert_eq!(deference_phrase("unlikeAnsible"), None);
+ }
+
+ /// Naming Ansible as a source of examples is not deference to it.
+ ///
+ /// `such as Ansible` introduces an instance, and its `as` is its own word,
+ /// so the leading boundary alone does not clear it. The `such ` entry in
+ /// [`EXEMPLAR_PREFIXES`](super::EXEMPLAR_PREFIXES) is what does, and the
+ /// last assertion below is that entry's liveness check: it carries the same
+ /// `as Ansible does` tail under a prefix that is not an exemplar, so it is
+ /// caught. An entry that stopped matching would turn the first two
+ /// assertions red instead of leaving them green and empty.
+ ///
+ /// `as with Ansible` and `as in Ansible` are cleared by the phrase search
+ /// itself — neither contains `as Ansible` or `like Ansible` as a substring
+ /// — so they are not this predicate's cases to argue about and are not
+ /// asserted here as though the exemplar list were clearing them.
+ #[test]
+ fn an_example_list_is_not_flagged() {
+ assert_eq!(deference_phrase("such as Ansible does today"), None);
+ assert_eq!(deference_phrase("helpers such as Ansible has"), None);
+ assert_eq!(
+ deference_phrase("Such as Ansible does"),
+ None,
+ "the exemplar prefix is matched against the folded text"
+ );
+ assert_eq!(
+ deference_phrase("today, as Ansible does"),
+ Some("as Ansible"),
+ "the same words under a nonexemplar prefix are the appeal"
+ );
+ }
+
+ #[rstest::rstest]
+ #[case::underscore("_as Ansible", None)]
+ #[case::number("2as Ansible", None)]
+ #[case::unicode_word("éAs Ansible", None)]
+ #[case::punctuation("(as Ansible)", Some("as Ansible"))]
+ #[case::unicode_punctuation("—Like Ansible", Some("like Ansible"))]
+ #[case::later_real_appeal("such as Ansible; as Ansible does", Some("as Ansible"))]
+ fn phrase_boundaries_and_later_matches_are_respected(
+ #[case] text: &str,
+ #[case] expected: Option<&str>,
+ ) {
+ assert_eq!(deference_phrase(text), expected);
+ }
+
+ /// A sentence mentioning Ansible for another reason is not flagged.
+ #[test]
+ fn an_unrelated_mention_is_not_flagged() {
+ assert_eq!(deference_phrase("because Ansible has one"), None);
+ assert_eq!(deference_phrase("Ansible"), None);
+ assert_eq!(deference_phrase(""), None);
+ }
+}
diff --git a/tests/rfc_stdlib_coverage/document.rs b/tests/rfc_stdlib_coverage/document.rs
new file mode 100644
index 000000000..d132f7ea0
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/document.rs
@@ -0,0 +1,273 @@
+//! The document layer: sections, subsections, and raw table rows.
+//!
+//! Every parser in this module tree reads its source through this layer, so the
+//! question "where does this section end" is answered once. The lexical
+//! judgements it rests on — is a line a heading, is it a table row, is it inside
+//! a fence — belong to `super::markdown`; what lives here is the structure built
+//! on top of them.
+//!
+//! Line numbers are absolute and preserved through subscripting, so a parse of a
+//! nested subsection still reports where in the file a row came from.
+//!
+//! Split out of `mod.rs` to keep that module under Whitaker's 400-line
+//! `module_max_lines` ceiling, not as a new resolution boundary: `mod.rs` owns
+//! this module and re-exports it, so each item reaches a sibling exactly as it
+//! did before the split, and nothing outside this module tree may use it.
+
+use anyhow::{Context, Result};
+
+use super::{Fences, heading_depth, is_separator, row_cells, table_heading};
+
+/// A contiguous slice of a Markdown document, with absolute line numbers.
+///
+/// Line numbers are preserved through subscripting so that a parse of a nested
+/// subsection can still report where in the file a row came from.
+pub(super) struct Section<'a> {
+ /// One-indexed line number of the first element of `lines`.
+ first_line: usize,
+ /// The section's lines, with line terminators removed.
+ ///
+ /// Public to the module tree because two parsers scan the raw lines
+ /// themselves rather than through a method here: `section8` re-derives an
+ /// end offset for a `###` heading whose text is not known in advance, and
+ /// `totals` collapses section 6.1's prose to compare it against a
+ /// transcription. Each is a different question from the ones
+ /// [`Section::tables`] and [`Section::subsections`] answer, so an accessor
+ /// for either would be unused by the other.
+ ///
+ /// A scan added here is fence-blind unless it brings its own [`Fences`].
+ /// `clauses` and `roadmap` used to walk these lines for headings and now go
+ /// through [`Section::subsections`], which is fence-aware; prefer that route
+ /// for anything that reads structure.
+ pub(super) lines: Vec<&'a str>,
+}
+
+impl<'a> Section<'a> {
+ /// Treat a whole document as one section.
+ ///
+ /// The document's own path is not retained: every caller holds the `&str`
+ /// it read the text from, and takes its diagnostics from that. Carrying a
+ /// copy here would be state nothing reads.
+ pub(super) fn whole(text: &'a str) -> Self {
+ Self {
+ first_line: 1,
+ lines: text.lines().collect(),
+ }
+ }
+
+ /// The subsection headed by `heading`, running to the next heading of the
+ /// same or a shallower depth.
+ ///
+ /// The heading line is part of the returned section. A table placed directly
+ /// under a subsection heading is introduced by it and by nothing else, so a
+ /// subsection that withheld its own heading would leave that table
+ /// attribute-less, and [`Section::tables`] would name it `""` rather than the
+ /// heading a caller filters on.
+ ///
+ /// The heading must match on its full text, so `14.1. Slice` cannot
+ /// accidentally select `14.10. Slice`.
+ ///
+ /// Note that `heading` is matched verbatim, and that the start scan skips
+ /// fenced lines, so a document that quotes the heading inside an example
+ /// still lands on the real one. Both scans are fence-aware for the same
+ /// reason: an example is not structure, and a start scan that stopped on one
+ /// would run to the end of the example's own block instead of the heading's.
+ pub(super) fn subsection(&self, heading: &str) -> Option {
+ let start = self.unfenced_heading(heading)?;
+ let depth = heading_depth(self.lines.get(start)?)?;
+ let rest = self.lines.get(start..)?;
+ let end = rest
+ .get(1..)?
+ .iter()
+ .scan(Fences::default(), |fences, line| {
+ let fenced = fences.mark(line);
+ Some((fenced, line))
+ })
+ .position(|(fenced, line)| !fenced && heading_depth(line).is_some_and(|d| d <= depth))
+ .map_or(rest.len(), |offset| offset + 1);
+ Some(Self {
+ first_line: self.first_line + start,
+ lines: rest.get(..end)?.to_vec(),
+ })
+ }
+
+ /// This section's `###` subsections, in document order.
+ ///
+ /// A subsection runs from its own heading to the next heading of the same or
+ /// a shallower depth, which is [`Section::subsection`]'s rule applied to
+ /// every heading at once. Only depth three is collected, because the sections
+ /// that carry subsections in this corpus are depth two: a `####` inside one is
+ /// body content, and treating it as a sibling would split its parent.
+ ///
+ /// The body is fence-free in the same sense [`Section::tables`] is. A child
+ /// RFC's clause subsections quote Jinja, shell, and diagnostic text, and a
+ /// `#` line inside one of those snippets is a comment rather than a heading;
+ /// reading it as one would truncate the subsection and drop whatever
+ /// followed. Heading lines are not themselves body, so a `####` inside a
+ /// subsection contributes its prose but not its own line.
+ pub(super) fn subsections(&self) -> Vec {
+ let headings = self.headings();
+ let mut found = Vec::new();
+ for (index, (offset, line)) in headings.iter().enumerate() {
+ let end = headings
+ .get(index + 1)
+ .map_or(self.lines.len(), |(next, _)| *next);
+ found.push(Subsection {
+ heading: line.clone(),
+ line: self.first_line + offset,
+ body: self.unfenced_body(offset + 1, end),
+ });
+ }
+ found
+ }
+
+ /// The offset of the first unfenced line that equals `heading`.
+ ///
+ /// Shared with [`Section::subsection`] because both scans have to agree
+ /// about which lines are structure: a start scan that matched inside a fence
+ /// would hand the end scan a position the end scan does not consider real.
+ ///
+ /// The comparison is on the raw line and not on its trimmed text, because
+ /// [`heading_depth`] is the single authority on what a heading is and it
+ /// rejects an indented line: ` ### Foo` is body, not a heading, for every
+ /// scan in this module tree. Comparing trimmed text here would match a line
+ /// the rest of the tree treats as prose, and `subsection` would then fail at
+ /// the depth it reads next, reporting "no such section" for a heading the
+ /// caller can see. An indented `### Foo` in a document is therefore not a
+ /// heading to look up; that is `heading_depth`'s decision, made once.
+ pub(super) fn unfenced_heading(&self, heading: &str) -> Option {
+ let mut fences = Fences::default();
+ for (offset, line) in self.lines.iter().enumerate() {
+ if fences.mark(line) {
+ continue;
+ }
+ if *line == heading {
+ return Some(offset);
+ }
+ }
+ None
+ }
+
+ /// Every unfenced `###` heading, as (offset, text).
+ fn headings(&self) -> Vec<(usize, String)> {
+ let mut fences = Fences::default();
+ let mut found = Vec::new();
+ for (offset, line) in self.lines.iter().enumerate() {
+ if fences.mark(line) {
+ continue;
+ }
+ if heading_depth(line) == Some(3)
+ && let Some(text) = table_heading(line)
+ {
+ found.push((offset, text));
+ }
+ }
+ found
+ }
+
+ /// The unfenced lines in `start..end`, with trailing whitespace removed.
+ ///
+ /// The fence scan starts at the section's first line rather than at `start`,
+ /// so a body that begins inside a block its own heading opened is read as
+ /// fenced. That cannot happen for a heading the scan reached unfenced, but it
+ /// costs nothing to be right about.
+ fn unfenced_body(&self, start: usize, end: usize) -> Vec {
+ let mut fences = Fences::default();
+ let mut body = Vec::new();
+ for (offset, line) in self.lines.iter().enumerate().take(end) {
+ if fences.mark(line) {
+ continue;
+ }
+ if offset >= start {
+ body.push(line.trim_end().to_owned());
+ }
+ }
+ body
+ }
+
+ /// Every Markdown table in this section, in document order, paired with the
+ /// heading text that precedes it.
+ ///
+ /// The header row is dropped and the `|---|` separator skipped, so each
+ /// returned row is a data row. A table ends at the first line that is not a
+ /// table row, which is what keeps two adjacent subtables distinct.
+ ///
+ /// Fenced lines are skipped in both roles. A `|` line inside a fence is
+ /// example content, and a `#` line inside a fence is a comment — in a Jinja
+ /// or shell snippet, both are ordinary text and neither is structure.
+ pub(super) fn tables(&self) -> Vec<(String, Vec)> {
+ let mut tables: Vec<(String, Vec)> = Vec::new();
+ let mut fences = Fences::default();
+ let mut heading = String::new();
+ let mut body = false;
+ for (offset, line) in self.lines.iter().enumerate() {
+ if fences.mark(line) {
+ body = false;
+ continue;
+ }
+ let number = self.first_line + offset;
+ if let Some(text) = table_heading(line) {
+ heading = text;
+ body = false;
+ continue;
+ }
+ let Some(cells) = row_cells(line) else {
+ body = false;
+ continue;
+ };
+ if is_separator(&cells) {
+ continue;
+ }
+ if !body {
+ // The first row of a table is its header, and the separator
+ // under it is skipped, so the rows that follow are data rows.
+ tables.push((heading.clone(), Vec::new()));
+ body = true;
+ continue;
+ }
+ if let Some((_, rows)) = tables.last_mut() {
+ rows.push(RawRow {
+ cells,
+ line: number,
+ });
+ }
+ }
+ tables
+ .into_iter()
+ .filter(|(_, rows)| !rows.is_empty())
+ .collect()
+ }
+}
+
+/// One `###` subsection of a section, with its body.
+pub(super) struct Subsection {
+ /// The heading text, without its hashes.
+ pub(super) heading: String,
+ /// One-indexed line number of the heading.
+ pub(super) line: usize,
+ /// The subsection's own lines, fences and heading excluded.
+ pub(super) body: Vec,
+}
+
+/// One Markdown table row, with the line it was read from.
+pub(super) struct RawRow {
+ /// Cell text, trimmed, in column order.
+ cells: Vec,
+ /// One-indexed line number.
+ pub(super) line: usize,
+}
+
+impl RawRow {
+ /// The cell at `column`, or an error naming this row when the table is
+ /// narrower than the caller expects.
+ pub(super) fn cell(&self, column: usize, what: &str) -> Result<&str> {
+ self.cells
+ .get(column)
+ .map(String::as_str)
+ .with_context(|| format!("row at line {} has no {what} column", self.line))
+ }
+}
+
+#[cfg(test)]
+#[path = "document_tests.rs"]
+mod tests;
diff --git a/tests/rfc_stdlib_coverage/document_tests.rs b/tests/rfc_stdlib_coverage/document_tests.rs
new file mode 100644
index 000000000..060d98118
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/document_tests.rs
@@ -0,0 +1,67 @@
+//! Exercise section boundaries and source locations without tracked documents.
+use super::*;
+use rstest::rstest;
+
+#[test]
+fn sections_skip_fenced_headings_and_stop_at_peer_boundaries() {
+ let text = concat!(
+ "# Intro\n```\n## Target\n```\n## Target\n",
+ "### Child\nbody\n~~~\n# ignored\n~~~\nafter\n## Peer\nend\n"
+ );
+ let whole = Section::whole(text);
+ let section = whole.subsection("## Target").expect("real target exists");
+ assert_eq!(section.first_line, 5);
+ assert_eq!(section.lines.last(), Some(&"after"));
+ assert!(whole.subsection("## Missing").is_none());
+ assert!(whole.subsection("## Tar").is_none());
+ let child = section.subsection("### Child").expect("child exists");
+ assert_eq!(child.first_line, 6);
+ let subsections = section.subsections();
+ assert_eq!(subsections.len(), 1);
+ let first = subsections.first().expect("one subsection");
+ assert_eq!(first.line, 6);
+ assert_eq!(first.body, ["body", "after"]);
+}
+
+#[test]
+fn table_recognition_preserves_absolute_lines_and_separate_headers() {
+ let text = concat!(
+ "# Intro\n## Tables\n### First\n| a | b |\n|---|:---:|\n",
+ "| value | other |\n\n| header |\n| second |\n",
+ "```\n### Fake\n| h |\n| fake |\n```\n### Empty\n| h |\n"
+ );
+ let section = Section::whole(text)
+ .subsection("## Tables")
+ .expect("tables exist");
+ let tables = section.tables();
+ assert_eq!(tables.len(), 2);
+ let first = tables.first().expect("first table");
+ let row = first.1.first().expect("first data row");
+ assert_eq!(first.0, "First");
+ assert_eq!(row.line, 6);
+ assert_eq!(row.cell(1, "value").expect("second cell"), "other");
+ let second = tables.get(1).expect("second table");
+ assert_eq!(second.1.first().expect("second data row").line, 9);
+}
+
+#[rstest]
+#[case::shallow("## peer")]
+#[case::root("# root")]
+#[case::same("### peer")]
+fn subsection_ends_at_same_or_shallower_heading(#[case] boundary: &str) {
+ let text = format!("### target\nbody\n#### nested\nmore\n{boundary}\nafter");
+ let section = Section::whole(&text)
+ .subsection("### target")
+ .expect("target exists");
+ assert_eq!(section.lines.last(), Some(&"more"));
+}
+
+#[test]
+fn missing_raw_cell_reports_column_and_absolute_source_line() {
+ let row = RawRow {
+ cells: vec!["only".into()],
+ line: 42,
+ };
+ let error = row.cell(1, "resolution").expect_err("missing cell");
+ assert_eq!(error.to_string(), "row at line 42 has no resolution column");
+}
diff --git a/tests/rfc_stdlib_coverage/inventory.rs b/tests/rfc_stdlib_coverage/inventory.rs
new file mode 100644
index 000000000..655ce948d
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/inventory.rs
@@ -0,0 +1,145 @@
+//! The section 7 inventories the parse needs in literal form.
+//!
+//! Two of the three cannot be derived from the survey tables: section 7.8
+//! states the rename exceptions in a sentence, and table 11 gives the count of
+//! optioned helpers without ever listing them, their names appearing in three
+//! separate places. Each is transcribed here and witnessed against the document
+//! by the reader that consumes it, so a prose edit fails loudly instead of
+//! drifting silently.
+//!
+//! The third is the set of disposition tables itself. The parse needs each
+//! table's heading, disposition column, and namespace, and no row of the
+//! document states them.
+
+use super::Namespace;
+
+/// A section 7 table whose rows carry a disposition.
+pub(super) struct CandidateTable {
+ /// Heading text as it appears in the document, without the hashes.
+ pub(super) heading: &'static str,
+ /// Zero-based index of the disposition column.
+ pub(super) disposition_column: usize,
+ /// Namespace its rows register into.
+ pub(super) namespace: Namespace,
+}
+
+/// The seven disposition tables of RFC 0006 section 7, in document order.
+///
+/// The global-function table at 7.7 has one fewer leading column than the six
+/// before it, because a global function has no surveyed Ansible spelling
+/// distinct from its name.
+pub(super) const CANDIDATE_TABLES: [CandidateTable; 7] = [
+ CandidateTable {
+ heading: "7.1. Core filters",
+ disposition_column: 2,
+ namespace: Namespace::Filter,
+ },
+ CandidateTable {
+ heading: "7.2. Collection and mathematical filters",
+ disposition_column: 2,
+ namespace: Namespace::Filter,
+ },
+ CandidateTable {
+ heading: "7.3. URL filters",
+ disposition_column: 2,
+ namespace: Namespace::Filter,
+ },
+ CandidateTable {
+ heading: "7.4. Core tests",
+ disposition_column: 2,
+ namespace: Namespace::Test,
+ },
+ CandidateTable {
+ heading: "7.5. Filesystem tests",
+ disposition_column: 2,
+ namespace: Namespace::Test,
+ },
+ CandidateTable {
+ heading: "7.6. Collection tests",
+ disposition_column: 2,
+ namespace: Namespace::Test,
+ },
+ CandidateTable {
+ heading: "7.7. Global functions",
+ disposition_column: 1,
+ namespace: Namespace::Function,
+ },
+];
+
+/// An accepted helper registered under a name other than the surveyed one.
+pub(super) struct Rename {
+ /// The surveyed spelling, as it appears in section 7.
+ pub(super) surveyed: &'static str,
+ /// The registered Netsuke name.
+ pub(super) registered: &'static str,
+ /// Section that specifies the registered name, as `N.N`.
+ pub(super) section: &'static str,
+}
+
+/// RFC 0006 section 7.8's rename exceptions, asserted to number three.
+///
+/// Section 7.8 states these in prose: "Ansible's `hash` becomes `text_hash`
+/// (§11.1), its `quote` becomes `shell_quote` (§10.2), and its `win_splitdrive`
+/// becomes `splitdrive(dialect='windows')` (§8.6)." The sections recorded here
+/// are where each *registered* name is specified in section 8, which for `quote`
+/// is not the section its survey row cites.
+pub(super) const RENAMES: [Rename; 3] = [
+ Rename {
+ surveyed: "hash",
+ registered: "text_hash",
+ section: "8.9",
+ },
+ Rename {
+ surveyed: "quote",
+ registered: "shell_quote",
+ section: "8.9",
+ },
+ Rename {
+ surveyed: "win_splitdrive",
+ registered: "splitdrive",
+ section: "8.6",
+ },
+];
+
+/// An existing helper that gains a behaviour-preserving option.
+pub(super) struct Optioned {
+ /// The existing Netsuke helper name.
+ pub(super) name: &'static str,
+ /// The surveyed section 7 row that names it, in either column.
+ pub(super) evidence_row: &'static str,
+ /// The namespace RFC 0006 section 3.2 places it in.
+ ///
+ /// The namespace is recorded rather than assumed. Two of the three are
+ /// filters and one is a function, and a hardcoded default is not merely
+ /// inaccurate for the odd one out: the coverage check compares a child
+ /// registry's namespace column against the value derived here, so a wrong
+ /// answer makes a *correct* child RFC fail. `glob` is the one that differs,
+ /// which is exactly the case a default gets wrong.
+ pub(super) namespace: Namespace,
+}
+
+/// The three existing helpers gaining an option, from table 11's count of 3.
+///
+/// Table 11 gives the count. The names come from three separate places:
+/// `basename` and `dirname` are section 7 row names, and `glob` appears only in
+/// the resolution cell of the `fileglob` row. The namespaces are section 3.2's
+/// two lists: `basename` and `dirname` are under Filters, and `glob` is under
+/// Functions. `glob` is the only one of the three that is not a filter, which is
+/// what makes a default wrong here rather than merely redundant.
+pub(super) const OPTIONED: [Optioned; 3] = [
+ Optioned {
+ name: "basename",
+ evidence_row: "basename",
+ namespace: Namespace::Filter,
+ },
+ Optioned {
+ name: "dirname",
+ evidence_row: "dirname",
+ namespace: Namespace::Filter,
+ },
+ Optioned {
+ name: "glob",
+ evidence_row: "fileglob",
+ namespace: Namespace::Function,
+ },
+];
diff --git a/tests/rfc_stdlib_coverage/links.rs b/tests/rfc_stdlib_coverage/links.rs
new file mode 100644
index 000000000..e2fbc4be2
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/links.rs
@@ -0,0 +1,143 @@
+//! Resolution of relative Markdown links across the RFC corpus.
+//!
+//! Nothing else in the toolchain checks these. `markdownlint-cli2` is configured
+//! without cross-file link or anchor validation, and `mdtablefix` only
+//! canonicalizes tables, so a relative link between two documents can rot
+//! unnoticed. The RFC corpus is where that matters most: the child RFCs cite
+//! each other, RFC 0006, the roadmap, and the ADRs, and a reader following a
+//! stale path has no fallback.
+
+use anyhow::{Context, Result};
+
+use super::Repo;
+
+/// A relative link target found in a document.
+pub(super) struct Target {
+ /// The target as written, before resolution.
+ pub(super) target: String,
+ /// One-indexed line the target was found on.
+ pub(super) line: usize,
+}
+
+/// Every relative Markdown link target in `text`.
+///
+/// Absolute URLs, bare anchors, and `mailto:` targets are skipped: none is a
+/// path this test can resolve. Targets that wrap across lines are read whole,
+/// because a link split by the 80-column wrap is still a link.
+pub(super) fn targets(text: &str) -> Vec {
+ let mut found = Vec::new();
+ let mut line = 1;
+ let mut rest = text;
+ while let Some(open) = rest.find("](") {
+ // `open` indexes the `]` of a `](`, and every byte skipped here is ASCII,
+ // so each split lands on a character boundary.
+ let (head, tail) = rest.split_at(open);
+ line += head.matches('\n').count();
+ let after = tail.split_at(2).1;
+ let Some(close) = after.find(')') else {
+ break;
+ };
+ let (raw, remainder) = after.split_at(close);
+ let target = raw.split_whitespace().next().unwrap_or("").to_owned();
+ if is_relative(&target) {
+ found.push(Target { target, line });
+ }
+ line += raw.matches('\n').count();
+ rest = remainder.split_at(1).1;
+ }
+ found
+}
+
+/// Whether a link target is a relative path this test should resolve.
+pub(super) fn is_relative(target: &str) -> bool {
+ !target.is_empty()
+ && !target.starts_with('#')
+ && !target.contains("://")
+ && !target.starts_with("mailto:")
+}
+
+/// The path part of a link target, with any `#fragment` removed.
+///
+/// A target that is nothing but a fragment is returned unchanged, so a bare
+/// anchor that reached here still fails to resolve rather than passing as the
+/// directory it sits in.
+fn path_of(target: &str) -> &str {
+ let (path, _) = target.split_once('#').unwrap_or((target, ""));
+ if path.is_empty() { target } else { path }
+}
+
+/// Resolve a relative target against the directory holding `file`.
+///
+/// Only `..` segments are collapsed, which is all the RFC corpus uses.
+///
+/// Returns `None` when the traversal climbs above the repository root, rather
+/// than dropping the surplus `..` segments and handing back a path that looks
+/// ordinary. `../../../etc/passwd` and `etc/passwd` are not the same link, and
+/// a resolver that silently reported the second as resolvable would give the
+/// caller a path it never asked about — resolvable or not, the link itself
+/// points outside the repository.
+///
+/// A `#fragment` selects a heading inside the target document, so only the path
+/// before it names a file. Fragments are stripped and not validated: the anchor
+/// slugs are a renderer's business, and every renderer spells them differently.
+pub(super) fn resolve(file: &str, target: &str) -> Option {
+ let path = path_of(target);
+ let mut segments: Vec<&str> = file.split('/').collect();
+ segments.pop();
+ for segment in path.split('/') {
+ match segment {
+ "" | "." => {}
+ ".." => {
+ // Popping the last segment of an empty path would climb past
+ // the repository root.
+ segments.pop()?;
+ }
+ other => segments.push(other),
+ }
+ }
+ Some(segments.join("/"))
+}
+
+/// Every dangling relative link in `file`.
+///
+/// Returns the failures rather than asserting, so a caller can report all of
+/// them at once instead of one per run.
+pub(super) fn dangling_in(repo: &Repo, file: &str) -> Result> {
+ let text = repo.read(file)?;
+ let mut failures = Vec::new();
+ for target in targets(&text) {
+ let failure = match resolve(file, &target.target) {
+ Some(path) => {
+ if repo
+ .exists(&path)
+ .with_context(|| format!("resolve {}:{} -> {path}", file, target.line))?
+ {
+ continue;
+ }
+ format!(
+ "{file}:{} links to {} which resolves to {path}, and no such file exists",
+ target.line, target.target
+ )
+ }
+ None => format!(
+ "{file}:{} links to {}, which climbs above the repository root",
+ target.line, target.target
+ ),
+ };
+ failures.push(failure);
+ }
+ Ok(failures)
+}
+
+/// Every dangling relative link in the RFC corpus.
+pub(super) fn dangling(repo: &Repo) -> Result> {
+ let mut failures = Vec::new();
+ for file in repo.markdown_files(super::RFC_DIR)? {
+ failures.extend(dangling_in(repo, &file)?);
+ }
+ Ok(failures)
+}
+
+#[cfg(test)]
+#[path = "links_tests.rs"]
+mod tests;
diff --git a/tests/rfc_stdlib_coverage/links_tests.rs b/tests/rfc_stdlib_coverage/links_tests.rs
new file mode 100644
index 000000000..60f36e63a
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/links_tests.rs
@@ -0,0 +1,158 @@
+//! Exercise relative-link extraction and repository-root containment.
+
+use super::*;
+use anyhow::ensure;
+use proptest::prelude::*;
+use rstest::rstest;
+
+#[test]
+fn extracts_relative_targets_with_original_source_lines() {
+ let text = concat!(
+ "[external](https://example.org) [anchor](#heading)\n",
+ "[mail](mailto:user@example.org) [empty]()\n",
+ "[local](../guide.md#section) [wrapped](\nchild.md)\n",
+ "[title](other.md \"description\") [unfinished](missing\n",
+ );
+ let actual: Vec<_> = targets(text)
+ .into_iter()
+ .map(|target| (target.target, target.line))
+ .collect();
+ assert_eq!(
+ actual,
+ vec![
+ ("../guide.md#section".into(), 3),
+ ("child.md".into(), 3),
+ ("other.md".into(), 5)
+ ]
+ );
+}
+
+#[rstest]
+#[case("", false)]
+#[case("#anchor", false)]
+#[case("https://example.org", false)]
+#[case("mailto:a@b", false)]
+#[case("child.md#heading", true)]
+fn classifies_relative_targets(#[case] target: &str, #[case] expected: bool) {
+ assert_eq!(is_relative(target), expected);
+}
+
+#[test]
+fn preserves_a_bare_fragment_when_resolving_directly() {
+ assert_eq!(
+ resolve("docs/source.md", "#anchor"),
+ Some("docs/#anchor".into())
+ );
+}
+
+#[test]
+fn reports_missing_and_escaping_links_with_file_and_line() -> Result<()> {
+ let temporary = tempfile::tempdir()?;
+ let root = camino::Utf8Path::from_path(temporary.path()).context("UTF-8 fixture root")?;
+ let dir = cap_std::fs_utf8::Dir::open_ambient_dir(root, cap_std::ambient_authority())?;
+ dir.create_dir_all("docs/rfcs")?;
+ dir.write("docs/rfcs/present.md", "present")?;
+ dir.write(
+ "docs/rfcs/source.md",
+ "[valid](present.md)\n[missing](absent.md)\n[escape](../../../outside.md)\n",
+ )?;
+ let repo = Repo::fixture(root)?;
+ let failures = dangling_in(&repo, "docs/rfcs/source.md")?;
+ ensure!(
+ failures.len() == 2,
+ "fixture result differs from expected contract"
+ );
+ ensure!(
+ failures
+ .first()
+ .context("missing-target diagnostic")?
+ .contains("docs/rfcs/source.md:2 links to absent.md")
+ );
+ ensure!(
+ failures
+ .first()
+ .context("missing-target diagnostic")?
+ .contains("resolves to docs/rfcs/absent.md")
+ );
+ ensure!(
+ failures
+ .get(1)
+ .context("above-root diagnostic")?
+ .contains("docs/rfcs/source.md:3")
+ );
+ ensure!(
+ failures
+ .get(1)
+ .context("above-root diagnostic")?
+ .contains("climbs above the repository root")
+ );
+ ensure!(
+ dangling(&repo)? == failures,
+ "fixture result differs from expected contract"
+ );
+ let missing = dangling_in(&repo, "docs/rfcs/missing.md")
+ .err()
+ .context("missing read fails")?;
+ ensure!(missing.to_string().contains("read docs/rfcs/missing.md"));
+ Ok(())
+}
+
+/// Validate traversal depth independently of the reference model's rendering.
+///
+/// Return `None` when any prefix climbs above the root. Only `reference_path`
+/// uses this test-only helper; it deliberately does not call the resolver.
+fn reference_depth(initial_depth: usize, target: &[String]) -> Option {
+ let mut depth = initial_depth;
+ for segment in target {
+ if segment == ".." {
+ depth = depth.checked_sub(1)?;
+ } else if segment != "." && !segment.is_empty() {
+ depth += 1;
+ }
+ }
+ Some(depth)
+}
+
+/// Model traversal by counting depth first, then reducing matched pairs.
+fn reference_path(directory: &[String], target: &[String]) -> Option {
+ reference_depth(directory.len(), target)?;
+ let mut stack = directory.to_vec();
+ for segment in target {
+ if segment == ".." {
+ stack.truncate(stack.len().saturating_sub(1));
+ } else if segment != "." && !segment.is_empty() {
+ stack.push(segment.clone());
+ }
+ }
+ Some(stack.join("/"))
+}
+
+proptest! {
+ #![proptest_config(ProptestConfig {
+ failure_persistence: Some(Box::new(proptest::test_runner::FileFailurePersistence::Direct(
+ "tests/rfc_stdlib_coverage/links_tests.proptest-regressions",
+ ))),
+ .. ProptestConfig::default()
+ })]
+ #[test]
+ fn resolution_matches_repository_stack_model(
+ directory in proptest::collection::vec("[a-z]{1,5}", 0..6),
+ filename in "[a-z]{1,5}",
+ target in proptest::collection::vec(
+ prop_oneof![Just(String::new()), Just(".".into()), Just("..".into()), "[a-z]{1,5}"],
+ 1..12),
+ fragment in proptest::option::of("[a-z]{1,5}"),
+ ) {
+ let file = directory.iter().cloned().chain([format!("{filename}.md")]).collect::>().join("/");
+ let raw = target.join("/");
+ // A bare fragment is deliberately a direct-resolver special case.
+ let suffix = fragment.map_or_else(String::new, |anchor| format!("#{anchor}"));
+ let linked = format!("{raw}{suffix}");
+ let expected = if raw.is_empty() && !suffix.is_empty() {
+ reference_path(&directory, &[suffix])
+ } else {
+ reference_path(&directory, &target)
+ };
+ prop_assert_eq!(resolve(&file, &linked), expected);
+ }
+}
diff --git a/tests/rfc_stdlib_coverage/map.rs b/tests/rfc_stdlib_coverage/map.rs
new file mode 100644
index 000000000..54a8b18ed
--- /dev/null
+++ b/tests/rfc_stdlib_coverage/map.rs
@@ -0,0 +1,347 @@
+//! Reading and checking RFC 0006's coverage map.
+//!
+//! The map is a table in RFC 0006 section 14.13 with one row per child RFC. It
+//! is simultaneously the artefact RFC 0006 section 6.1 asks for and the anchor
+//! for the ownership bijection: each row names the section 8 subsections a
+//! child RFC owns, and the rows together must partition the accepted set
+//! exactly.
+//!
+//! The `Owns` grammar is deliberately tiny, because a richer one would be a
+//! language nobody reviews. Clause forms, separated by semicolons:
+//!
+//! - `` `8.6` `` — every helper section 8.6 specifies;
+//! - `` `8.6` except `expandvars` `` — all of them but the named one;
+//! - `` `8.7` only `abs` `` — the named one alone.
+//!
+//! It is ASCII on purpose. The document's own section references use `§`, but no
+//! Rust source in this repository does, and a map whose grammar is borrowed from
+//! prose punctuation is harder to review than one that spells out `only` and
+//! `except`.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use anyhow::{Context, Result, ensure};
+
+use super::{RFC_0006, RawRow, Repo, Section, backticked, registries};
+
+/// The heading of the coverage map's subsection in RFC 0006 section 14.
+const MAP_HEADING: &str = "### 14.13. Coverage map";
+
+/// One row of the coverage map.
+pub(super) struct MapRow {
+ /// The RFC number reserved for this child, without the `RFC` prefix.
+ pub(super) number: String,
+ /// Repository-relative path of the child RFC, when it has been written.
+ pub(super) written: Option,
+ /// The helpers this child owns, resolved from the `Owns` clauses.
+ pub(super) owns: Vec,
+ /// The existing helpers this child adds an option to.
+ pub(super) optioned: Vec,
+ /// The roadmap step that delivers the group.
+ pub(super) step: String,
+ /// Whether the child RFC has been written.
+ pub(super) is_written: bool,
+}
+
+impl MapRow {
+ /// Every helper this row claims, owned or optioned.
+ pub(super) fn claims(&self) -> Vec {
+ let mut all = self.owns.clone();
+ all.extend(self.optioned.iter().cloned());
+ all
+ }
+}
+
+/// The parsed coverage map.
+pub(super) struct Map {
+ /// Its rows, in document order.
+ pub(super) rows: Vec,
+}
+
+impl Map {
+ /// The number of rows whose child RFC is still unwritten.
+ pub(super) fn unwritten(&self) -> usize {
+ self.rows.iter().filter(|row| !row.is_written).count()
+ }
+
+ /// Map every claimed helper name to the child that claims it.
+ ///
+ /// Fails when two rows claim the same helper, naming both, which is the
+ /// two-owner control the ownership obligation needs.
+ pub(super) fn ownership(&self) -> Result> {
+ let mut owners: BTreeMap = BTreeMap::new();
+ for row in &self.rows {
+ claim(row, &mut owners)?;
+ }
+ Ok(owners)
+ }
+}
+
+/// Record one row's claims, failing when another row already claimed a name.
+fn claim(row: &MapRow, owners: &mut BTreeMap) -> Result<()> {
+ for name in row.claims() {
+ if let Some(previous) = owners.get(&name)
+ && previous != &row.number
+ {
+ return Err(anyhow::anyhow!(
+ "helper {name} is claimed by both RFC {previous} and RFC {}",
+ row.number
+ ));
+ }
+ owners.insert(name, row.number.clone());
+ }
+ Ok(())
+}
+
+/// Read the coverage map from RFC 0006.
+pub(super) fn parse(repo: &Repo, sections: &BTreeMap>) -> Result