Skip to content

feat(conformance): deterministic conformance coverage model (024) - #375

Merged
pofallon merged 64 commits into
mainfrom
024-deterministic-conformance-coverage
Jul 27, 2026
Merged

feat(conformance): deterministic conformance coverage model (024)#375
pofallon merged 64 commits into
mainfrom
024-deterministic-conformance-coverage

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

Implements specs/024-deterministic-conformance-coverage in full (150 tasks, no deferrals).

The registry already said whether each behavior was covered. This says whether the scenario space is — and where it is not, turns the hole into a counted, dispositioned number instead of an unknown.

What this adds

A scenario model in its own namespace. conformance/registry/scenario.json declares six sdim- dimensions (operation, config-source, container-state, features, layering, output-mode). They deliberately do not join dimensions.json: applies_in_profile treats a condition on an unassigned dimension as unsatisfied, so a scenario dim there would silently drop behaviors out of profile and shrink the very denominator this work exposes (research D1). applicability.json adds 8 rule- exclusions — each with a ground naming the mechanism, not "out of scope" — and 14 hand-selected hrt- high-risk triples.

Two obligation kinds, never multiplied. conformance/obligations/obligations.json is machine-owned, the sole output of coverage generate: obl-bhv-* (one per in-profile behavior) and obl-cmb-* (per-operation pairwise + the selected triples). A behavior is not crossed with the scenario space — that product is the combinatorial explosion the model exists to avoid. Ids are substance-anchored, so a reordered or renamed triple keeps its id and its disposition.

A mandatory disposition per obligation. Every applicable obligation carries exactly one hand-authored odp- record: case, non-testable (+ a rationale naming a ground), waived (+ a scoped waiver with expires), or gap. A high-risk triple accepts only case or gap — an argument may not stand in for evidence where interaction defects hide.

Violation classes V26–V30, all blocking a PR via registry_valid: scenario-model integrity, obligation provenance (a hand edit and a stale regeneration are indistinguishable and both fail), zero-or-multiple dispositions, malformed dispositions, and injected-regression integrity.

The coverage generate|check|report|scaffold command group, dev-only and hermetic. Reporting never gates: coverage report's exit code reflects whether it could write its artifacts, never what they say. certify is the gate.

An injected-regression harness proving the channels are live. coverage-regressions applies each reg- record to the evidence source — a process result, an inspect document, file bytes — and requires the case to go from clean to failing on that channel. Injecting into an observer's return value is forbidden and does not compile: every entry point is generic over a sealed EvidenceSource trait no observer output can implement. inertCount must be zero.

Also: a Docker-backed error-path tier, de-suppression of previously-hidden normalized fields, and cases.json split into cases/<area>.json.

Two findings this machinery produced

Both were real, both were in committed cases, and only the injected run found them:

  • A jsonSubset: {} assertion matches any value — the channel was declared, observed, and incapable of failing.
  • A contains assertion cannot see appended output by construction.

That is also why SC-005 floors every channel at three covering cases: a channel carried by one case is one authoring mistake from unobserved.

Along the way the work surfaced several real deacon defects, fixed here: a containerEnv precedence bug (deacon merged the Feature layer over the configuration's value instead of the reverse — measured against oracle 0.87.0), an unlocked read-modify-write in the disk cache's state index causing container leaks under concurrent up/down, down silently accepting an unreadable config and exiting 0, and a Compose project-name case-sensitivity bug leaking networks.

The end state is deliberate

certify reports NOT certified on 10 gap-pairwise-<operation> records. The feature measures and exposes the residual combination hole; it does not claim to close it. Those gaps are the honest output, not an oversight — closing them is follow-up work, and certify refusing to go green is exactly the behavior the model exists to produce.

Note this means the release gate is currently red on this branch. That is a conscious hand-off, not a regression: before 024 the same hole existed and was simply invisible.

Deferrals

Both spec-documented deferral tasks were genuinely resolved rather than left open:

  • T149 — the naive "mark both environment profiles active" instruction turned out to be structurally impossible (Coverage::evaluate and the validator both use a single-profile .find(|p| p.active) lookup). Modeled prof-linux-amd64-podman-0870 with active: false and added a guard, exactly_one_environment_profile_is_active, rather than forcing a broken state.
  • T150 — landed as real cases and behavior records.

Review notes

The last commit (8772e7c) addresses an xhigh code review of the branch: an unbounded flock on tokio worker threads plus a lost-update on its failure path in the disk cache, a compose path that never applied feature-contributed containerEnv, a presence-mismatch divergence reported under a bare channel id that is un-tolerable by construction, a per-case timeout mislabelled as an oracle timeout, and an obligation-id encoding that was not injective across condition arity. One review finding was rejected on the merits — erroring on a missing --config-path in down is symmetric with up/exec, so only the misleading comment was corrected.

The compose containerEnv fix ships without a conformance case behind it, tracked in #374.

Verification

  • cargo fmt --all -- --check and cargo clippy --all-targets --all-features -- -D warnings clean
  • make test-nextest (full profile): 4037/4037 passing
  • validate, coverage check, certify all behave as designed (certify red on the 10 intended gaps)
  • The live parity lane needs Docker + the pinned oracle and runs in CI

🤖 Generated with Claude Code

https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf

pofallon and others added 30 commits July 26, 2026 01:48
…ity lane

Lands the buildx setup + verification step BEFORE the runner starts depending on
it, so the runner change is never the thing that reddens the lane.

Declarative cases with a `build` operation need BuildKit. The GitHub runner ships
buildx today, so this is mostly an assertion — which is the point. The alternative
was an outcome-conditional assertion inside the case data, tolerating "succeeded OR
failed" because BuildKit's presence was unknown, and that shape was nearly vacuous:
every one of those invocations passes `--output-format json`, and deacon's JSON
ERROR document also contains `"outcome"`, so the assertion held either way.

Establishing the precondition here — and checking it in the harness for local runs,
which follows — dissolves the need for that assertion shape entirely rather than
arguing about how to express it.

The verify step bootstraps the builder and fails loud, so a missing builder cannot
surface later as a `build` case reporting a divergence that is really a missing
builder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UCGzJEFZd85HJqz1Wx2kE
…s (024 T001-T006)

Adds empty module stubs for the scenario/obligation/coverage-report/regression
machinery, wires the dev-only `coverage generate|check|report|scaffold` command
group into the conformance CLI (dispatching to todo!() stubs), and reserves the
V26-V30 rows in the RULES.md violation-class index ahead of the sections later
tasks add. Confirmed cargo run -p deacon-conformance -- validate/certify both
exit 0 on the pre-change baseline (T001).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Mechanical move only: conformance/registry/cases.json becomes
conformance/registry/cases/<area>.json, one file per area, where an area is
the `area` of each case's first linked behavior — the same per-area layout
`behaviors/<area>.json` already uses (024 T007, research Decision 7).

No record content changes: the 88 records are byte-identical, and
concatenating the ten new files and sorting by id reproduces the original
record list exactly. Per-area counts: build 1, exec 4, host-ca 1,
observable-state 9, ports 1, profiles 1, read-configuration 65, secrets 1,
trust 1, up 4 = 88.

The loader change that reads the new layout lands in the next commit (T008),
together with the count guard test (T009), so this commit stays a reviewable
pure move.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
… guard

T008: `Registry::load` reads every `conformance/registry/cases/*.json` instead of
the single `cases.json`. `load_case_files` walks the directory in sorted-filename
order and keeps each record's origin file plus its index WITHIN that file, so the
load-time shape and allowed-difference diagnostics still point at a reviewable
`cases/<area>.json:records[i]` rather than an index into a concatenation. The
concatenated set is then stable-sorted by case id, making the aggregate ordering a
property of the data rather than of how the areas happen to be named or split —
which is exactly the ordering the single id-sorted `cases.json` had.

T009: `every_migrated_case_survives_the_per_area_split` in `registry_valid.rs`
asserts the loaded count is exactly 88, that the set is id-sorted across files, and
that no two records claim the same id. The constant is a deliberate-edit gate, not a
floor: the split's one real risk is silently dropping records, and a smaller
denominator is invisible in every downstream count.

The 24 fixture registries under `fixtures/conformance/` move to the same layout, and
the three test helpers that synthesize or mutate a registry follow.

validate and certify both still exit 0; `make test-nextest-fast` is green
(3587 passed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
T010: `TestCase` gains `scenarioContext`, an ordered map from each `sdim-` scenario
dimension to the value this case exercises. It is a SIBLING of `context`, not a
replacement: `context` stays the environment selector answering "where can this
evidence be gathered?", while `scenarioContext` answers "what does this case
exercise?". They are separate namespaces because `applies_in_profile` treats a
condition on an unassigned environment dimension as unsatisfied, so folding scenario
dimensions into the environment model would silently shrink the very denominator this
feature exists to expose (research Decision 1). The field only has to round-trip here;
well-formedness of the assignment is V26/V16 in US1.

T011: `scenarioContext` joins the case-hash allow-list, so changing what a case
exercises re-records and re-reviews its snapshot rather than silently carrying the old
evidence forward. Key order is insignificant — `canonicalize` sorts it — so
reformatting an assignment never re-records.

The key is omitted when the assignment is empty. That is the one conditional in the
input document and it is deliberate: an empty map declares nothing, the field is
`skip_serializing_if` empty so "absent" and "present but empty" are the same record,
and hashing it would have marked every pre-existing snapshot stale without any input
having changed. Staleness must mean the evidence-determining inputs drifted, never
that the hash function grew a field. `snapshot check` still reports 1 fresh, 0 stale.

validate and certify both still exit 0; 451/451 conformance tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
T012: `behavior_obligation_id` and `combination_obligation_id` derive an obligation's
id from what it IS, following the `clu-` clause precedent — `obl-bhv-<hash8>` over
`behavior ‖ canonical(context)` and `obl-cmb-<hash8>` over `operation ‖
canonical(sorted assignment)`. Reordering records, renaming a file, or moving a
dimension's declaration position leaves an id alone, so a cosmetic edit never orphans
a hand-authored disposition; changing what a combination is does change it, which is a
new obligation needing its own decision.

`hash8` is local rather than the private helpers in `inventory.rs` / `clause.rs`:
those hash a schema pointer and a prose excerpt, so coupling to either would tie
obligation identity to an unrelated record's field set. Only the truncation convention
and the `0x1f` separator are shared. `canonical_context` sorts both the conditions and
each condition's value subset — a context is a conjunction and a condition pins a set,
so neither order carries meaning.

`combination_obligation_id` takes any iterable of pairs and sorts internally, so no
caller can opt out of key-order invariance by picking an order-preserving container.

T013: `assignment_key_order_does_not_change_the_combination_id` builds its two
assignments from `IndexMap`, which preserves insertion order — a function that failed
to sort internally forks visibly there, whereas a `BTreeMap` would have sorted on the
caller's behalf and made the test vacuous. It also asserts the fixtures genuinely
iterate differently before comparing, and re-checks agreement through a `Vec` in a
third order and a `BTreeMap`.

Five further tests cover substance tracking, separator injectivity (a key/value split
and a pair boundary must both be visible to the hash), stability under cosmetic
context reordering, and disjointness of the two id spaces. validate and certify both
still exit 0; 457/457 conformance tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…ation

T014, documentation only. Extends the standing hermetic-conformance convention block
in .config/nextest.toml with this feature's binaries, forward-referencing files later
tasks create — the same style the block already uses — so the classification is
decided once rather than re-litigated per binary.

Three groups: the five conformance-crate coverage guards carry NO override and run in
every profile; `injection_faults` and `prereq_faults` are still hermetic but take an
`fs-heavy` override mirroring `observation_faults`, which bounds their fixture-tree
I/O without admitting a daemon; and `parity_conformance_docker` is the ONLY live
binary, needing per-profile overrides in all six profiles when T020/T021 wire it, or
the non-parity lanes stop being truthful by non-selection. No real overrides are added
here. Also records that the `coverage` command group is a `deacon-conformance` bin
surface, never a shipped `deacon` subcommand.

All six profiles still parse; fmt, clippy, validate and certify are green, and
`make test-nextest-fast` passes 3593 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…wn off the executor

Two defects the resource-group split (024 Block B) would otherwise amplify.

**FR-077b — a per-case bound.** `run_case` now runs under a 5-minute
`tokio::time::timeout` and reports expiry as `HarnessError::CaseTimeout`
naming the case id. nextest's `slow-timeout` is per TEST FUNCTION, so a
driver that owns a whole resource group can only report "the group was
slow" — indistinguishable from a wedged daemon, and naming nothing to fix.
The bound is also strictly wider than the per-invocation bound in `exec`:
a case that hangs BETWEEN invocations (a teardown that never returns, a
snapshot probe against an unresponsive daemon) is invisible to that one.
Abandoning the future drops it, which runs the workspace's RAII guard, so
a timed-out case still reclaims its container, network, volume and temp
directory.

**Principle V — no blocking work on the async executor.** Three call paths
reachable from the (about to become concurrent) Docker driver still ran
blocking `std::process::Command::output` inline:

- `execute_ops` created the isolated temp workspace and recursively copied
  every fixture tree into it inline; now offloaded, with the guard dropping
  on the blocking pool when a fixture is missing.
- three of the four oracle-type paths (`spec-expectation`, `snapshot`,
  `invariant-metamorphic`) held the workspace guard to end of scope, so
  `deacon down` plus several `docker` calls ran on the executor at drop.
- `live_differential` offloaded deacon's teardown but not the reference's.

All now go through one `runner::release_workspace`, which is the pattern
`live_differential` already established for deacon's side. Reclamation
happens as soon as the last observer has read what it needs — every Docker
observer reads the pre-fetched `container_inspect`, never a live container,
so nothing captured afterwards depends on the resources still existing.
The error path is deliberately left to `Drop`: a leaked container is worse
than a stalled runtime on a path that is already failing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…ce group

The declarative runner was ONE `#[tokio::test]` iterating every case
serially, which makes three requirements unreachable (research Decision 4):
`resourceGroup` is declared data on 12 real cases and nextest cannot act on
it (grouping is per test binary/function); a per-case bound cannot be
attributed; and a tier budget needs concurrency, which needs to know which
cases may share a daemon.

The case set is now partitioned by `ResourceGroup`, one driver function per
variant, across two binaries. `parity_conformance_runner` keeps the groups
that need no container runtime — `none` (66 cases) and `fs-heavy`, which
per its own model definition is significant *filesystem* work, not Docker.
The new `parity_conformance_docker` owns `docker-shared` (12 cases) and
`docker-exclusive`. Both call one shared `parity_harness::driver`, so the
binaries are thin and there is no second copy of the reporting logic.

SC-013 survives: `ResourceGroup` is a closed set and every variant now has
a driver, so adding a case with an existing group stays a pure data edit.
Only a genuinely new group would need a new function — a deliberate
infrastructure change, not case authoring. A hermetic unit test asserts the
two binaries partition the enum, so a fifth variant added with no driver
fails rather than being silently driven by nobody.

**Bounded concurrency (FR-077, Principle V).** `drive_group` runs cases
through a `JoinSet` bounded by a semaphore, mirroring the same-named
nextest group: `docker-shared` at 4 (each case has its own isolated temp
workspace, so its `devcontainer.local_folder` label and therefore its
container/network/volume names are unique), `docker-exclusive` at 1. The
config-only groups stay at 1 — no requirement asks for concurrency there,
and perturbing a green lane as a side effect of a reshape is not a
speed-up worth having. Results are collected in completion order and then
id-sorted, so the emitted verdict report stays byte-stable.

**Tier budget (FR-077a, research Decision 10).** The Docker binary measures
its own wall clock and asserts the 30-minute budget EXPLICITLY, reporting
the elapsed number, the groups it spans, and the slowest cases. nextest's
`slow-timeout` is set above it (35m) as a backstop only: a `slow-timeout`
failure says "the binary was slow", which names nothing to fix. The tier
spans sibling nextest processes, so each group records its span into a
per-run artifact tree keyed by `NEXTEST_RUN_ID` — the fold takes
`max(finished) - min(started)`, the true wall clock rather than the sum,
and a stale artifact from an earlier run cannot widen it. A process that
runs before its siblings sees a partial fold, which can only UNDER-report.

**Registry truth (T020).** `registry.json` recorded
`parity_conformance_runner` as `docker_required: false` while 12 of its
cases drove Docker. The split makes the claim true rather than papering
over it, and registers `parity_conformance_docker` with
`docker_required: true`. Baseline enumeration gains an explicit
`POST_FREEZE_PROGRAMS` allow-list: the baseline records a pre-migration
world, so a binary created after the freeze contributes zero units. It is
an allow-list, not a fallthrough — a typo'd registry name is still
`UnknownProgram`.

A per-case timeout is recorded as that case's failure and the group keeps
going; every other `HarnessError` aborts the group fail-loud, because those
recur for every case and continuing would restate one environmental fault N
times. An empty group is not a silent skip: it still writes the fragment
that proves the binary ran, and says so on stderr.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…command boundary

Two structural checks in `parity_registry_check`, hermetic and selected in
every lane, so both facts fail on the PR that breaks them rather than in
the parity lane weeks later.

**The Docker driver is wired in all three places.** `check_nextest_profiles`
already enforces registry/profile agreement generically, so why name one
binary? Because this is the binary whose absence is plausible. It was split
out of `parity_conformance_runner` precisely so the Docker-backed resource
groups could be scheduled and budgeted separately, and a half-landed split
— source file present, registry entry or profile filter missing — reads as
"the Docker cases are covered" while nothing selects them under
`--profile parity`, or while a hermetic lane silently picks up a binary
that needs a daemon. The generic check reports that as one line in a list;
this reports it as what it is. It also asserts the SIBLING's claim: if
`parity_conformance_runner` were still `docker_required`, the split bought
nothing and the registry would be lying in exactly the way T020 fixed.

**No coverage/regression command reaches the shipped CLI (Constitution II).**
`coverage` and the regression tooling are `deacon-conformance` surfaces —
contributor machinery about how deacon is tested, not consumer
functionality the containers.dev spec describes. The existing check walks
the top-level `Commands:` column only, which was sufficient while every
dev-only tool was a top-level group; `coverage` has subcommands, and the
cheapest way to leak one is to hang it off an existing consumer command
rather than add a top-level entry. So this walks one level deeper, and
carries a positive control (`up` must be found) so a parse that silently
matched nothing cannot pass vacuously.

Both functions are authored in `FUNCTION_UNITS`, as 023 US4 did for its
seven fault-injection guards: baseline enumeration must describe every
`#[test]` it finds, so a guard with no recorded assertion is a hard error
rather than an undescribed unit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
… evaluator

Introduces the `sdim-` / `rule-` / `hrt-` record shapes and the applicability
evaluator in a new `scenario` module (024 T031-T033).

Scenario dimensions get their OWN namespace and must never join `dimensions.json`:
`applies_in_profile` treats a condition on an unassigned environment dimension as
unsatisfied, so folding scenario dimensions into the environment model would silently
drop behaviors out of profile and shrink the very denominator this feature exposes
(research Decision 1).

`Condition` is reused verbatim; only the evaluator is new. A rule constrains only the
dimensions it names, a partial combination missing a named dimension is inconclusive
rather than excluded, and a conditionless rule excludes nothing — treating it as
excluding everything would turn one malformed record into an empty denominator, the
failure mode most likely to look like success.

Also registers the four new id prefixes on `RecordType` so V2's grammar, prefix-type
agreement, and cross-registry uniqueness cover them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…ty rules

Adds `scenario.json` / `applicability.json` loading to the registry loader and authors
both records (024 T034-T036).

The six required dimensions carry the FR-005 - FR-009 minimum value sets. Eight
exclusion rules prune the space, each carrying a ground that names a mechanism rather
than asserting its own exclusion:

- no container state for the six operations that never create or attach to one;
- no Feature composition for teardown, scaffolding, and diagnostics;
- no Feature ordering or lockfile resolution for `exec`, which attaches to a container
  another operation created;
- no image-metadata layer for operations that never resolve an image;
- no layered configuration for `doctor`;
- output-mode exclusions for the operations that emit only one form (`exec` streams the
  invoked command and emits no result document; `read-configuration`/`up` emit only a
  JSON document; `down`/`run-user-commands` emit none).

Note the deliberate divergence from data-model.md's illustrative rule, which excludes
only the three container-ful states rather than all four: excluding `none` as well would
leave `read-configuration`, `build`, and `doctor` with NO valid total assignment, and a
case must assign every dimension (data-model.md §3). The dimension therefore survives
with one value rather than being pruned outright.

`applicability.json` carries two record kinds in one document, so it needs its own file
struct (the `MappingFile` precedent) rather than the `Collection` envelope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Implements the contracts/obligation.md 11-step generation algorithm (024 T037-T038):
per-operation pairwise enumeration over applicable dimensions, then hand-selected
high-risk triples, then one obligation per behavior, all sorted by id.

Two decisions worth stating, both about not multiplying the denominator:

- The full Cartesian product is never materialized. Pairs come directly from the
  two-dimension cross product of PERMITTED values, after dimension pruning — which is
  what makes the space tractable without a covering-array minimizer (research D3).
- Each behavior yields EXACTLY ONE obligation. An empty applicability is one universal
  context (zero obligations would erase the behavior from the denominator); a non-empty
  one is itself the context, because each condition pins a value SUBSET meaning "any of
  these", and expanding a subset into one obligation per value would multiply the two
  obligation kinds against the environment model — the multiplication FR-019 forbids.

Generation is refused rather than guessed when the registry declares no spec-kind
revision: an inventory with an invented pin would have no provenance at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…n provenance

V26 (registry-only, so it runs on every PR via `validate`): dead dimension values, empty
or duplicated value sets, a missing required dimension, a rule naming an unknown
dimension or value, a rule with fewer than two conditions or no real ground, a malformed
high-risk triple, and a case `scenarioContext` that is partial, undeclared, or itself an
excluded combination.

V27 (needs the committed inventory, so it joins V14/V17 in the sibling-resolved pass):
committed != regenerated, a revision that names no spec-kind record, and an obligation
referencing a value the registry no longer declares. A registry that declares a scenario
model but ships no inventory is a violation, not a skip — an absent file would otherwise
read as "nothing to check".

A registry declaring no scenario dimensions opts out of V26 entirely, so the fixture
registries that predate this feature stay silent rather than reporting six missing
dimensions each.

The ground check reuses V23's filler vocabulary rather than forking a second one, but
deliberately NOT `names_an_exclusion_ground`, whose markers ("constitution",
"principle", "authoring") are tuned for permanent out-of-scope claims: an applicability
ground argues from a mechanism, so requiring those markers would reject every correct
ground.

Both are free functions rather than further `Checker` methods — `validate.rs` is already
3,300 lines and Principle V puts new logic in a new boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…he coverage CLI

Wires the `coverage generate|check|report` handlers (024 T039-T040, T043-T048) and
commits the generated obligation inventory (T051): 745 obligations — 718 combination
pairs across the ten operations, plus 27 behavior obligations.

Coverage matching (T043) follows contracts/scenario-model.md exactly: a pair is covered
iff a case's `scenarioContext` matches both values under the SAME operation and the case
is executable — a legacy carrier counts only while an open residual keeps it alive, so
coverage decays with the residual rather than outliving it. Behavior obligations reuse
the established per-behavior coverage rather than a second, forkable evaluator, so the
denominator `certify` measures and the one this feature adds can never disagree.

Buckets are the five FR-026 ones plus `undispositioned` — the honest sixth state for an
obligation carrying no explicit disposition yet (User Story 2's job). Folding it into
`gap` would overstate what is known; folding it into `covered` would understate the hole.

Three report families ship now; `coverage-triples` waits for the `hrt-` records it
renders, since an empty triples document would report a complete triple set nobody chose.
Each .md is rendered from the same in-memory model as its .json, so the human-readable
artifact and the machine-readable one cannot disagree about coverage.

Two report fields are DERIVED because the record does not carry them yet, and say so:
input classes (`boundary`/`unsupported` have no derivable signal and are therefore always
reported missing) and per-operation case attribution (from the subcommands a case
actually invokes, which keeps the report informative for the 88 cases predating the
scenario model). Against the committed record it reports 6 channels below the
three-case floor and 7 operations with zero cases — the measured shape of the hole.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…igation determinism guard

Eight acceptance tests (024 T023-T030) covering every US1 scenario, plus the hermetic
`coverage check` equivalent in `registry_valid` (T049) so committed-vs-regenerated drift
fails on every PR.

Two of the tests deliberately RE-IMPLEMENT the applicability predicate and the pair
enumeration rather than calling the production evaluator: asserting
`scenario::is_invalid` against `scenario::is_invalid` would be a tautology, and a second
independent reading of the same records is what makes it evidence. The comparison runs
in both directions — a dropped valid pair understates the hole and a kept forbidden pair
makes the denominator unreachable, and both look like progress from inside the report.

The T030 guard fingerprints the whole `conformance/` tree before and after a real
`coverage generate` run, so "writes only obligations.json" covers files the test never
thought to name.

Also fixes an unrelated failure from ccd479c: registering `parity_conformance_docker`
took the live-binary count to 7 while `parity_corpus`'s assertion still expected 6.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…nce (V26-V27)

Adds the RULES.md section the V26/V27 index rows already link to (024 T050), and marks
T023-T051 complete in tasks.md.

Records four things that are easy to get wrong later:

- why scenario dimensions must never join `dimensions.json`, and that the failure would
  be INVISIBLE (certify counts only in-profile behaviors, so the denominator would shrink
  and stay green);
- why exclusion must stay attributable — collapse "absent because impossible" into
  "absent because nobody wrote it" and any missing combination can be explained away,
  which makes the denominator unfalsifiable;
- the deliberate divergence from data-model.md §2's illustrative rule: excluding `none`
  as well as the three container-ful states would leave read-configuration/build/doctor
  with no valid total assignment at all, and a case must assign every dimension;
- why a hand edit and a stale regeneration are indistinguishable to V27, and both fail —
  there is no way to edit the generated file that the check treats as legitimate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
T015–T022 marked done with what was verified, in the annotation style
T001/T007–T014 already use. Notes the two things that were not obvious
going in: `fs-heavy` belongs to the config-only binary (its own model
definition says "no Docker"), and registering a seventh live binary
required an explicit post-freeze allow-list in baseline enumeration,
because the frozen baseline records a pre-migration world a new binary
was never part of.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…s explicit resolution

Adds the hand-authored `odp-` half of the obligation model (024 US2, T061-T063):
`conformance/registry/obligation-dispositions/<area>.json`, loaded exactly like
`behaviors/` and `classifications/` (same envelope, same per-file split, so adding
an area is a file rather than a code change).

The exactly-one-of arity across `cases`/`rationale`/`waiver`/`gap` is enforced at
DESERIALIZE time via a raw intermediate + `TryFrom`, following `residual.rs`: a
record whose payload disagrees with its disposition word is not a nuance for a
validation pass to interpret, it is a half-stated judgement, and the only honest
reading is to refuse it at the door. V28 then owns arity ACROSS records (how many
dispositions one obligation has) and V29 owns semantics -- questions that genuinely
need the rest of the registry to answer. The same deserializer refuses a blank
payload and the `UNREVIEWED` scaffold sentinel (T070), so a skeleton committed
unedited cannot load.

Resolution is explicit only -- no inheritance, no default, and no winner picked from
a conflict: silently preferring one of two records would convert a disagreement
between reviewers into a decision neither made. `audit_dispositions` is the single
join behind V28, `certify`'s blocking set, and `coverage scaffold`'s worklist, for
the reason `join_inventory` exists: if the scaffold's idea of "undispositioned"
could drift from V28's, the workflow would loop forever.

`evaluate_obligations` now consults dispositions with a stated precedence: the
DERIVED inactive-environment bucket first (an author must not be able to retire an
obligation by declaring its environment inactive), then the single explicit record,
then the evidence. The last step is not a disposition default -- V28 fires on an
undispositioned obligation whatever bucket the report prints -- it is only what a
report can honestly say in the interval between generating an obligation and
judging it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Replaces the T003 `todo!()` stub (024 US2, T069). Emits skeleton `odp-` records to
STDOUT for every undispositioned APPLICABLE obligation, reading its worklist from
the same `audit_dispositions` join V28 will enforce, so filling in everything the
scaffold emits is guaranteed to clear the gate.

Every skeleton carries the `UNREVIEWED` sentinel twice. `disposition` carries it so
the closed enum refuses the record -- which forces the author to pick one of the four
words rather than inherit a default; `rationale` carries it because
contracts/coverage-cli.md names that field, and because a record still holding it
after a disposition was chosen is exactly the shape V29's filler test rejects.

Never writes the registry, and exits 0 even when nothing is undispositioned: making
"nothing to do" an error would put the workflow's terminal state in the failure
channel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Authors the initial `obligation-dispositions/<area>.json` tree (024 US2, T071): one
`odp-` record for each of the 745 committed obligations, so the queue V28 will
enforce starts at zero rather than at 745.

- **27 behavior obligations -> `case`**, pointing at the cases already linked to
  each behavior in `cases/<area>.json`. Nothing new is claimed here: the links
  predate this feature and back `certify`'s existing behavior-level coverage. All 27
  have at least one, so none needed a waiver or a gap.
- **718 combination obligations -> `gap`.** No committed case declares a
  `scenarioContext` yet (that is US3-US5), so not one pair can honestly be
  dispositioned `case`, and `non-testable` would be an argument standing in for
  evidence nobody has. `gap` is the honest word.

The 718 records point at **ten** shared `gap-` records, one per operation, rather
than 718 individual ones: the gap is a single fact per operation ("no case enters
this operation's pair space"), and 718 restatements of it would be noise a reviewer
cannot read. Each names what is missing and links the tracking phase. They carry no
`behaviors`: this is missing coverage in the combination space, not a claim about
any behavior, and linking one would flip that behavior's coverage state and R7
disposition for a reason that has nothing to do with it.

The records were generated mechanically from the committed inventory (obligation ->
gap is determined purely by the obligation's `operation`), never hand-typed, for the
same reason `coverage generate` exists.

**`certify` now exits 1 with ten blocking gaps, and that is the checkpoint, not a
regression**: "undispositioned work blocks the release" is what US2 delivers, and
watering a disposition down to reach green is precisely the move the whole feature
exists to prevent. `validate` stays exit 0. The one test asserting the real registry
certified with an EMPTY blocking set is retargeted at the invariant that still
holds and is the one worth guarding: nothing but a declared coverage gap blocks --
no uncovered behavior, no constraint/clause join violation, nothing hiding behind
them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…ispositions

Implements the obligation half of the gate (024 US2, T064-T068).

**V28 (arity across records)**: an applicable obligation with zero dispositions, or
with more than one. Zero is the state nobody chose -- the diagnostic names the
obligation, its behavior or operation+assignment, and its context, so it is
actionable without opening the machine-owned inventory. More than one is two
judgements: resolution never picks a winner, because doing so would turn a
disagreement between reviewers into a decision neither made.

**V29 (semantics)**: a `non-testable` rationale that names no ground (the same
ground-naming test V23 applies to `outOfScopeRationale` -- a bare restatement is
indistinguishable from unqueued debt, which is what a gap is for); a high-risk
triple argued rather than tested (FR-015 -- triples are selected precisely because
interaction defects hide there); a stale record whose obligation no longer resolves;
a payload naming a case, waiver, or gap the registry does not declare; and a
`waived` record backed by a BLANKET waiver scope.

That last one is the FR-023 analogue of the FR-032 rule V19 enforces on an allowed
difference. `Scope` is a closed union and only `StateField` can be widened -- its
`field` matches exactly or as a trailing-`*` prefix, so a bare `"*"` matches every
field of the fixture: a global ignore wearing a waiver's clothes, and one that could
never self-invalidate. `CorpusCase` names one case of one corpus and has no wildcard
form, so it has no blanket shape to reject; inventing one for symmetry would reject
correct records.

Both classes are computed from the SAME `audit_dispositions` join `coverage
scaffold` emits from, so filling in what the scaffold prints is guaranteed to clear
the gate. Both are scoped to registries that declare a scenario model, exactly as
V27 is: a pre-024 fixture never opted into the obligation regime, and without the
scoping every one of them would suddenly owe a decision per behavior.

`certify` gains `BlockingKind::Obligation` carrying the class in `code` -- the shape
`Constraint` and `Clause` already use, so the output does not fork into a third --
plus a `today` parameter for the one date-dependent condition: a `waived`
disposition whose waiver has expired (V6, SC-009). An expired waiver has never
blocked certification on its own, and still does not; what changed is that when a
waiver is the ONLY thing standing between an obligation and "undispositioned", its
expiry leaves nothing standing there. A `gap` disposition gets no blocker of its
own: it blocks through the `gap-` record it names, which V29 already requires to
resolve, and listing it twice would double-count one fact.

The five FR-026 buckets plus the undispositioned queue are reported ALONGSIDE the
behavior-level numbers, never folded into them: the two denominators answer
different questions, and collapsing them would let progress on one hide the absence
of progress on the other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…tures

Covers every User Story 2 acceptance scenario (024, T052-T060; FR-071, FR-078):
undispositioned blocks and is NAMED (with its operation and assignment, or its
behavior and context); `gap` blocks; a waiver expiring before `--today` blocks and
names the waiver; unexpired `waived` and `non-testable` neither block nor vanish;
two dispositions on one obligation fail; a filler rationale fails; adding a behavior
without dispositioning its obligation fails; a disposition whose obligation no
longer resolves is stale. Plus the triple-argued and blanket-waiver-scope arms of
V29, and a scoping test proving a pre-024 registry owes nothing.

The fixtures are `base/` plus per-scenario overlay directories rather than nine
complete registries: nine copies of a twelve-file registry would bury each
scenario's one interesting edit in several hundred identical lines, and the review
question is always "what one thing changed, and does the gate notice?". The
obligation inventory is generated at test time because it is a pure function of the
registry (V27 says so), so a committed copy per variant would be a second thing to
keep in sync with no independent authority.

Three choices worth naming:

- The tests drive the LIBRARY gate, not the binary. The binary validates before
  certifying and exits 1 on any violation, so every scenario here would "fail
  certification" whether or not the obligation gate existed. Asserting on the
  verdict's SHAPE -- which blocker, which class, which record -- is what separates a
  working gate from a registry that merely fails.
- `stale` is driven by renaming a dimension VALUE, not by hand-writing a record that
  names a nonexistent obligation. Both produce a stale record; only the model edit
  shows the mechanism the drift workflow actually meets, and it simultaneously
  proves the other half -- the re-hashed obligations come back undispositioned,
  because disposition is never inherited by name.
- Every "the gate fires" assertion is paired with a control. `base` is clean and
  certifies (so a check that fired unconditionally would not pass), the filler
  rationale is contrasted with nine grounded ones of the same disposition kind, and
  the blanket waiver is contrasted with a specific-scope waiver.

One behavior changed to make the conflict test true rather than to make it pass: a
conflicting obligation now buckets `undispositioned` instead of falling through to
the evidence. The evidence fallback answers "nobody has looked at this yet", and
someone plainly has -- twice, incompatibly. Letting the evidence speak would report
a conflict as settled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Adds the `## Obligation dispositions (V28 - V29)` section the T004 violation-class
index already links to, keeping the `validate.rs` <-> `RULES.md` lockstep the index
exists to make checkable (024 US2, T072).

Restates gap vs. waiver for obligations, where the vocabulary gains a third option
the behavior-level distinction does not have. The three are not interchangeable, and
`non-testable` is the only one admitting NO follow-up -- which is why its rationale
is held to V23's ground-naming test. The reverse mistake gets equal weight: reaching
for `gap` when the difference is already characterized inflates the blocker list with
things nobody intends to change, and a blocker list that is mostly noise stops being
read.

Also records the triple restriction (an argument is the one thing the model does not
accept where interaction defects hide), why explicit-only resolution picks no winner
from a conflict, why `inactive-environment` outranks even an explicit record, why a
stale disposition is reported rather than dropped, and why a `gap` disposition gets
no blocker of its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Records what each task landed as, including the three places the implementation
differs from the task text and why: the fixtures live at
`fixtures/conformance/obligation-gating/` (where all 24 sibling conformance fixture
registries live) rather than under `crates/`; the `UNREVIEWED` rejection sits in the
`odp-` deserializer rather than `load.rs`, next to the record's other field rules;
and V29's "all executable" check does not reuse `executable_case_ids`, whose
residual-carrier decay rule would declare six live integration binaries nonexistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…low (024 US3)

Fills the hole User Story 2 froze in place: 718 combination obligations, every one
dispositioned `gap`, because no committed case declared a `scenarioContext`.

Data
- 14 hand-selected high-risk triples (`hrt-`) in `applicability.json`, each with a
  stated reason a reviewer can argue with, and each covered by an executable case —
  FR-015 makes the triple set the one place an argument may not stand in for evidence.
- 81 new cases across all ten operations, taking the seven with ZERO cases
  (`build`, `run-user-commands`, `down`, `outdated`, `upgrade`, `templates apply`,
  `doctor`) to real coverage, and giving every operation a case in each of the five
  input classes and each permitted configuration source.
- 77 pre-US3 declarative cases annotated with the `scenarioContext` they always
  exercised. Four are deliberately left unannotated: the `bad-config-path` /
  `missing-config` families carry no configuration document whose shape could be
  recorded, and annotating `case-readconfig-snapshot` would invalidate its committed
  snapshot for a record whose inputs did not change.
- 18 behaviors, all from research Decision 9 source (a) — the observable contract of a
  previously-uncovered operation — each traced to a source unit and registered in
  `POST_BRANCH_BEHAVIORS` with why it is not a variant of an existing claim.
- 4 waivers and 2 extensions for divergences measured against the pinned oracle
  0.87.0: the reference does not search the spec's `.devcontainer/<folder>/`
  discovery location, accepts a value outside a closed schema enum, reports an empty
  `outdated` table for a Feature an extends chain contributes, and fails `upgrade` on
  a configuration with no Features.

Result: 315/732 combination obligations covered (from 0), 45/45 behavior obligations,
14/14 triples, 0 undispositioned. `missingInputClasses` and `missingConfigSources` are
empty for all ten operations.

Code
- `TestCase.inputClass`: declared rather than inferred. Inference could only ever
  distinguish three of the five classes, so `boundary` and `unsupported` were reported
  permanently missing however many such cases existed.
- `coverage-triples` renderer (contracts/coverage-report.md §2), joined to generation
  on the substance-anchored obligation id rather than by position.
- `coverage-operations` gains `differentialAvailable` / `differentialSubstitution`, so
  an operation with no runnable differential says why instead of looking under-tested.
  `reference-lenient` is then not a permitted class there — demanding a case that
  cannot be written turns an honest measure into noise.
- V16: a `reference-lenient` case must be a `live-differential`; an operation with no
  runnable differential must not declare one. V26: a case's declared operation must be
  one it actually invokes. V29: a triple's covering case must be EXECUTABLE.
- `outdated` and `upgrade` join `CONSUMER_SUBCOMMANDS`; `templates-apply` expands to
  the two-word command both CLIs actually take.
- `fs-heavy` cases now get an isolated temp workspace with temp-dir-only cleanup: the
  group means "significant filesystem operations", and those must not land in the
  committed fixture tree every other case reads.

Retired, both for the reason V25 was retired
- V21's reverse-orphan direction ("every declarative case is reached by a mapping
  entry"). True only while the case set WAS the migration's output; a case authored
  for coverage the migration never had maps from no baseline unit by construction, so
  the rule would forbid exactly the growth the migration exists to make room for.
- `post_branch_exceptions` now covers waivers as well as extensions, so the exemption
  depends on when a fact was learned rather than on which mechanism an author reached
  for.

Also: the report's dead-value liveness is computed from the pair enumeration only, so
a triple naming a value the pair space cannot reach can no longer rescue it — that is
a V26 modelling mistake, and the report must not be the one that hides it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…-review

Six hermetic acceptance tests in `crates/conformance/tests/workflow_coverage.rs`, all
reading the REAL committed registry so none can pass against a synthetic model:

- T073 refuses all three shapes of "skipped" — no driver selects it (the ResourceGroup
  partition asserted total AND both drivers non-empty, so one binary's selection cannot
  go dead), nothing can observe it, its evidence cannot be gathered. An `#[ignore]`-style
  opt-out is deliberately not asserted: the record has no field that could express one.
- T074 reads the SAME report a reviewer reads. A test that recomputed the tallies would
  pass while the report said something else, and the report is where the claim is made.
- T075 matches a lenient case's direction-pinning twin on a SHARED BEHAVIOR, not a name
  suffix — a naming convention can be satisfied by a file rename.
- T076 asserts both halves: no differential is claimed where none can run, and the report
  states why. Without the second, such an operation just reads as under-tested.
- T077 asserts per case, not in aggregate: an aggregate failure names the suite, and the
  suite is not what needs fixing. Repetition is the live tier's job; what is checkable
  hermetically is what repetition would expose.
- T078 catches the failure V29 cannot: a triple dispositioned `gap` — honest, permitted
  by FR-015, and still leaving SC-003 unmet. It caught exactly that during authoring.

`conformance/RULES.md`: records the retirement of V21's reverse orphan-*case* direction
and the post-branch exception exemption now covering waivers as well as extensions, and
states that a triple's covering case must be executable.

T095: of the 156 `non-testable` clause classifications, only the 56 carrying "pending a
dedicated conformance behavior" are the reservoir Decision 9 names — the other 100 are
descriptive prose whose ground is unchanged. Eight are re-classified `behavior-mapped`
where a US3 behavior now covers them: the lifecycle sequence and its per-command exit
requirement, the lockfile's name/version/integrity/resolved fields, and `dependsOn`
install-before plus the tool's responsibility to calculate an order. The round-priority
ALGORITHM clauses are deliberately left: the new cases pin the resulting order, not the
intermediate rounds, and mapping them would claim evidence for steps nothing observes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…/T102)

Adds the `errorPathTier` marker to the declarative case shape and extends V16 to
check that a case claiming it can actually reach a verdict past configuration read.

`errorPathTier` is a DECLARED boolean rather than a predicate over
`expectFailurePhase`, because a predicate gets it wrong in both directions:
`case-down-removes-container` declares `expectFailurePhase: exec` on a verification
step and is not an error-path case, while a genuine tier case whose phase was left
off would silently leave the tier with nothing to notice. Declaring membership makes
each of those a validation failure instead. The phase stays the single record of
WHERE the failure occurs (FR-042) — the boolean adds only the claim of membership.

V16 gains four rules. A tier case must (1) declare a later-stage `expectFailurePhase`
on some operation, (2) declare `config-resolution` on none — a verdict reachable at
configuration read is a verdict about the stage the tier exists to look past — and
(3) be Docker-backed, since every later stage needs the runtime and a case without
the group also gets no isolated workspace and no cleanup guard. Rule (4) governs
EVERY declarative case: a declared phase must be one its subcommand can reach, via
the closed `phases_reachable_by` mapping. `read-configuration` reaches exactly one
phase, so a case declaring `lifecycle:postCreate` on it describes a run that cannot
happen; unchecked it would validate cleanly, run, and report a green nothing.

`down` deliberately stops at `config-resolution`: teardown owns no phase in the
closed set (022 data-model §8, reused rather than extended), so a teardown
error-path case declares its failure on the operation that OBSERVES the teardown —
the shape `case-down-removes-container` already uses.

`errorPathTier` is excluded from `caseHash` for the same reason as `inputClass`: it
classifies a case, it does not change a byte the runner feeds the CLI, so joining
the tier must never re-record a snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…h side's outcome

Two harness defects the error-path tier surfaced by being the first coverage whose
cases compare little else but an exit code.

**An exit-code divergence could not be tolerated at all.** `diff_paths` emits the BARE
channel id when two sides' evidence is a differing scalar — there is no key to descend
into — and a bare channel id is exactly what `AllowedDifference::is_global_ignore`
rejects (FR-032). So every `observablePath` a case could legally declare was
unreachable, and the four committed tolerances spelling `chan-exit-code.exitCode` were
inert: three `case-*-reference-lenient-wrong-type` cases were diverging in the live lane
against tolerances their authors reasonably believed applied.

Fixed by NAMING the observable rather than weakening FR-032. A channel in the new
`SCALAR_OBSERVABLE` set carries exactly one value, so `chan-exit-code.exitCode` is fully
specific — it is the whole of that channel because that channel is one thing, not
because the path matches everything. `chan-stdout`/`chan-stderr` are deliberately absent:
they carry arbitrary text, and a tolerance over the whole of one is a wildcard by any
reading, so their diverging path stays the bare id that no loadable path can cover.

**A verdict recorded a stage nobody declared, and only one side's outcome.**
`attach_failure_phase` carried the coarse subcommand inference, which maps EVERY
`run-user-commands` failure to `exec` — so a case pinning `lifecycle:postStart` had its
own recorded stage contradicted by the report it appeared in. The DECLARED phase now
wins (it is the reviewed record), with `inferredFailurePhase` kept alongside when the two
differ so nothing is hidden. And a new `sides` block records each side's exit code and
whether it failed: a differential verdict said only THAT the two disagree, while FR-042
asks the error-path tier to record what each side did.

Also exposes three seams the tier's acceptance tests need: `prereq::probe_docker`,
`oracle::verify_binary`, and `DriverConfig::run_config`. A test cannot drive the
env-var resolution those two wrap, because `std::env::set_var` is `unsafe` under this
edition and `unsafe_code = "deny"` — quite apart from being process-global and hostile
to a parallel runner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
pofallon and others added 22 commits July 26, 2026 21:43
The dev-only program that proves each observable channel is live. For every `reg-`
record it runs each candidate case twice — once clean, once with the record's
perturbation applied at the evidence-source boundary — and classifies the record
`detected` when a case that was clean before is red after, ON THAT RECORD'S
CHANNEL. It writes the byte-stable `target/conformance/regressions.json` and exits
non-zero on any inert channel (FR-067).

This is the only program that can inject: it takes out the process-level
capability `inject::activate` requires, which no other program does.

Four decisions worth stating:

- Baselines are cached PER CASE, not per record. One Docker `up` exercises four
  channels; re-running it once per record would multiply the tier's cost without
  adding evidence.
- Detection requires a CLEAN baseline. A case already failing on the channel is
  reported as a note and does not count — its post-injection failure is not
  evidence the injection caused anything, and counting it would let a pre-existing
  divergence certify a channel nobody perturbed.
- A perturbation that never landed aborts the run naming the record, rather than
  becoming an `inert` verdict about the channel.
- A FILTERED run refuses to write the canonical report. A partial document at the
  canonical path would read as a complete run that happened to exercise one
  channel, which is the same category of lie the whole feature exists to prevent.

The exit rule lives on `RegressionReport::exit_status`, so the acceptance test
asserts the decision this bin makes rather than a restatement of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Running the injected-regression harness for the first time reported
`chan-process-graph` and `chan-stderr` INERT. In both cases the perturbation was
sound and the CASE was not — which is the finding this harness exists to produce.

`case-up-docker-channels` asserted `chan-process-graph` with `jsonSubset: {}`. An
empty subset matches any graph, so the channel was declared, observed, compared,
and structurally incapable of failing: the mount/network/volume graph could have
been anything at all and the case would still have been green. It now pins the
graph's headline claim — the workspace bind (source, type, read-write) and the
network the container joined.

`case-errors-decl-malformed-json` asserted `chan-stderr` with `contains`. A
`contains` cannot see APPENDED output by construction, so trailing bytes after the
diagnostic — a stray print, a partially written second error, a buffer flushed
late — were invisible on the one channel whose whole job is diagnostics. The
assertion is now anchored at end-of-text: the parse diagnostic must be the LAST
thing deacon writes.

Neither change weakens what the cases already asserted; both add a property that a
plausible defect could violate. Verified against live Docker and the pinned 0.87.0
oracle: `cargo run -p parity-harness --bin coverage-regressions` reports all
eleven channels `detected`, `inertCount: 0`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Adds `make test-parity-regressions` and wires the same run into
`parity / live-certification`, after the comparison and the aggregator so a real
divergence is still reported on its own terms first.

Deliberately without `continue-on-error`. The two steps answer different
questions: the comparison asks whether deacon matches the reference, this asks
whether the suite that says so is capable of noticing that it does not. An inert
channel means every green result resting on that channel was unearned, which is a
worse state than a red comparison — so it fails the lane exactly as a divergence
does. The report is uploaded as its own artifact for diagnosis on red runs.

`make test-parity-regressions` is a separate target from `make test-parity` for
the same reason `test-parity-equivalence` is: it is a question you ask about the
harness, not a step in comparing against the reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
`injection_faults.rs` is the suite that watches the watchman. The
injected-regression harness is the only thing standing between a dead observable
channel and a trusted green conformance run, so the ways IT can be wrong are
exactly the ways a green suite becomes worthless.

Six guards, one per failure mode:

- an injected regression fails the run and the verdict names the CHANNEL. A run
  that merely goes red proves something broke; the record's claim is that THIS
  channel noticed.
- an inert channel makes the run exit non-zero (FR-067), asserted through
  `RegressionReport::exit_status` — the same decision the bin makes, not a
  restatement of it. Plus: every channel with an observer has a committed record,
  so an acceptance run can never report `inertCount: 0` about channels it never
  exercised.
- the tree is byte-identical after a run, including after an UNWIND (FR-066). The
  panic path is the one that matters: `Drop`, not the happy path, is what carries
  the guarantee.
- the same inputs classify identically twice (FR-069). A classification that
  varies run to run would make `detected` a coin flip and let an `inert` result be
  re-rolled away.
- a DEAD observer — constructed here deliberately, returning a constant whatever
  the source says — is reported `inert`, not `detected`. The test then asserts the
  raw source really did change, which is the proof the injection point is upstream:
  had the harness perturbed the observer's RETURN value, this observer would have
  reported `detected` while observing nothing (research Decision 5, FR-065b).
- an ordinary run cannot apply a regression (FR-070), asserted structurally rather
  than by convention. Enabling injection requires `RegressionHarness::declare()`,
  and a source scan asserts that call exists in exactly one program; the drivers
  and the harness modules they route through are additionally asserted not to name
  `activate` / `perturb_source` at all. Their single contact point is
  `inject::intercept`, and a runtime check confirms it leaves captured evidence
  byte-identical when nothing is armed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…ments (T150 batch 1)

Every remaining `non-testable` clause classification carried one of two boilerplate
rationales ("Descriptive/informative prose..." or "...pending a dedicated conformance
behavior"), which is the filler-rationale failure V23/V29 reject elsewhere in this
registry. T150 re-reviews each against the clause's actual excerpt.

This batch covers supporting-tools, lockfile, features-user-env, parallel-lifecycle,
declarative-secrets, secrets-support, gpu-host-requirement, features-legacy-ids and
devcontainer-id-variable (37 clauses):

- 9 flipped to `behavior-mapped` on EXISTING behaviors, so the denominator does not
  grow and every flip is evidence-backed: the parallel-lifecycle object form and its
  per-command success rule (bhv-up-lifecycle-command-forms,
  bhv-run-user-commands-hook-failure), the secrets-file mechanism and its JSON example
  (bhv-secrets-dotenv-superset), and the four hostRequirements.gpu property-shape
  clauses (bhv-readconfig-basic-parse, matching how their `cst-` schema twins are
  already classified).
- 11 re-dispositioned `not-applicable`: the tool-catalogue and editor-owned entries in
  supporting-tools, and the whole features-legacy-ids document, whose `legacyIds` /
  `deprecated` schema twins are already not-applicable under constitution II.
- 17 keep `non-testable` with a restated ground naming a specific mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…and reference (T150 batch 2)

27 clauses re-reviewed against their actual excerpts (image-metadata 8,
features-lifecycle-scripts 3, devcontainer-reference 16):

- 20 flipped to `behavior-mapped` on EXISTING behaviors. The largest group is the one
  024 US5 unblocked by de-suppressing named fields: "container mounts, environment
  variables, and user configuration should be applied at [container creation]" now has
  three behaviors that observe exactly those three axes, and "per variable, last value
  wins" is precisely bhv-up-container-env-merge-precedence. The image-metadata merge
  rows join their already-mapped siblings on bhv-readconfig-merged-configuration, and
  the metadata structure joins bhv-container-metadata-label-content.
- 7 keep `non-testable`, each with a ground naming a mechanism: a goal statement, two
  block preambles, a delegation clause, an explicit non-requirement, a prohibition
  scoped to a stage no channel observes, and a validation over an invocation state
  deacon's argv defaults make unreachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…n-reference (T150 batch 3)

All 31 remaining `non-testable` clauses of the devcontainer.json property reference,
re-reviewed against their excerpts.

- 27 flipped to `behavior-mapped` on EXISTING behaviors, so nothing is added to the
  coverage denominator and every flip rests on evidence that already runs. The rule
  applied is the one 020 already established for the schema surface: a prose clause
  that states a property's shape maps where its `cst-` schema twin maps
  (bhv-readconfig-basic-parse), and a clause that states a runtime consequence maps to
  the behavior that observes it — the lifecycle-stage descriptions to
  bhv-run-user-commands-hook-order, `containerEnv` to
  bhv-up-container-env-merge-precedence, `remoteEnv`/`remoteUser` to
  bhv-exec-container-id-metadata, `mounts` to bhv-up-mount-source-and-shape,
  `containerUser` to bhv-up-effective-user-uid-gid, `userEnvProbe`'s closed enum
  additionally to bhv-readconfig-unsupported-enum-rejected.
- 4 keep `non-testable`: `name`'s UI display text, `requireLocalPort`,
  `onAutoForward: notify` and the `customizations` delegation — each a UI or
  other-product affordance deacon has no surface for, with the property's own shape
  already claimed elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…ncies (T150 batch 4)

All 16 remaining `non-testable` clauses of the Feature-dependencies proposal.

- 10 flipped to `behavior-mapped` on EXISTING behaviors. Every step of the round-based
  sort that can change WHICH order results (graph construction, roundPriority
  assignment and defaulting, the max-priority commit rule, the lexicographic tiebreak,
  the worklist rule, Feature equality, overrideFeatureInstallOrder's n-idx assignment)
  maps to the two behaviors that observe the resulting order, joining the sibling
  algorithm clauses of this document that were already mapped there. The
  no-progress-terminates-with-an-error step maps to bhv-up-feature-install-failure,
  which 024 US4 authored for exactly "a dependency graph with no resolvable order".
- 5 keep `non-testable`: a definition preamble, the sort's initialization line, the
  OCI media-type fragment, the WHEN-resolution-runs statement, and the MUST-fallback
  clause for an annotation fast path deacon does not implement at all (so extracting
  the Feature is its only path and nothing distinguishes falling back from never
  optimizing).
- 1 re-dispositioned `not-applicable`: serializing the Feature metadata into an OCI
  annotation is publish-side work, out of scope under constitution II.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…ocuments (T150 batch 5)

All 36 remaining `non-testable` clauses carried by authoring.json — 30 from the
Features document, 6 from Templates.

A finding worth recording: none of the 36 turned out to be miscategorized
authoring-surface clauses that the document-scope `not-applicable` default already
covers. Every one of them is a CONSUMER obligation living inside an authoring
document — the installer's contract with a Feature, or the applying tool's contract
with a Template — which is exactly why they were given per-clause records rather than
left to the blanket default.

- 24 flipped to `behavior-mapped` on EXISTING behaviors, following the mapping this
  file already used for its three consumer clauses (bhv-up-exec-parity as the "up
  builds the container correctly from its Features" claim): option resolution and its
  environment-variable naming, the per-Feature command sequencing, install ordering and
  the dependsOn family, the `containerEnv`-before-install rule (which 024 US5's
  bhv-up-path-construction now actually observes), the capAdd/privileged merge rows,
  the `_REMOTE_USER`/`_CONTAINER_USER` family, and the three Template option
  substitution clauses.
- 12 keep `non-testable`, each with a ground naming a mechanism: two `${devcontainerId}`
  computation steps, its scope enumeration and uniqueness claim, the build-layering
  note, identifier lowercasing (OCI references are already lowercase at the registry),
  the install.sh invocation mechanics and their shell default, a motivation sentence,
  and the three `optionalPaths` clauses, whose obligation is an interactive prompt a
  non-interactive CLI has no channel for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
`DiskCache` loads `index.json` into memory at construction and republishes the
whole map on every mutation. The publish was already atomic (temp file +
rename), but the read-modify-write SEQUENCE was not, so two processes sharing a
cache directory silently erased each other's entries — a classic lost update.

The host-global state index is the caller that makes this user-visible: `up` for
workspace A and `up` for workspace B race on the same file, and the loser's
container is no longer recorded. A later `deacon down` for that workspace then
resolves nothing and exits 0 having removed nothing. `down --remove --all` was
never affected, because it selects by label rather than through the index.

Take an advisory `fs2` flock over a sibling `index.lock` across the WHOLE
sequence and re-read the published index under it before applying the local
mutation, mirroring `port_forward::registry`. `remove`/`clear` degrade to the
in-memory snapshot with a warning if the lock cannot be taken, since the `Cache`
trait has no fallible form for them — never silently.

Both regression tests fail without the fix and pass with it: 170 of 200 entries
lost at the cache level, 10 of 16 workspaces at the `StateManager` level.

Surfaced by the 024 conformance Docker tier, where four cases run concurrently
in `docker-shared` and `case-down-removes-container` diverged because its
container was never removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
`down` was the one subcommand that tolerated an unreadable devcontainer.json.
On a parse error it logged a warning and fell through to auto-discovery, which
re-derives the workspace identity from a DEFAULT configuration — so a `down`
over a malformed document scoped the teardown differently from the `up` that
created the container, then exited 0 having removed nothing. `up` and `exec`
already rejected the same document with exit 1.

A configuration that was FOUND but cannot be read is a developer mistake, not
an absent configuration, so it now propagates (constitution IV, "strict on
mistakes"). The absent case is untouched: `DiscoveryResult::None` still reaches
auto-discovery, which is a real capability rather than a fallback — it resolves
a container recorded for the workspace PATH, so teardown survives the
configuration being deleted, and nine integration tests pin exit 0 there.

Fixes the divergence `case-down-malformed-config-rejected` reported on the 024
live Docker tier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…atch 6)

The last of the 148 `non-testable` clause classifications, and the only one of them that
was neither out of deacon's scope nor already covered by an existing behavior:
features-contribute-lifecycle-scripts' "As with all lifecycle hooks, commands are
executed from the context (cwd) of the [project workspace folder]". It is the clause
inventory's ONLY carrier of the cwd rule — nothing in devcontainer-reference or
devcontainerjson-reference states it — so classifying it `non-testable` would have been
false and mapping it onto a neighbouring behavior would have claimed evidence that does
not exist.

So it takes the other branch T150 allows: `behavior-mapped`, on a new behavior that
arrives with its evidence rather than with a promise of it.

- `bhv-up-lifecycle-command-cwd`, accounted in POST_BRANCH_BEHAVIORS with why it is not
  a variant of `bhv-up-lifecycle-command-forms`: an implementation can run both declared
  forms of every hook out of the image's default directory and satisfy that claim while
  breaking this one for every hook that resolves a relative path.
- `case-up-lifecycle-cwd` + `fx-lifecycle-cwd`. The hook reports its OWN `pwd` into a
  file at an absolute path. Writing to a relative path would have been the shorter
  fixture and the worse evidence: a file that lands outside the bind mount and a hook
  that never ran leave the host observer with the same empty allowlist, whereas the
  recorded value names the directory that was actually used.
- Both axes were measured before the record was written — deacon and the pinned oracle
  0.87.0 each wrote `/workspace` for this fixture — so `reference: aligned` is an
  observation, not an assumption (research Decision 11).

Verified live: `cargo nextest run --profile parity -E 'binary(=parity_conformance_docker)
and test(conformance_group_docker_shared)'` passes with the new case in it. `validate`
stays green, `certify`'s blocking set is unchanged (the obligation is dispositioned
`case`, not `gap`), and the obligation count moves 787 → 788 with covered 370 → 371.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Closes the T150 deferral in 024's Deferred Work section with the tallies (148 → 45
non-testable, +91 behavior-mapped of which 90 reuse existing behaviors, +12
not-applicable, zero left open), the mapping rule the flips followed, and the two
findings that are follow-ups rather than T150 deliverables — `optionalPaths` being
unimplemented, and the disproven hypothesis that authoring.json held records the
document-scope default already covered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
The `docker-shared` group left eight `diverge` verdicts from User Story 3's
originally-authored cases. Each is now either fixed in deacon (separate commits)
or evidence-backed here. The tier is green with nothing waived silently.

Two are the SAME already-recorded deacon extension surfacing outside
`read-configuration`: oracle 0.87.0 has no `extends` support at all, so its `up`
fails where deacon builds, and its `run-user-commands` then reports "Dev
container not found." Both cases link `bhv-readconfig-extends-merged` and scope
an exit-code tolerance to `ext-extends-resolution`.

Two are genuine differences newly characterized, each with a behavior carrying
all three axes, a self-invalidating waiver, and a filed issue:

- `bhv-exec-image-path-preserved` — `exec` drops the PATH entries the image
  contributed via `ENV PATH`, so a tool the image installed does not resolve.
  The probe runs a login shell whose `/etc/profile` replaces PATH,
  `build_effective_env` rebuilds from the probe alone (the sibling
  `merge_environments` implements the documented containerEnv layer and `exec`
  does not call it), and `build_exec_args` then forces the degraded value with
  `-e PATH=`. Not fixed here: the precedence question it raises is a deliberate
  decision on a path shared by up/exec/run-user-commands, not a reorder (#370).
- `bhv-up-changed-config-recreates-container` — deacon provisions a new
  container when the configuration has changed; the reference reattaches to the
  stale one (measured directly at 0.87.0). deacon's direction is deliberate and
  safer. Leaving the superseded container RUNNING is not, and is filed
  separately (#371).

Three cases asserted contracts deacon has never held and are corrected, with the
reasoning recorded in each record:

- `case-down-unsupported-no-workspace-config` expected a rejection. Teardown is
  idempotent by design and nine existing tests pin exit 0; the class stays
  `unsupported` because the input IS unprovidable — it is the outcome column of
  the class table, a typical consequence rather than a definition, that does not
  apply to an idempotent operation.
- `case-run-user-commands-feature-contributed-hook` counted hooks across BOTH
  `up` and `run-user-commands`, so it asserted the negation of the already
  accepted `wvr-ruc-completed-hooks-not-rerun`. Every hook was at 2, the
  configuration's own included; only this case counted. `up --skip-post-create`
  isolates the pass under test, and `matches ^1\s*$` replaces a `contains "1"`
  that would also have passed on 11.
- `case-up-stale-config-reentry` asserted `first-create-vs-restart`, which is
  not deacon's contract and which the closed relationship set cannot express for
  a recreate. Re-pointed to a spec-expectation.

`case-exec-dockerfile-overlay` is split in two. It carried the environment
LAYERING (which agrees) and the image PATH (which does not) in one argv, and the
PATH difference lands in arbitrary stdout text — where a tolerance would be a
wildcard, which is exactly why `chan-stdout` is excluded from
`SCALAR_OBSERVABLE`. `case-exec-image-path-overlay` takes the probe alone so the
tolerance is scoped to a case whose only exit code IS the probe. It resolves the
tool through a SHELL rather than as the exec argv, because `docker exec`
resolves a bare argv against the container's own configuration and finds it on
both sides — an earlier `command -v` form was worse still, being a builtin and
therefore not an executable at all, so both CLIs failed identically and agreed
about nothing.

Also files #372 (`run-user-commands` writes markers with `config_hash: None`,
erasing `up`'s config-drift detection) and #373, the `parity-drift` issue
`bhv-container-metadata-label-content` has been asking for in its notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
T149 asked for a second environment profile to be activated for real, so that
FR-004b's promise — activation is a data change, nothing else moves — is proved
against the authoritative registry rather than a fixture. The record now exists:
`prof-linux-amd64-podman-0870`, identical to the docker profile but for
`dim-runtime: podman`. It is deliberately `active: false`, and the reason is
empirical.

Activation WAS performed, on a copy of the real registry. Its structural half
holds: it needed zero changes to `scenario.json`, `applicability.json` or any
case. Its substantive half does not. `validate` and `certify` under the podman
profile returned results identical to the docker profile's — 787 obligations,
370 covered, 417 gap, `inactive-environment 0`, the same ten blocking gaps —
because all 55 behaviors carry an empty `applicability` and therefore apply in
every environment. The re-bucketing SC-015 predicts is real and currently
vacuous: nothing is runtime-conditioned for a swap to move.

That identical output is the argument for leaving the flag off. Activation would
move nothing, so it would verify nothing, while asserting that the registry's
whole `reference` axis holds under a runtime no case has ever executed against.
"Statuses are evidence-backed claims, not aspirations" is the registry's own
first principle, and a profile flag is the last place to break it. Genuine
activation needs a podman execution lane and runtime-conditioned applicability
on the behaviors that actually differ.

Modelling the second profile also creates a hazard that did not exist while the
file held one record. Coverage and validation both resolve the active profile
with `.find(|p| p.active)` — the FIRST match, silently — so a contributor
"activating" podman by setting a second `active: true` would get a clean report
that means nothing. Activation can only ever be a swap. FR-004a says exactly one
profile is active and nothing enforced it, so
`exactly_one_environment_profile_is_active` now does.

`load_real::seed_registry_loads_cleanly` asserted `profiles.len() == 1` under
the label "one active profile", conflating the record count with the active
count. It now asserts both separately, which is what it meant all along.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…oor (T142)

SC-005 requires no observable channel to rest on fewer than three covering cases.
Four were below it: `chan-image` on ONE case, `chan-filesystem`,
`chan-injected-process` and `chan-process-graph` on two each. A channel carried by a
single case is one authoring mistake away from being unobserved — exactly the failure
US6's injected-regression harness exists to catch after the fact, and one worth not
authoring in the first place.

Four cases, each with a full `scenarioContext`, so they close pairwise gaps as well as
the floor:

- `case-up-compose-service-graph` — a Compose `up` with no Features. `chan-image`: the
  service container is created from the image the Compose file DECLARES and still
  carries deacon's `devcontainer.*` identity alongside Compose's own labels.
  `chan-process-graph`: both declared mounts with their shape, the workspace bind and
  the named volume. Neither the volume's daemon path nor the `<project>_default`
  network is pinned — both are workspace-derived, so pinning them would assert the temp
  directory rather than the graph.
- `case-up-dockerfile-feature-image` — Dockerfile plus one local Feature, a pairwise
  gap. The Feature layer rebuilds the image, so `chan-image` pins the base's `ENV` and
  entrypoint on the image the container was finally created from, and
  `chan-injected-process` pins that the same value reaches the process under the
  configured non-root `containerUser`. `cwd` is deliberately not asserted: deacon
  leaves `WorkingDir` empty here and pinning it would record an uncharacterized value.
- `case-up-running-reattach-process-context` — `up` against an ALREADY-RUNNING
  container. The sibling metamorphic case proves re-entry creates no second container;
  the relationship oracle produces a `chan-temporal` verdict only and never looks at
  what the reattached container's process context IS.
- `case-templates-apply-multifile-tree` — scaffolding as a TREE. `exists` on a file
  outside `.devcontainer/`, `absent` on the template manifest a naive whole-tree copy
  leaves behind, `mode` on the scaffolded configuration, and the option substituted into
  the DOCKERFILE rather than the configuration.

Every assertion was run against live Docker and the pinned 0.87.0 oracle, then
NEGATIVELY tested: each was perturbed and confirmed to diverge, so none passes
vacuously. `channelsBelowFloor` is now 0.

Also corrects 22 combination dispositions left reading `gap` while a case already
matched them. US3 updated its dispositions in lockstep; US4, US5 and T150 added cases
and only registered the new BEHAVIOR dispositions, so the combination records drifted.
The direction was pessimistic — over-reporting gaps, never claiming absent coverage —
but the report is meant to be truthful in both directions. Covered 374 → 396, gap
417 → 395.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
The 024 entry read `(PLANNED)`. It now records what was built, and the machinery it
describes gets a section of its own rather than being folded into the registry's.

- New "Deterministic Coverage Model" section: the `sdim-` namespace and WHY it must stay
  out of `dimensions.json` (a condition on an unassigned dimension is unsatisfied, so a
  scenario dim there silently shrinks the denominator); the two obligation kinds and why
  they are never multiplied; the mandatory `odp-` disposition and the precedence trap that
  bit this branch — explicit records outrank the evidence, so a commit that adds cases and
  registers only the new BEHAVIOR dispositions leaves the combination records reading
  `gap` while a case matches; the five reporting buckets; reporting-never-gates; V26–V30;
  and the injected-regression harness with the two findings it produced (`jsonSubset: {}`
  matches anything, `contains` cannot see appended output).
- "Conformance Registry": `cases/<area>.json`, the four new registry files, the `coverage`
  command group, and 11 channels rather than the pre-024 nine.
- "Parity & Conformance": `certify` also blocks on V28/V29; `coverage report` is not a
  gate; a new case must flip its `odp-cmb-*` records in the same commit; a new channel
  needs a `reg-` record and the three-case floor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Three additions, one of them a rule this branch learned the hard way.

- **Drift workflow (adding a case)** — new. The pin-bump workflow already existed; adding
  a case has a different shape (nothing is regenerated, only dispositions move) and its
  step 4 is the one that gets skipped silently. An explicit disposition takes precedence
  over the evidence — deliberately, so a reviewer can rule that a mechanical
  `scenarioContext` match is not real coverage — and the cost is that a commit which adds
  cases and registers only the new `obl-bhv-` dispositions leaves the combination records
  reading `gap` while a case matches. Nothing fires: `validate` sees a well-formed record,
  `certify` sees a blocking-shaped gap, the report under-counts. It happened here, 22
  records across three areas, found by hand.
- **The five reporting buckets** — a single table defining `covered` / `waived` /
  `non-testable` / `gap` / `inactive-environment` plus the undispositioned queue, with
  what each means and whether it blocks. They were previously inferable only from the
  `certify` outcome table and a closing paragraph; that paragraph now points here rather
  than half-restating it.
- **Reporting never gates** — stated already; made explicit that `coverage report`'s exit
  code reflects only whether it could WRITE, and that the rule runs both ways (`certify`
  names what blocks, and does not reproduce the reports' numbers).

Also drops the "(User Story 2)" forward references from the drift workflow and the
gating note — that story shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…behind

The Docker tier leaked one network and one volume per Compose case, every run, and the
leak presented as a FLAKE IN AN UNRELATED CASE: after enough runs the daemon answers the
next `docker compose up` with "all predefined address pools have been fully subnetted",
so whichever compose case ran next failed with "observed nothing" — a message about
observation for a fault in cleanup. 28 leaked networks had accumulated before anything
looked.

Nothing was watching because nothing could be. `deacon down` computes
`deacon_<workspaceHash>_<configHash>` while the reference derives its project from the
workspace DIRECTORY (`<basename>_devcontainer`), so `down` never sees the reference's
project; and the container label sweep does not reach it either, because only CONTAINERS
carry `devcontainer.local_folder` — a Compose network carries just
`com.docker.compose.project`.

The sweep matches on NAME, which is what the available labels leave: compose names every
resource `<project>_<resource>` and the isolated workspace basename is unique per run, so
containment selects this workspace's project and no other — the same collision-safety
FR-037 already relies on. Matching is case-INSENSITIVE, and that is not a nicety: a
compose project name is lowercased and `tempfile`'s suffix is not, so the first version
of this fix selected nothing and left the leak exactly where it was, behind a sweep that
looked like it worked. The selection is a pure function with its own unit test for that
reason.

`the_error_path_tier_reclaims_every_resource_it_creates` gains the matching assertion,
attributed the same way the container check is: a Compose network or volume named for an
isolated workspace that no longer exists is orphaned by definition, and one whose
workspace still exists is a sibling driver's in-flight run.

Networks are now flat across repeated tier runs (3, the daemon's built-ins) where they
previously grew by three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…d commands (T148)

Walked every command block end to end against the shipped binaries. The commands
themselves are accurate — `generate` / `check` / `report` / `scaffold` / `validate` all
behave and print as documented, `scaffold`'s note really is on stderr so the documented
`> skeletons.json` redirect works, and every field the "Find what is missing" table names
(`summary.undispositioned`, `bucket`, `excluded`, `deadValues`) exists in the emitted
JSON. Four corrections:

- **"Add a deterministic case" was missing its load-bearing step.** It said "point the
  disposition at it" as an aside; the actual requirement is to flip every `odp-cmb-*` the
  case now covers off `gap`, in the same commit, because an explicit disposition outranks
  the evidence. Skipping it is silent — validate passes, certify blocks on a
  gap-shaped gap, and the report under-counts. It happened during this feature's build-out
  (22 records). Now step 4, with the reports to read at step 3 to find them.
- Same section gains the two rules the injected-regression work paid for: perturb each new
  assertion and confirm it diverges, and keep every channel at three covering cases.
- **`--profile parity` does not run "both drivers".** It runs the whole live allow-list,
  the two declarative drivers plus the four surviving legacy carriers. Added the
  per-binary filter for the config-only tier alongside the Docker one.
- Troubleshooting gains three rows for symptoms seen while finishing this feature: a pair
  still reading `gap` after a case covers it, `channelsBelowFloor > 0`, and `observed
  nothing` — which reads like an assertion failure and is not one.

Also states the report artifact count as emitted (4 families, 8 artifacts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
…148)

Records the nine polish and verification tasks, and what each one actually found rather
than just that it ran.

Two were not the no-ops their titles suggest. **T142** found the channel floor breached
four ways — `chan-image` on one covering case — and closed it with four new cases,
negatively tested. **T145** found the live tier flaking, and the cause was a Compose
resource leak on the reference side that presented as an observation fault three layers
away; the fix and its evidence are recorded on the task.

Final state: 791 obligations, 0 undispositioned, 396 covered / 395 gap; 14/14 high-risk
triples covered; `channelsBelowFloor` 0; `inertCount` 0 across 11/11 channels;
`missingConfigSources` and `missingInputClasses` empty for all ten operations;
`make test-nextest` 4032/4032; `certify` NOT certified on exactly 10
`gap-pairwise-<operation>` blockers, which is the honest end state this feature was built
to produce.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
Disk cache index locking (crates/core/src/cache/disk.rs):
- `lock_index` created the lock file unconditionally, so an existing but
  non-writable cache dir (read-only mount, another uid's `~/.deacon/state`)
  failed `DiskCache::new` and took `up`/`down`/`exec` down with it. Reads are
  safe unlocked — `save_index` publishes by rename — so `load_index` now
  degrades to an unlocked read and skips only the destructive expiry sweep.
- Replaced the unbounded blocking `flock(LOCK_EX)` with a bounded try-lock
  poll. These calls are reached from `async fn` command paths, where an
  indefinite wait stalls every other future on the runtime (constitution V).
- `remove`/`clear` skipped the re-read on lock failure but still reached
  `save_index`, republishing the construction-time in-memory snapshot over the
  live index — the exact lost update the lock exists to prevent, on the failure
  path. They now always re-read, locked or not.
- The expiry sweep unlinked data files without republishing the index that
  named them, so later readers logged "Cache data file missing" until some
  unrelated `set` happened to republish. It is now published under the same
  lock that performed it, and no longer runs on every `set` (a whole-map stat
  and unlink pass serializing N independent cache writes).

Compose feature env (crates/deacon/src/commands/up/compose.rs):
- The compose path never read `feature_build.combined_env`, so a Feature's
  `containerEnv` reached the feature-extended image but never the service's
  `environment:` block. `bhv-up-container-env-merge-precedence` is stated over
  "the created container", not one container shape. Folded in below the
  configuration's and the CLI's values, matching the 024 US5 single-container
  fix.

Parity harness:
- `verdict_differential` named the scalar observable only on the value-diff
  branch, so a presence mismatch reported a bare channel id — which
  `is_global_ignore` rejects at load (FR-032), making it un-tolerable by
  construction.
- A per-case wall-clock timeout was reported as `Cause::OracleTimeout`,
  blaming the pinned reference for wedged deacon runs, hung `docker inspect`
  calls and teardown stalls. Added `Cause::CaseTimeout`.
- The structured-output injector kept visiting outcomes after a failure, so the
  reported cause could come from a different operation than the one it
  corrupted while earlier outcomes stayed mutated. It now stops at the first
  failure and names the operation.
- `authored_properties` was re-read from disk on every structured-output
  capture — per operation, per side, per case — and at OBSERVE time, so an
  operation that rewrites the configuration changed the authored set out from
  under the document being classified. Snapshotted once in `RunContext`,
  mirroring `container_inspect`.
- `deacon_binary` shelled out to a synchronous `cargo build` from inside
  `runtime.block_on`, blocking a tokio worker for the whole compile.

Conformance:
- `canonical_context` had no arity marker, so `[{a:[b]},{c:[d]}]` and
  `[{a:[b,c,d]}]` hashed identically — two behavior obligations colliding on
  one `obl-bhv-` id, silently transferring a hand-authored disposition. Values
  now join on a distinct separator. No id churn: the registry has no
  multi-value conditions today.
- `build_observables` counted every registry case toward the SC-005 three-case
  channel floor, so a legacy case whose carrier binary is deleted could satisfy
  a floor that exists because an unobserved channel reports green forever. It
  now gates on `executable_case_ids`, as `evaluate_obligations` already did.
  All 11 channels still clear the floor.

Also corrected the `down` config-load comment, which claimed the branch only
handled "found but unreadable" when it also covers a missing `--config-path`
(symmetric with `up`/`exec`, which pass an explicit `--config` straight to the
loader).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
@github-actions github-actions Bot added feature New feature (alias) docs Documentation changes ci CI/CD changes build Build system changes labels Jul 27, 2026
The Windows `dev-fast` lane failed `committed_obligations_match_a_fresh_regeneration`
with an EMPTY semantic diff:

    conformance/obligations/obligations.json does not byte-match a fresh
    regeneration (V27): first difference None; +0 added, -0 removed, ~0 changed.
    Run `cargo run -p deacon-conformance -- coverage generate`

Identical records, different bytes: git's autocrlf rewrote LF to CRLF on checkout,
which JSON parses away, so the units compared equal while the byte comparison failed.
The suggested remedy could not work — `coverage generate` writes LF and the checkout
rewrites it right back.

Extend `.gitattributes` with `conformance/obligations/** -text`, mirroring the entries
020/021/022/023 each needed for their own byte-exact artifacts (schemas, clause prose,
snapshots, the migration baseline). The committed blob is already LF, so this only pins
the working-tree bytes on every platform.

Also make the failure name its own cause. V27 deliberately cannot distinguish a hand
edit from a stale regeneration; it equally cannot distinguish either from a re-encoded
checkout, and the message sent the reader after drift that did not exist. Both the
validator and the hermetic test now detect the identical-units-but-different-bytes case,
report it as a line-ending difference, and point at `.gitattributes` instead of
`coverage generate`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFsUBzDguiWtpaXXP74ogf
@pofallon
pofallon merged commit 63d3771 into main Jul 27, 2026
14 of 15 checks passed
@pofallon
pofallon deleted the 024-deterministic-conformance-coverage branch July 27, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build Build system changes ci CI/CD changes docs Documentation changes feature New feature (alias)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant