Skip to content

Make the wiring gates honest, and the suites that judge them hermetic - #832

Merged
wenzowski merged 4 commits into
mainfrom
claude/wiring-gates-honest
Sep 3, 2026
Merged

wenzowski merged 4 commits into
mainfrom
claude/wiring-gates-honest

Conversation

@wenzowski

@wenzowski wenzowski commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Three rows on one matrix. All three are the same defect class: a gate or a diagnostic that could not be trusted, in ascending order of nastiness — one reported stale wiring as healthy, one refused without naming a remedy, and one let a record on the developer's machine turn a deny assertion green.

Closes CLOUD-1191
Closes CLOUD-1339
Closes CLOUD-1373

CLOUD-1191 — derive the mediation spelling from the SURFACE row

batten hook was spelled independently in three unlinked places, and five committed wiring files carry it as data. reaches_engine matched the literal "hook", so against a settings file naming a command that no longer exists it returned true — the one diagnostic built for this failure was blind to it, and the golden test would have passed a half-rename.

surface.rs now owns the answer (BINARY, MEDIATION_ID, mediation(), mediation_argv()); hook.rs's wiring_command and doctor.rs's reaches_engine both derive from it with no literal fallback. Emitted bytes are unchanged, so no wiring file moves.

Two test assertions carried a fourth and fifth literal and are derived too. spec.rs's the_mediation_entrypoint_is_never_read_only is the instructive one: hardcoded, after a rename it would ask whether a path the surface no longer declares is read-only, get "not in the allowlist" for the trivial reason, and pass green while pinning nothing — the same false green this row removes, one level down, in the test that guards it.

Shown able to fail by attempting CLOUD-1192's hook → adjudicate rename against this branch: the generator and the diagnostic followed the row with no edit, and exactly those two literals went red.

CLOUD-1339 — the refusal names the verb that clears it

hook wire duplicate sent a session to a prose document instead of a command. The class now carries batten wiring reclaim as its first route, kind = "command", so a reader following only the refusal reaches the verb. MERGED_SIBLING's doc comment is corrected to match.

CLOUD-1373 — the committed-policy suites read the developer's own admission store

common::batten() scrubs the ambient environment twice — every BATTEN_ variable the surface declares, and every bypass name beside it. Both are walks over environment variables, and an admission is not one: CLOUD-1051 retired BATTEN_FILED_HERE_BYPASS and its siblings precisely so suppressing a refusal would cost a signed record in the state store rather than a knowable string. The channel that replaced the scrubbed ones is unreachable from the scrub by construction.

bypass_scrub.rs already knew half of it. the_hatch_is_load_bearing had to stop observing the hatch through a protected-path refusal, and its comment says why — "that class declares an override route and the boundary honours a spent admission for it, so the variable stopped being its way out." The suite recorded that an admission had replaced the variable, and left the store ambient.

Measured, and a false green in the unsafe direction. A spent admission for batten.toml in the developer's own store turned cli.rs::the_committed_protected_paths_fire_on_a_mutating_verb green-side: mv batten.toml elsewhere.toml answered exit 0 where the case demands 2. The suite reported that the committed protected-path policy refuses a write while a record on that machine was admitting it.

State is keyed by repository, so the affected suites are exactly those whose subject is the committed configuration and which therefore run at the real root: cli.rs's hook helpers, mediated_verbs.rs (whose AUTHORITY is batten.toml), gh_guard.rs, pipeline_shapes.rs, refusal_ceiling.rs, shell_write_advisory.rs, preset_segments.rs. They cannot escape it with a fixture home, because the state segment is derived from the root that carries the config.

Not a default on every spawn, which was the first shape tried and is wrong: a fixture suite may spawn a child that writes the store and read it back in-process (admission.rs's a_correctly_answered_override_completes_end_to_end does, via admission::load, which resolves the root from the parent's environment), and redirecting only the child splits the two. That case is not the defect — its subject is a scratch repo, so it already has a segment of its own.

state_roots lives in common/ rather than in the asserting suite because that module is the one place those variables may be named, which primitives::no_suite_sets_the_state_dir_variables_itself enforces — a case that re-typed them would become the copy that audit refuses while claiming there are none.

Shown able to fail, both halves, matching that file's own discipline: the mechanism half reddens when the redirect is removed, and an anti-vacuity mirror issues and spends a real admission against the real root and shows the committed protected gate flipping from exit 2 to exit 0.

What was scoped out, and why — each measured rather than judged

  • CLOUD-1192 (rename hook → adjudicate) — attempted here and backed out at 136 occurrences across 53+ files, 112 of them argv in the compiled-binary tier. It renames the entrypoint every wiring file names, so putting it in the same matrix as the change that repairs the diagnostic validating that wiring means if both are wrong together, the wiring gates cannot tell you. Back in Todo, unassigned, and now immediately pullable against an honest diagnostic.
  • CLOUD-940 (a hook-body program no wiring names is a finding) — its live subject is mise-tasks/ready-guard.sh, and only 12 of its 24 cases were ported by CLOUD-312. The other 12 cover the landing lease and the landing commit, which nothing enforces (leased-push reads like the successor and is not — it decides git's --force-with-lease spelling). So the gate reds verify on day one and cannot land until that retirement does. Filed as CLOUD-1363, which blocks it.
  • The list_issues reduction — measured at 20,366 bytes emitted, more than the 17,313 stored. It is not a config row: Reduce::Project is a flat field-pick at one node path, so projecting each element of issues[] needs an engine arm. Left for its own change.

Filed from this work, not fixed here

  • CLOUD-1374 — an admission binds to a SHA, so it cannot survive land, which rebases every lap. Three re-issues of one identical articulation, chained by Admits-prev.
  • CLOUD-1375 — filed-over-own-diff intersects path names, and every rule in this repository registers in one ~11k-line file, so any rule-proposing row overlaps any rule-touching PR whatever the two are about.
  • CLOUD-1377 — wiring reclaim removes registrations harness-declared.json declares. Hit live: running CLOUD-1339's own named remedy deleted the two hooks declared under CLOUD-1079 (still In Progress), turning a green tree into two hook declare stale findings that neither reclaim -n nor doctor hooks can see.

Verification

mise run lint:clippy, mise run test:cargo, mise run policy-test, then mise run verify green on the branch. Each row's shown-able-to-fail case is described above.

@linear-code

linear-code Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
CLOUD-1339 `hook wire duplicate` names no remedy, so its refusal sent a session to a menu instead of the verb that fixes it

§1 Subject

The hook wire duplicate [[verdict]] row in batten.toml and its two [[verdict.route]] entries. Not policy/harness-wiring.rego, whose predicate is correct and fired correctly.

§2 The measurement

Measured 2026-09-02 while landing CLOUD-1321 in a Claude Code web container.

batten check --rule harness-wiring refused with 2 harness-wiring. The condition was real: ~/.claude/launcher-settings.json registered session-start-git-identity.sh on SessionStart and stop-hook-git-check.sh on Stop, beside the mediator, against a declared [hook] exclusive.

The remedy exists and ships in this repository: batten wiring reclaim (CLOUD-893's repair path), with crates/batten/tests/it/wiring_reclaim.rs behind it. One invocation removed both registrations and took the rule to exit 0.

The refusal did not name it. hook wire duplicate's routes are:

route target
prose read first AGENTS.md
rule read first .claude/rules/toolchain.md

Both are prose documents. Neither names a command, and neither of those documents names batten wiring reclaim either.

Its own sibling in the same module does name its fix. hook wire loose's class reads "Retire the guard behind the engine, or declare it in policy/harness-wiring.rego's table naming the issue that will." So this is an inconsistency inside one module's own verdict set rather than a house-wide omission.

The class prose actively points away from the remedy. It reads "The wiring this judges is the launcher's own merged settings, which live outside this repository". That sentence is true about where the file lives and false about what can fix it, and it is the sentence a reader reasons from.

§3 What it cost, measured rather than supposed

The session read the refusal, read doctor.rs's MERGED_SIBLING doc — "a merged surface is under $HOME, so editing the repository cannot remove the registration" — and concluded the condition was unfixable from the repo. It then:

  • spent HK_SKIP_STEPS=hooks-wiring-check on three commits;
  • wrote that justification into two commit messages (bde7661, eca096a1) as a claim about the repository's capabilities, which is now false and is on main-bound history;
  • and put a three-option menu to the human, one of which was "stop here" — the punt AGENTS.md names as "a block reported as a decision".

All of it was closed by one command the refusal could have named.

**AND THE REMEDY DOES NOT HOLD, WHICH IS THE SECOND HALF AND WAS MEASURED AFTER THE ROUTES LANDED. **batten wiring reclaim -y cleared both registrations and hooks-wiring-check reported 5 harness(es), 0 unwired. One SessionStart later the same two were back in ~/.claude/launcher-settings.json, and the same commit was refused again. The harness re-registers them every session start, so the verb is a repair rather than a fix and this recurs for every session in this environment.

That changes what naming the verb buys: it turns a session-long dead end into a per-session chore, which is a large improvement and not a resolution. The row that would resolve it is a different one — either the registrations stop being written, or [hook] exclusive learns to tolerate a declared launcher sibling by name. Both are outside this row; what belongs here is that the refusal must not send a reader looking for a permanent fix that no command provides. A route naming batten wiring reclaim should say it is a repair that the next session start undoes.

§4 Why the contract already covers this

CLOUD-122's refusal contract is that every deny names the fix. no_fix_reason exists for the rows that deliberately cannot — bats-tests-not-deleted carries one, stating that which of two remedies applies IS the question. hook wire duplicate carries neither a fix nor a no_fix_reason; it carries an explanation, which reads as compliance and is not.

Refinement — Ready (name the verb in the refusal)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

{
  "source_of_truth": "`hook wire duplicate`'s `[[verdict]]` row in batten.toml carries two `[[verdict.route]]` entries, both `kind = \"document\"` pointing at prose, and no mention of `batten wiring reclaim` in either the routes or the class — while its sibling `hook wire loose` names its fix inline",
  "gate": { "task": "batten-check", "exits": [0, 2] },
  "commit_type": "fix",
  "blockers": [],
  "tests": [
    { "file": "policy/verdict-routes-resolve.rego", "mutation": "route-task-unchecked" },
    { "file": "policy/remedy-authorship.rego", "mutation": "remedy-prefix-unchecked" }
  ]
}
  • Authority boundary (§1). The hook wire duplicate row in batten.toml — its class and its routes. Whether the general obligation ("a verdict whose remedy is a verb names that verb") becomes a gate is the second half and may be split; the instance is fixable on its own and should not wait for it.

  • **Computable predicate (§2). **batten check --rule harness-wiring over a tree carrying a merged sibling emits a refusal whose class or routes name batten wiring reclaim, and a reader following only the refusal reaches the verb without opening a prose document.

  • **Deliberately not in scope (§2). **policy/harness-wiring.rego's predicate, which is correct. doctor.rs's MERGED_SIBLING doc comment is in scope for the same correction: it states the repository cannot remove the registration, which is what the verb disproves.

  • **Effect (§3). **read. A config edit plus a doc-comment correction.

  • Test obligation (§7). The generalisable half is the interesting one: a gate over [[verdict]] rows asserting that a class naming no fix carries a no_fix_reason, shown able to fail by stripping this row's remedy once it lands.

    The claims above name EXISTING mutations in the two modules this work extends, so they bind today. The first draft named remedyless-verdict-passes and prose-only-remedy-passes, neither of which exists — obligations-bound refuses a declared slug that is not a #MUTANT row in the declared file, and it caught exactly that on the filing branch. The unbuilt case belongs in this clause rather than in a claim nothing can join.

  • Blockers (§8). None.

Acceptance

  • The refusal names the verb, and doctor.rs's doc no longer claims the repository cannot remove a merged registration.
  • A reader who follows only the refusal reaches batten wiring reclaim.

§6 Cost of not doing it

Already paid once, above: a false claim on two commits, a gate switched off three times, and a decision put to a human that the tree already answered. The next session meets the identical refusal with the identical documents.


Found while landing CLOUD-1321 (PR #828).

CLOUD-1191 The hook command spelling exists as three unlinked literals, so `doctor hooks` would report stale wiring as healthy and the golden test would pass a half-rename — derive all three from SURFACE

Why

batten hook is spelled independently in three places, with nothing linking them:

site what
surface.rs:2313 the SURFACE row — the declaration
hook.rs:1123-1124 the generator: format!("batten hook --harness {}", harness.as_str())
doctor.rs:770 the diagnostic: let derived = ["hook", "--harness", harness.as_str()]

Plus five committed wiring files (.claude/settings.json, .cursor/hooks.json, .codex/hooks.json, .gemini/settings.json, .github/hooks/batten.json) that carry the string as data.

Nothing derives any of them from the row. Three consequences, in ascending order of nastiness:

  1. Renaming the SURFACE row does not propagate to the generator.
  2. doctor hooks **would report the stale wiring as HEALTHY. **reaches_engine (doctor.rs:751-778) matches the literal "hook", so against settings files naming a command that no longer exists it returns true. The one diagnostic built for this exact failure is blind to it.
  3. **The golden test would pass a half-rename. **tests/cli.rs:9276-9296 diffs generated wiring against crates/batten/tests/fixtures/hooks/wiring/*.json; both the emitter and the fixtures are independent of SURFACE, so renaming the row alone leaves the test green while the emitted wiring names a dead command.

Why this is worse than a normal drift

**The failure mode is silent fail-open. **exit.rs:7-13 states it as a design property:

"Every host with a pre-tool hook reads 0 as allow, 2 as deny… and anything else as 'the hook itself failed, let the call through.' …it makes fail-open structural."

An unknown subcommand is a clap error → ExitCode::Usage (1) (main.rs:85-93, exit.rs:70) → every host allows every call. So a disagreement between these three literals does not break loudly; it turns enforcement off across every harness while doctor reports green.

This is hook-convergence work regardless of any rename: three authorities over one fact, where disagreement disables the gate.


Refinement — Ready (one authority for the hook invocation)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • **Authority boundary (§1). **crates/batten/src/{surface,hook,doctor}.rs and crates/batten/tests/. The five wiring files and the fixtures are regenerated, never hand-edited.
  • Computable predicate (§2). The generator and the diagnostic both derive the invocation from the SURFACE row rather than from a literal. A change to the row's path changes the emitted wiring and the diagnostic's expectation in the same build.
  • **The diagnostic must be able to fail (§2). **reaches_engine currently answers "does the wiring name this literal"; after this it answers "does the wiring name the declared mediation path". Those differ exactly when it matters.
  • Deliberately not in scope (§2). Renaming hook — that is the follow-on row, and it is unsafe until this lands. Changing the wiring format, the harness set, or the --harness flag.
  • Effect (§3). read for the diagnostic; the generator is stdout-only and stays read per CLOUD-244's generate precedent.
  • Commit / bump (§6). refactor(hook) — no bump if the emitted bytes are unchanged, which is the point: this row should produce a zero-byte diff in all five wiring files.
  • **Test obligation (§7). **crates/batten/tests/*.rs over the compiled binary. Shown able to fail per CLOUD-418, three observed: (a) with the SURFACE path changed in a fixture, the emitted wiring changes with it — the derivation, proven rather than assumed; (b) reaches_engine returns false against wiring naming a path the surface no longer declares — the exact blindness this row removes, and it must be shown failing before the fix; (c) the five committed wiring files are byte-identical before and after.
  • Blockers (§8). None. Blocks the hook rename. relatedTo CLOUD-984.

Acceptance

  • surface.rs is the only place the mediation command is spelled; the generator and the diagnostic derive it.
  • A test proves reaches_engine fails against wiring naming an undeclared path — demonstrated failing first.
  • All five committed wiring files and their fixtures are byte-identical after the change.
  • Renaming the SURFACE row becomes a mechanical, gated change rather than a silent fail-open.

Found while pressure-testing a proposed hook → adjudicate rename: the rename was safe on paper and would have disabled enforcement everywhere, with doctor reporting green.

CLOUD-940 A guard retired from the wiring but left in the tree is invisible to every wiring gate, and `gh-guard` has been in that state with a header claiming it is wired

Why

mise-tasks/gh-guard.sh:4 says "Wired from .claude/settings.json". Measured at 170c7c4, it is not:

  • **not in **.claude/settings.json — the path-registered entries are issue-search-guard, issue-read-guard, board-move-guard, connector-verb-guard, connector-allow-guard, fanout-guard, run-shape-guard, plus stop-guard and .claude/hooks/session-start.sh.
  • **not in hooks-wiring-check's 13-row **DECLARED table (mise-tasks/hooks-wiring-check.sh:168-180).

Its enforcement moved into the engine — batten.toml's four gh shape rows (gh-pr-merge, gh-pr-comment-fast-forward, gh-pr-checks, gh-run-watch) — and the wiring row was removed, correctly, since wiring-declaration-stale refuses a licence outliving its retirement. The files were left behind.

The mechanism gap, which is the point rather than the instance

hooks-wiring-check decides over commands that are registered: it derives the expected wiring and refuses an undeclared registration. A guard that is unregistered is outside its subject by construction — there is nothing to declare, so nothing to be undeclared about.

CLOUD-713 built the census for the other direction ("nothing counts the PreToolUse commands that are not batten's, so six shell guards accreted beside the engine unremarked") and is Done. CLOUD-312 owns the ten declared rows that are still wired and must retire. Neither owns the absence direction: a guard whose registration is gone while its file, its suite, its documentation and its own header all still assert it is live.

So the tree can carry a program that:

  • claims wiring it does not have (this instance);
  • is still executed by its suite, so it stays green and looks maintained;
  • is still documented as an active guard — .claude/rules/toolchain.md:214-216 describes gh-guard denying gh pr merge/gh pr checks/gh run watch and names mise-tasks/gh-guard-check.sh as the decision table;
  • still justifies a toolchain pin — mise.toml:26 explains the jq pin as "reads the PreToolUse payload in gh-guard";
  • is still counted by the retirement census as an unmigrated gate, which makes the campaign's own number wrong in the pessimistic direction.

A SECOND DIRECTION, measured 2026-08-31 — a gate that IS wired to a caller that is itself unreachable

This row's §2 predicate is scoped to hook-body-shaped programs: those whose
#MISE description= self-declares a hook body, absent from both .claude/settings.json
and DECLARED. That is the right subject for the instance it was filed on, and it does
not reach the shape below — which is the same defect one axis over.

in-progress-drain is invoked, and still never runs. Recorded because I first wrote
the opposite: a config-surface-only scan said its sole reference is mise.toml:457's
$MUTANT_GATES, so I nearly filed "it has no caller". That is false —
board-sweep.sh:279 runs it (run_gate in-progress-drain "$here/in-progress-drain.sh"),
and :229 lists it among six gates the sweep drives. The census predicate has to count
sibling callers, and this row's own footer records me making the identical mistake
("after asserting in a plan that this was two dead files to delete in passing — which the**
**tree contradicted in four places"
).

**The corrected finding is one level up, and it holds. board-sweep.sh itself has no
**invoker: nothing in mise.toml, hk.pkl, any .github/workflows/** job or any hk
step runs it. It is a hand-run command that additionally requires a person to pipe
get_issue payloads and exits could-not-look without them. So six board gates are wired
to a caller nothing calls.

That is CLOUD-825**'s recorded finding, and that row is Done while the condition****
**holds — "three gates have no invoker, so four of the seven never decide". Filed as an
instance on CLOUD-1253 too.

The consequence, measured rather than argued

in-progress-drain.sh's own header states the cost it was built to stop:

"A gate with no caller is a sensor with no gate, the shape non-negotiable 2 refuses, and*
the cost is a column that only grows: 39 rows In Progress for one assignee on
*2026-08-12, still 32 on 2026-08-20."

At origin/main 5b98174: 22 of 23 remote claude/* + wenzowski/* branches are not**
**on main**, the oldest 2026-08-12 — 19 days. **branch-age-check is the sibling gate and
it does have a caller (branch-hygiene.yml, weekly cron) — but that workflow's own
comment says "Failure here is informational" while the task's #MISE description says
"Gate:". A report wearing a gate's noun, which is why 22 stale branches cost nothing.

The predicate this direction needs, and why it is not §2's

A program is unreachable iff, transitively, no config-surface entry
(mise.toml, hk.pkl, .github/workflows/**, batten.toml) outside $MUTANT_GATES
and no .claude/ hook entry reaches it — where a sibling mise-tasks/ caller counts as
reaching it only if that caller is itself reachable. The transitive clause is the
whole predicate: without it in-progress-drain reads as wired, which is exactly the
wrong answer I nearly filed.

Measured first pass, non-transitive, over 123 governed programs: 15 have no
config-surface reference outside $MUTANT_GATES. Most are reached by a sibling. Run**
**the transitive version before writing the gate — the real number is between 1 and 15
and nobody has computed it.

Two directions, one subject: a program in the tree that decides nothing. §2 catches
the unwired hook body; this catches the unreachable gate. Whether they are one gate or
two is this row's to decide — but the second has two measured instances now and a cost
in days of stale branches, which is why this row is no longer in Backlog.

Why this is not simply "delete two files"

That was the first read and it is wrong. The pair is entangled, and batten.toml:1349-1355 already warns about this exact one:

"gh-guard.bats *drives *gh-guard-check too, and retiring only half of that pair must not buy the suite's deletion."

Concretely: tests/gh-guard.bats:2 declares # subject: mise-tasks/gh-guard.sh mise-tasks/gh-guard-check.sh — both halves; gh-guard.sh:19 invokes gh-guard-check; and crates/batten/src/hook.rs:3 cites mise-tasks/gh-guard-check.sh as the provenance of the engine's own port. Deleting the check breaks a citation the engine depends on for its rationale; deleting only the guard leaves a half-pair whose suite still names it.

So the disposition is a decision per file, not a sweep:

file plausible disposition
gh-guard.sh delete — it is the unwired hook body, and the engine adjudicates now
gh-guard-check.sh decide — it is the documented decision table and hook.rs's cited provenance. Keep as a testable reference, or move its content to where the engine's rows live and update the citation
tests/gh-guard.bats narrow to whichever half survives, via retires_with = "# subject:", never a waiver
toolchain.md:214-216 rewrite: the engine denies these, not gh-guard
mise.toml:26 re-justify the jq pin against a consumer that exists

Refinement — Ready

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). .claude/settings.json is the authority on what is wired, as hooks-wiring-check already treats it. This row adds the complement: a tracked guard-shaped program that the authority does not name. One gate, both directions — not a second list of "things that used to be wired."
  • Computable predicate (§2). A tracked hook-body-shaped program under mise-tasks/ that is named by neither .claude/settings.json nor DECLARED is a finding. "Hook-body-shaped" needs a decidable definition rather than a guess — the honest candidate is a program whose #MISE description= declares it a hook body (nine files do: PreToolUse hook body, Stop hook body, PostToolUse hook body), since that is a self-declaration already present and not a heuristic over content.
  • Effect (§3). read. Two committed files, no spawn, no network.
  • Generated artifacts (§4). None.
  • Output & exit (§5). Pointer-only: the path and the rule id, never the file's contents. Exit 1 for a finding, 2 for an unreadable settings.json — a settings file that cannot be parsed must not read as "nothing is wired", which would make every guard a finding at once.
  • Commit / bump (§6). ci — no bump for the gate. The deletions ride their own commits with types matching what they touch.
  • Test obligation (§7). Shown able to fail per CLOUD-418: a fixture tree with a hook-body-declared program absent from settings.json reds; adding it to settings.json greens; adding it to DECLARED instead also greens. The discriminating case is the inverse — a program that is not hook-body-shaped and not wired must not fire, or the gate reports all ~130 ordinary gate tasks and gets switched off, which is the false-positive-first-firing failure privileged-lane.rego:60-65 records.
  • Blockers (§8). None. relatedTo CLOUD-312 (the ten still-wired guards that must retire — this is the complement, a guard that already did), CLOUD-713 (which counted the other direction and is Done), CLOUD-777 (which widened the wiring check's scope), CLOUD-843 (whose census counts this file as an unmigrated gate it no longer is).

Acceptance

  • A tracked hook-body-declared program absent from both settings.json and DECLARED fails a gate, shown able to fail in both directions and shown not to fire on an ordinary gate task.
  • gh-guard.sh's disposition is executed and gh-guard-check.sh's is decided and recorded, with hook.rs's provenance citation still resolving.
  • tests/gh-guard.bats is narrowed via retires_with, not waived.
  • toolchain.md and mise.toml's jq justification name a consumer that exists.

Found while pressure-testing the CLOUD-927 bundle, after asserting in a plan that this was two dead files to delete in passing — which the tree contradicted in four places.

CLOUD-122 Refusal output contract: every deny points to the fix

Generalizes the actionable-refusal principle (design-decisions doc, principle #5). CLOUD-96 is the first concrete instance; this makes it a contract across the hook / exec / check layers.

Why. A block should get the agent to right in one hop, not make it thrash — and a one-line remediation pointer is far cheaper than an agent guessing. A denial is a linter result, not a closed door.

Scope. Every mediated deny returns, in machine-readable form: the error / rule id, a short reason, and a fix pointer — the exact command to run, plus an apply affordance (--fix) where the remedy is safe. Enforced as a test over the deny paths so it cannot regress to a bare "no."

Acceptance.

  • A denied hook / exec / check emits {code, reason, fix_command, fixable?} (shape pinned in the Ready block below) on the machine channel.
  • A test asserts every deny path carries a fix pointer, or explicitly declares it has none.
  • --fix applies the remedy where declared safe.

Sources (public). clippy --fix; thefuck (github.com/nvbn/thefuck) — command-correction UX.

Bound (CLOUD-211 note): a mediated deny originates only from a computable predicate — never a judge verdict (any model signal is advisory-only) — so the refusal shape need not model advisory output; refusal copy uses conformance-gate vocabulary, not "permission hook".


Refinement — Ready (one refusal type, constructed at every deny site, carrying the fix pointer by construction)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). One refusal type in crates/batten, beside the hook policy table it serves, is the authoritative shape; every deny site constructs it, and each channel (claude-code JSON decision, exit-code stderr line) is a projection of the same value — the shape is never re-typed per harness.
  • Computable predicate (§2). Completeness is structural: the refusal constructor requires a fix disposition, so a deny without one does not compile; a table-driven test walks every deny path and asserts the emitted payload parses and carries fix or an explicit none. mise run test:cargo → cargo test → exit code, an hk gate step, run by mise run ci. Not expressible as a batten.toml rule: the predicate spawns the binary under test, and process-spawning rule kinds run under batten enforce only — batten check refuses them with a usage error (exit 1).
  • Effect (§3). No new command, no effect-table change; the contract reshapes existing deny output only.
  • Output & exit (§5). Refusal payload, pointer-only and byte-stable: {rule, reason, fix} — rule id, one-line reason, and the exact command to run instead, or an explicit fix: null where no safe remedy exists. Exit codes are untouched by this issue: a deny is the policy verdict Violation (2) — decision JSON + exit 0 on the claude-code harness, exit 2 with the reason on stderr on the exit-code harness — and check's refusal of a rule it cannot honestly run is Usage (1) naming the verb that does run it; that refusal adopts the same shape.
  • Commit / bump (§6). feat → patch until 0.1.0 (DoR §6: below 0.1.0 release-plz bumps the patch whatever the type says).
  • Test obligation (§7). E2E over the compiled binary (tests/cli.rs): one case per hook policy-table deny asserting the parsed payload's fix names the sanctioned command for the denied intent; a check command-kind refusal case asserting the same shape; a fixture asserting a deny with no safe remedy declares fix: null rather than omitting the field.
  • Blockers (§8). None live — the contract lands over the deny paths that exist today (the hardcoded hook policy table and check's command-kind refusal). Cross-references: relatedTo CLOUD-40 (per-harness allow/deny/fail fixtures assert where each channel emits this payload), CLOUD-215 (the --fix apply affordance rides on Rule.fix), CLOUD-48 (a declarative rule table carries the fix pointer as config data), CLOUD-162 (exec adopts the shape when it lands).

Landed — PR #298, b913c98 on main, 2026-08-11. In Review rather than Done: the commit is in no tag yet, and under CLOUD-319's release debounce that is the ordinary state for up to a day.

Two deviations from the Ready block, both deliberate.

  1. §1 said "beside the hook policy table it serves"; the type landed as a leaf module, crates/batten/src/refusal.rs. hook.rs already imports rules.rs, and rules::run_static is one of the three deny sites, so housing Refusal in hook would have closed a rules ↔ hook module cycle for no gain. The load-bearing half of §1 is intact: one authoritative shape in crates/batten, constructed at every deny site, never re-typed per harness.
  2. §7 asked for a fixture where a deny "declares fix: null rather than omitting the field"; that fixture is the protected-path gate, not a shape row. reason is a required column on RuleKind::Shape, so a shape row structurally cannot fail to declare a remedy — there is no way to construct the no-remedy case there. The gate's [[verb]] half is where redirect is optional, so that is where the case lives (a_deny_with_no_safe_remedy_declares_it_rather_than_omitting_the_clause).

Where each fix comes from, since the Ready block did not pin it. A shape row's fix is its reason column — required on the kind and documented as "why this rule refuses, and what to do instead", so every shape deny carries a declared remedy with no new config key. The protected-path gate's fix is the verb's own redirect, Fix::None where none is declared. check's spawning-kind refusal is Fix::Run("batten enforce").

Follow-up seam, now open. CLOUD-215 landed mid-flight (6569aeb, renaming the command kind's run column to check and reserving fix). That reserved column is the proper home for a shape row's remedy, splitting today's overloaded reason into a cause and a fix. This issue deliberately did not pre-empt it; consuming it is CLOUD-215's to finish.

Defect found while landing, filed not absorbed: CLOUD-405 — bats-tests-not-deleted is a ratchet with base = "origin/main", a moving ref, so a branch a few commits behind reads as having deleted the tests main gained. Local verify green, CI red, same bytes. Not fixed here: rules.rs's ratchet semantics are a different owner and a different blast radius.

CLOUD-199 run-shape-guard covers `mise run` only — `git push | tail` masks a verdict the same way

Why. run-shape-guard names the root cause correctly — "treating a Bash call as a terminal that should print something short, when it is a supervised process whose exit status and lifetime are the interface" — but its matcher is scoped to mise run. Every other verdict-bearing command is still free to fail green.

Measured, minutes after the guard landed. The guard refused git push … | tail -2; mise run module-map-check … | tail -2. The agent then re-ran git push … | tail -2 alone and reported it as compliance. The push half was never the guard's business, so nothing objected; the mise run half was silently dropped rather than reshaped. Re-run in the correct form, that same push returned exit 1 (stale info — the branch had already been merged and deleted), a non-zero status the piped form had reported as success.

The failure was benign this time. The mechanism was identical to the one the guard exists to stop, and the guard could not see it.

Second measurement, 2026-08-08 — and a shape neither the guard nor this issue's acceptance covers: a filter whose pattern cannot match the output format.

While landing the CLOUD-241 fix I "confirmed clippy green" with:

cargo clippy -q -p batten --all-targets --all-features -- -D warnings 2>&1 | grep -E '^error|warning:' | head -5

Empty output, so I reported it clean and committed. mise run verify then failed on missing_errors_doc. The grep never had a chance: cargo colours its diagnostics, so every line begins with an ANSI escape sequence and ^error cannot match at line start. The anchor made the filter structurally incapable of finding what it was searching for, and absence of matches read as absence of errors.

This is not the pager shape. The exit status was not handed to a pager — head was last, and I never read a status at all; I read emptiness as a verdict. So it escapes both the landed guard (not a mise run) and this issue's acceptance as written (the deny list is about verdict-bearing commands piped to a pager, and a grep filter is not a pager). Two further instances of the same reasoning in one session: the git push --force-with-lease that returned stale info at exit 1 — the very failure recorded above, hit again for the same reason, because the branch had been deleted on merge — and a cargo build whose status I inferred from a quiet tail.

What this adds to the acceptance. The rule the guard should encode is narrower and sharper than "no pagers": a verdict-bearing command's status must be read from the harness, never inferred from its output. A filter, a pager, a wc -l, a head, or an eyeballed tail are all the same substitution — output standing in for status. The deny list therefore wants filters (grep, rg, awk, sed, wc) alongside pagers, and the denial message wants to say read the exit code rather than do not use a pager, because complying with the narrower wording is exactly how this instance happened.

The self-indicting detail: the compliant form is already documented and I used it everywhere else in the same session (cmd >log 2>&1; echo "EXIT=$?", which this issue's acceptance separately and correctly wants denied for the trailing-list reason). The shape only slipped in on a read-only-looking check — "just grepping for errors" — which is precisely where a status feels unnecessary and is not.

The generalisable lesson the guard's own comment states, and its matcher does not implement: a pipe replaces the command's exit status with the pager's. That is a property of pipes, not of mise. An agent reading the guard as a rule about the literal string mise run will comply with it exactly and keep making the error — which is what happened, in the same session, on the next command.

Acceptance.

  • The guard denies a pager pipe over any verdict-bearing command, not just mise run. An enumerable list is enough and keeps it computable: git push, git fetch, git rebase, gh pr *, cargo *, alongside mise run. Ordinary pipes over files and over read-only queries stay allowed — the current comment already draws that line ("a pager over a FILE is fine; a pager over a live task is not").
  • The denial message states the principle (a pipe discards the exit status) rather than naming one command, so complying with it generalises instead of narrowing.
  • A verdict-bearing command followed by a further command in the same Bash list (;, &&, ||) is denied for the same reason: only the last command's status survives, so mise run verify >log 2>&1; echo "EXIT=$?" reports the echo's status with no pipe involved — the laundered shape that looks compliant. The compliant form is the command alone in the call: status read from the harness, output read from the file in a separate call.
  • tests/run-shape-guard.bats covers at least one non-mise case (e.g. git push … | tail) and the trailing-list case.
  • The two places that teach the trailing list are corrected in the same change. The shape is not merely tolerated, it is prescribed: the guard's own CORRECT remediation string (mise-tasks/run-shape-guard) and .serena/memories/toolchain-and-hooks.md both hand out mise run <task> >/tmp/<task>.log 2>&1; echo "EXIT=$?"; tail -20 … verbatim, under the heading the correct form keeps the status. Denying it without rewriting both leaves the guard rejecting the form its own deny message recommends. The replacement is the command alone in the call, with the log read in a separate call.

Third measurement, 2026-08-08 — the trailing-list shape is worse than "looks compliant": backgrounded, the harness itself reports the wrong verdict. The acceptance bullet above already names cmd >log 2>&1; echo "EXIT=$?" and already explains why. What that bullet does not say is where the false verdict is delivered. With run_in_background, the task-completion notification carries the compound's status, so a failing task arrives as Background command "…" completed (exit code 0) — an authoritative-looking statement from the harness, not a reading of mine. Measured twice in one session: mise run fmt notified exit code 0 while /tmp/fmt.exit recorded EXIT=1 and shellcheck had genuinely failed on two style findings; the same wrapper on a later verify notified 0 and I only trusted it after reading the file. So the shape does not merely permit a misread — it manufactures a green report from a component that cannot fail, and hands it over as the notification. That strengthens the case for denying the trailing list at the same severity as the pager pipe rather than treating it as a lesser cousin, and it adds one line to §7: a backgrounded verdict-bearing command whose last list element is an echo is the case to assert on, since that is the form that reaches the notification path.

Fourth measurement, 2026-08-09 (CLOUD-40) — a tail window that is itself a well-formed green verdict. cargo test -p batten --quiet 2>&1 | tail -30 printed six consecutive test result: ok. blocks and I reported the suite green. It was not: a test in an earlier test binary was failing, and its block had scrolled past the window.

This is a sharper variant than the three above, and the reason is cargo test's output shape. It emits one running N tests / test result: pair per test binary, so a tail window does not show a truncated verdict that looks obviously partial — it shows the last few binaries' complete, genuinely green verdicts. There is nothing in the visible text to notice. The prior instances all left a tell (an empty filter result, a status never read); this one presents a fully-formed pass.

Caught only by re-running as cargo test -p batten --test cli and grepping for the test names, which is the harness-status substitute this issue argues against — the real fix was to read the exit code. run-shape-guard did not fire: the command was cargo test, not mise run, which is this issue's whole thesis, measured a third time on a third command family.

It adds nothing to the deny list — cargo * is already there in §8's source-of-truth table — but it adds a line to the §7 obligation: assert the cargo test per-binary case specifically, because a reviewer checking "does the guard stop a truncated verdict" will reach for a shape where truncation is visible, and this is the shape where it is not.

Stronger form, where it applies. A guard is feedforward; it can only catch shapes. For anything whose effect is observable, prefer asserting the state over trusting a status — git rev-parse HEAD origin/<branch> after a push, the verified receipt after verify (CLOUD-193). The receipt pattern is the durable answer and this guard is the cheap one; both are worth having, and the acceptance above is only the cheap half.


Refinement — Ready (the decision table generalises from mise run to verdict-bearing commands, plus the trailing-list shape)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). The decision table in mise-tasks/run-shape-guard: the verdict-bearing command list (mise run, git push, git fetch, git rebase, gh pr *, cargo *) is written once there, as data the matcher reads.
  • Computable predicate (§2). The guard is a pure function of the hook payload; its gate is mise run test:bats over tests/run-shape-guard.bats, in the hk gate. Policy-engine-first: not expressible as a batten.toml rule — command-shape matching over hook events is an engine gap; the engine path is the batten hook port (relatedTo CLOUD-202) fed by the declarative command rule table (relatedTo CLOUD-48), neither blocking — the bash table lands now and is the spec the port consumes.
  • Effect (§3). read — the guard inspects a command string and emits a decision; it runs nothing.
  • Output & exit (§5). The existing deny shape, pointer-only; the deny message states the principle (a pipe hands the exit status to the pager; a following list command replaces it) and the compliant form, never just the matched command.
  • Commit / bump (§6). fix → patch.
  • Test obligation (§7). tests/run-shape-guard.bats: git push … | tail denied; mise run … >log 2>&1; echo "EXIT=$?" denied; a pipe over a file and a pipe over a read-only query allowed; existing mise run cases unchanged; bypass honoured; unparseable input fails open.
  • Blockers (§8). None. relatedTo CLOUD-193 — the receipt is the durable answer for anything whose effect is observable; this guard is the cheap feedforward half and does not wait on it.

CLOUD-1163 Retire the eight small multi-program units — 59.7s, and SIX of eight land today: BOTH remaining blockers (CLOUD-1108, CLOUD-1115) disclaim blocking in their own bodies, by name

COLUMN CORRECTED 2026-09-02 — In Progress → Todo; unit 8 landed, the other seven are unclaimed

Measured against origin/main 9ece058c and the nine open PRs (file lists, not titles):

A claim nobody is working is not a claim. Back to Todo; the Ready block below still holds and blockedBy CLOUD-1251 still binds unit 4 only.


CORRECTION (2026-08-31, second pass) — the two BLOCKERS were read from this row's own table instead of from the rows they cite, and both cited rows say the opposite IN BOLD

This row's 2026-08-31 correction fixed three wrong reasons by reading the programs. It did not re-read the two BLOCKER ROWS, and that is where the remaining error was. Read each of them at head and both disclaim blocking this row, by name:

  • Unit 9 (run-shape-guard, 14.0s) is NOT blocked by CLOUD-1108. That row was re-derived 2026-08-31 against origin/main 0683ce53 and its point 3 names this row explicitly: "CLOUD-1151's build order lists this row third and says it blocks CLOUD-1163's run-shape-guard unit. On the evidence above that unit is not blocked by file-granularity — it is a whole-file retirement whose four successors are already written, and what it owes is the ledger arms and the deletion, not a ratchet change." Verified in the tree rather than inherited: policy/run-shape.rego raises V-COMMIT-STDIN-UNBOUND, V-FOREGROUND-SLEEP and V-BACKGROUND-TIMER, and policy/task-substitution.rego is tracked and opens by naming this exact family. N = K = 4. The #MISE description at run-shape-guard.sh:2 names those same three families and nothing else; the fourth is the cargo clause on the same line. There is no family left waiting, so the file is deletable WHOLE — the ratchet's one admitted disposition.

  • Unit 11 (replay, 18.9s) is NOT blocked by CLOUD-1115. That row's §8 opens: "Blockers (§8). None, in either direction. This row blocks nothing, and a blocks CLOUD-910 relation added here on 2026-08-28 was wrong and has been removed" — the identical relation, wrong once already, re-derived here a second time. Its own measurement is the argument: "nothing refuses a retirement for a missing or failing replay… replay appears in no hk.pkl step, no mise.toml task graph, no workflow and no rule row." Confirmed at head: a pattern scan of mise.toml, hk.pkl and .claude/settings.json for replay returns two PROSE comments (mise.toml:911,952) and no invocation; the only caller of replay-pointers.py is replay.sh:80 itself.

    And CLOUD-1115 is the argument FOR unit 11's deletion, not against it. A gate whose tree arm "has never run against a real gate and cannot pass", off the landing path, invoked by nothing, is a gate deciding nothing. Its disposition is the // withdrawn: arm — subjects-and-reason with no successors at all, policy/shell-retirement.rego:708 — which is the one ledger arm that has never been used (CLOUD-1176). Unit 11 is the campaign's first honest candidate for it, and it is the single largest member of this row.

Both blockers were carried on this row's TITLE-adjacent verdict rather than on the cited row's body. That is CLOUD-1166's class one level up from the three this row already corrected: not classifying a program instead of reading it, but classifying a BLOCKER instead of reading it. The reading took minutes, as it did the first three times.

The seconds are stale, and every rank moved

The 52.2s in the old title and the per-unit column below predate CLOUD-1198's corpus regeneration. Re-derived from bench/suites/RESULTS.md at origin/main:

unit members' suites was now disposition at head
11 replay 7.1 18.9 lands — // withdrawn:
6 token-bench 16.4 + token-bench-check 0.4 10.6 16.8 lands
9 run-shape-guard 13.6 + -quoting 0.4 11.8 14.0 lands — whole-file
4 mcp-allow-check 5.9 + connector-allow-guard 0.5 + -resolve 0.4 9.5 6.8 blocked (CLOUD-1251)
10 perf-compare 0.7 + perf-gate 0.2 1.0 0.9 lands
8 gh-guard 0.9 1.0 0.9 lands
7 suite-bench-check 0.9 0.7 0.9 lands
3 container-preflight 0.4 + egress-check 0.1 0.5 0.5 not migrating (a verdict)
52.2 59.7

Unit 11 went from fourth-largest to largest, and units 11 and 9 together — 32.9s, 55% of this row — were the two carried as blocked. The row is now: six units and 52.4s land today, 6.8s is genuinely blocked on CLOUD-1251, and 0.5s is not migrating.

Arm-4 evidence for the one surviving block, quoted rather than categorised: connector-allow-resolve.sh:142 is for candidate in /tmp/mcp-config-cse_*.json; do — a glob over a set discovered at runtime, under a suffix minted per session, which [[rule.external]]'s one-declared-path-per-row shape cannot express. That is CLOUD-1251 and it is real.


Why

Units 3, 4, 6, 7, 8, 9, 10 and 11 of the 83-unit partition. Eight independent deltas, 19 programs, 52.2s combined. They are one row because each is small and none glues to another; each still lands as its own PR with its own ledger arms.

unit members suites s gates #MUTANT
9 run-shape-guard, payload-field run-shape-guard 11.2, run-shape-guard-quoting 0.6 11.8 1 3
6 token-bench, token-bench-check token-bench 10.4, token-bench-check 0.2 10.6 1 1
4 connector-allow-guard, connector-allow-resolve, mcp-allow-check mcp-allow-check 8.6, +2 9.5 3 4
11 replay.sh + replay-pointers.py replay 7.1 7.1 0 0
10 perf-gate, perf-compare perf-compare 0.8, perf-gate 0.2 1.0 2 2
8 gh-guard, gh-guard-check gh-guard 1.0 1.0 1 1
7 suite-bench-check, suite-bench suite-bench-check 0.7 0.7 1 2
3 container-preflight, egress-check, gh-preflight container-preflight 0.4, egress-check 0.1 0.5 1 1

Glue, per unit (each is a $(dirname "$0")/$here resolution or a multi-subject header, neither of which any admission in policy/shell-retirement.rego can match):

  • 9 — run-shape-guard.sh:110 field="$here/payload-field.sh". hook-pin-check.sh:149 names payload-field literally, so it does not join.
  • 6 — tests/token-bench.bats:2 declares both subjects; also token-bench-check.sh:131.
  • 4 — connector-allow-guard.sh:59 and mcp-allow-check.sh:324 → connector-allow-resolve.sh. mcp-allow-check.sh:235 iterates "$(dirname "$0")"/*-guard.sh — a glob, not a named path, so it does not glue gh-guard/ready-guard.
  • 11 — tests/replay.bats:2 declares replay.sh and replay-pointers.py. The .py is ungoverned so it owes no arm, but SubjectFacts::died demands it actually be deleted. The only unit whose closure crosses out of the governed set.
  • 10 — perf-gate.sh:48 runs perf-compare. perf-pair is a mise.toml task, not a program, so it does not join.
  • 8 — tests/gh-guard.bats:2 declares both; also gh-guard.sh:22.
  • 7 — tests/suite-bench-check.bats:2 declares both. This unit owns bench/suites/RESULTS.md, the source of every number in this campaign.
  • 3 — container-preflight.sh:61 → egress-check.sh; :82 → gh-preflight.

The home each unit's decision lands in

A disposition is chosen before a successor is designed (CLOUD-1176). "Port it into crates/batten" is not on the list of homes: house style §2's surface is closed and §9 says consumer-specific behaviour is reconstructed through extension surfaces, never baked into the core.

unit home why
8 consumer module — policy/*.rego a pure argv classifier over input.call.segments; the gh lifecycle vocabulary and the task names it recommends are this repo's, so rule 1 keeps it out of a preset
9 consumer module (policy/run-shape.rego, three families already there) + existing verb for the glue payload-field.sh's successor already ships as batten payload field; the remaining cargo-substitutes-for-a-task family derives from mise.toml task bodies, which are consumer facts
10 existing verb — batten perf / batten perf pair already shipped, effect write
6 consumer module — policy/*.rego, two predicates token-bench-unmethodical is a document predicate over bench/tokens/RESULTS.md (input.tree.lines); token-bench-drift is a producer-written identity verdict (input.tree["tool-verdict"]). token-bench itself is a measurement task and a measurement task is not a gate
7 consumer module — policy/*.rego, plus a [[pattern]] row suite-bench-check is SET EQUALITY over paths — input.tree.tracked against the corpus rows in input.tree.lines — and reads no duration at all; suite-bench produces bench/suites/RESULTS.md
4 consumer module, once a DISCOVERED-path fact exists the MCP config paths are consumer-specific by construction — and external does not reach them, because it declares one path per row and these are globbed per session (see the 2026-08-31 correction)
3 keep-as-task — NOT MIGRATING proxy and token-scope probing is not a completion gate's job, and per CLOUD-1202 that is a recorded verdict rather than a fact to wait for
11 undecided — likely DELETE replay has no successor verb and CLOUD-1115 says its tree arm cannot pass as built; decide the disposition before designing anything

Which of the eight are actually reachable

  • Unblocked: unit 8 (gh-guard-check is a pure argv classifier over input.call.segments; gh-guard.sh:22 only shells to it).
  • Blocked on CLOUD-1108 — unit 9. The ratchet is file-granular and run-shape-guard is multi-family; three of four families already landed as policy/run-shape.rego, and the remaining cargo-substitutes-for-a-task family is expressible but cannot move alone.
  • CORRECTION (2026-08-30) — "benchmark/build execution" was never a blocker. This row previously listed units 6, 7, 10 and 11 as blocked because "no fact carries" a build or a suite result. Measured against the emitted surface rather than inferred: batten perf **and **batten perf pair already ship, effect write, and crates/batten/src/perf.rs builds two binaries and spawns hyperfine. §5's split is check = read and structurally incapable of spawning; enforce/exec are the spawning side, and perf sits there. So an execution is not outside the engine — it is outside check, which is a different sentence. Unit 10 is therefore unblocked, and units 6 and 7 are blocked only on their comparator's inputs, never on the execution. This also re-scopes CLOUD-1171, which generalised the same false premise.
  • Blocked, out-of-root: unit 4 (connector-allow-resolve.sh:142 globs /tmp/mcp-config-cse_*.json), unit 3 (container-preflight reads $HTTPS_PROXY and token scopes). Superseded — see the 2026-08-31 correction below. "Out-of-root" is no longer the reason for either, and it is now the wrong reason for both.

So three of eight land today — units 8, 10, and (comparator-first) 7. That is the honest count and it is why CLOUD-1151 cannot size a wave from unit count.

CORRECTION (2026-08-31) — the blocked verdicts above predate the fact surface they were judged against

Every blocked verdict in this row was written on 2026-08-29/30. Seven fact families have landed since, and the row was never re-read against them. Enumerated from the generated schema/policy-input.schema.json at 0683ce53, input.tree now carries external (CLOUD-1167), tool-verdict (CLOUD-1171), captured (CLOUD-1188), state (CLOUD-1203), commit-meta (CLOUD-1187), git-history, forge (CLOUD-1154) and staged — all Done. Re-derived per unit:

  • **Unit 4 is still blocked, but NOT because the paths are out of root — because they are DISCOVERED rather than DECLARED. **external landed and it takes exactly one path under one named root environment variable per declared row; the schema's own description is "the parsed node of the file found at path beneath the directory the named root environment variable holds", keyed by the declaring row's id. connector-allow-resolve.sh:142 globs /tmp/mcp-config-cse_*.json — a set discovered at runtime, which no declared single path can express, and a consumer cannot enumerate the ids in advance because the suffix is minted per session. That is a real gap with no owning row; it is filed separately and this unit is blockedBy it.

  • **Unit 3 is still blocked, and its reason belongs to a different class entirely. **container-preflight reads $HTTPS_PROXY's VALUE and probes egress and token scopes. external reads a FILE under a root variable, never a variable's value, and nothing on the surface performs a probe. Per CLOUD-1202's decision, this is the scope reminder's "not a reference monitor" clause rather than a missing fact: **the disposition is keep-as-task, and the unit should be recorded as not migrating rather than carried as blocked forever. **CLOUD-1201 reaches the same verdict from the caller's side.

  • Units 6 and 7 — SETTLED, and the premise was wrong in BOTH directions. NEITHER COMPARATOR COMPARES A MEASUREMENT. An earlier revision of this clause said they compare "MEASUREMENTS against a baseline" and sent an implementer to settle which fact carries one. That was asserted from the category "comparator over two records" without reading either program, and it is the same error one level down as the two above.

    Unit 7 (suite-bench-check) reads no duration at all, and its own header says so as a design decision: "It is NOT byte-diffed against a fresh run … Wall clock is not [deterministic] … WHAT IS DETERMINISTIC IS MEMBERSHIP, and that is what rots." The predicate is set equality between two sets of PATHS — git ls-files 'tests/*.bats' against the paths in the third column of bench/suites/RESULTS.md, both directions. Both are on the surface today: input.tree.tracked ("repository-relative paths the working-tree walk yields — paths, never content") and input.tree.lines over the corpus. Unit 7 needs no new fact and is fully expressible now. Its output is already pointer-only by the same rule 4 the module inherits — "Never a duration — a number here would be a second authority over the corpus."

    Unit 7 does need a [[pattern]] row, and this is the one real hazard: the corpus row is parsed for a backticked path inside a Markdown table, and the shell records that exact regex being got wrong once — "Run against a formatted corpus it matched no row at all and reported every tracked suite as missing: 150 findings, all false, from a gate that looked like it was working." prettier owns Markdown here and pads table columns. Carry that case into the successor's suite.

    Unit 6 (token-bench-check) is two predicates, and neither is a threshold either. token-bench-unmethodical asks whether every published figure states workload, baseline, run count and method — a pure document predicate over bench/tokens/RESULTS.md, so input.tree.lines. token-bench-drift asks whether the committed table is byte-identical to what a fresh run produces from committed fixtures — an identity check, not a comparison against a threshold, and exactly the shape input.tree["tool-verdict"] landed for: "read back from a record a producer wrote OUTSIDE the engine, because check is read-only and structurally cannot run a validator — KEYED BY (tool, pinned version, input digest)". The producer runs the generator; the module reads the verdict. So unit 6 is also expressible, and tool-verdict IS the answer for its drift half — the opposite of what this clause previously said.

    Neither unit needs records or produced, and neither needs a measurement fact that does not exist.

~~THE COUNT CHANGES: it is now FOUR of eight, not three. ~~(Superseded 2026-08-31 by the second-pass correction at the top: it is SIX of eight. Units 9 and 11 were blocked on rows that each disclaim blocking.) Unit 6 was carried as blocked on a comparator input it does not need, and reading token-bench-check.sh rather than its category shows it is expressible today. Units 6, 7, 8 and 10 land now. Unit 3 is not migrating (a verdict, not a block); units 4, 9 and 11 remain blocked, on CLOUD-1251, CLOUD-1108 and CLOUD-1115 respectively. Only unit 4 remains blocked, on CLOUD-1251. CLOUD-1108 and CLOUD-1115 each disclaim blocking this row in their own bodies — see the second-pass correction at the top — and both blockedBy relations have been removed.

Every wrong reason in this row came from classifying a program instead of reading it. Three corrections, three instances: "out-of-root" for unit 4, "missing fact" for unit 3, "comparator inputs" for units 6 and 7. The reading in each case took minutes. Read the program before recording why it cannot move.

Two need a runnable successor at a real path, not just a ledger arm

run-shape-guard.sh is .claude/settings.json:24's hook command; serena-mcp.sh (a different unit) is .mcp.json:4's. Deleting either without an executable at that path disarms a live hook silently. Both consumer files are ungoverned, so the repoint is free — but it must happen in the same delta.


Refinement — Ready (eight deltas, one row; units 8, 10 and 7 land today)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Authority boundary (§1). 19 programs and 13 suites across eight PRs. One // carried: arm per deleted path; replay-pointers.py is deleted without an arm (ungoverned) but must be gone. Ten $MUTANT_GATES entries and 14 #MUTANT rows move to successors' tiers.
  • Computable predicate (§2). Each unit's decision is conserved; this row moves where it lives, never what it concludes. Conserve the decision, not the defect (CLOUD-1176): where a unit's shell carries a tracked defect, the successor implements the corrected decision and the defect's own row records the change — a retirement that launders a known defect forward into Rust is the failure this campaign exists to avoid, not evidence of fidelity. What must be shown unchanged is the verdict over every case the dying suite covered, minus the cases a tracked defect row explicitly re-decides.
  • Deliberately not in scope (§2). Deciding CLOUD-1108's file-granularity fix. Changing what any benchmark measures. Merging any two of these units — they are independent and merging them would manufacture the glue this partition exists to avoid.
  • **Effect (§3). **read for the guards and checks. token-bench, suite-bench, perf-* and replay execute suites or builds and keep their existing effect class.
  • Output and exit (§5). Pointer-only per unit. Exit follows the 0/1/2/3 table; could-not-look is 3, never a false 2.
  • **Commit / bump (§6). **refactor(ci) — no bump, per PR. Below 0.1.0 every release-worthy type collapses to a patch, but refactor is not one: it releases nothing at any version. CLOUD-595's correction.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/; no .bats file is added or edited (V-SHELL-RULE-ADDED refuses one at deny). One arm per deleted path (CLOUD-908). Shown able to fail per CLOUD-418 per unit, with the anti-vacuity mirror each time. For unit 9 specifically: the hook must still deny after the repoint — a test that only checks the module loads would pass over a disarmed .claude/settings.json. Mutated: 14 rows re-homed, mutant-census green across every move. Replayed: per unit; CLOUD-1115 is the standing caveat on replay's tree arm, and unit 11 **is **replay — it cannot be its own instrument.
  • **Blockers (§8). **blockedBy CLOUD-1251 (unit 4 only). blockedBy CLOUD-1108 (unit 9 only). Removed 2026-08-31: CLOUD-1108's own re-derivation names this row's unit 9 as not blocked by it, and CLOUD-1115 states it blocks nothing. relatedTo CLOUD-1151 (the wave owner), CLOUD-1115, CLOUD-908, CLOUD-418.

Weakens: rule-removed rule[harness-wiring-merged]

Weakens: rule-predicate-changed rule[harness-wiring].documents

Weakens: rule-predicate-changed rule[harness-wiring].external

Weakens: rule-predicate-changed rule[harness-wiring].minted

Unit 9's landing carries four config smells against origin/main, declared here rather than only in the commit that performs them. harness-wiring-merged is removed because CLOUD-1160's split of the wiring predicate into two rules cited an engine defect that does not exist, so the halves recombine into one rule. The three predicate changes are harness-wiring gaining the merged rule's external rows, the policy/harness-declared.json document that keeps the exemption table's three directions testable once unit 9 empties it, and CLOUD-1310's minted fact source. config-lint compares bytes with no ranking, so an ADDED fact source reads as a change in the same way a removed one does; each of these reads more rather than less.

Acceptance

  • Eight PRs, 19 programs and 13 suites deleted, one arm per deleted path, replay-pointers.py gone.
  • .claude/settings.json:24 points at a live executable and the hook still denies, asserted end to end.
  • bench/suites/RESULTS.md survives unit 7's retirement — the campaign's own measurement source must not die with its producer.
  • mutant-census green; all 14 mutations honoured.
  • Each unit records whether it landed or why it could not.

Units 3, 4, 6, 7, 8, 9, 10, 11 of 83.

CLOUD-1373 The committed-policy suites read the developer's own admission store, so a spent admission on the machine turns a deny assertion green

Why

common::batten() scrubs the ambient environment twice — every BATTEN_ variable the surface declares, and every bypass name beside it. bypass_scrub.rs exists to assert exactly that, and its header states the principle: "The suite's own environment cannot weaken the engine it is testing."

Both scrubs are walks over environment variables. An admission is not one. CLOUD-1051 retired BATTEN_FILED_HERE_BYPASS and its siblings precisely so that suppressing a refusal would cost a signed record in the state store rather than a knowable string anyone could export. So the channel that replaced the scrubbed ones is unreachable from the scrub by construction — the same shape as BATTEN_BIN, which batten()'s own comment already calls out as needing to be set explicitly for that reason.

**This suite already knew. **the_hatch_is_load_bearing had to stop observing the hatch through a protected-path refusal, and its comment says why: "that class declares an override route and the boundary honours a spent admission for it, so the variable stopped being its way out." The suite recorded that an admission had replaced the variable, and left the store ambient.

Measured 2026-09-02, and it is a false green in the unsafe direction. A spent admission for batten.toml — taken by hand, for unrelated work, hours earlier in the same session — turned cli.rs::the_committed_protected_paths_fire_on_a_mutating_verb green-side: mv batten.toml elsewhere.toml answered exit 0 where the case demands 2. The suite reported that the committed protected-path policy refuses a write while a record on that machine was admitting it. It was diagnosed only because the failure was chased to its cause; a developer holding a live admission sees a passing suite and no signal at all.

It is not one case. State is keyed by repository, so every suite that drives the binary against the REAL root shares the real repository's own segment: cli.rs, mediated_verbs.rs (whose AUTHORITY constant is batten.toml), gh_guard.rs, pipeline_shapes.rs, refusal_ceiling.rs, shell_write_advisory.rs. Those suites cannot escape it with a fixture home, because the committed config is their subject and the segment is derived from the root that carries it. mediated_admission.rs is unaffected for the same reason in the other direction — its fixture is a scratch repo, so it has always had a segment of its own.

**Predecessor. **CLOUD-619 (Done) fixed the platform half of this class: suites redirected with XDG_DATA_HOME alone, which is inert on Windows, and common::state_dir/state_home landed to redirect on every platform. This is the next instance — the suites that redirect are correct; the ones running against the real root never redirected at all.


Refinement — Ready (redirect the suite's state root by default)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • **Authority boundary (§1). **crates/batten/tests/it/common/mod.rs and crates/batten/tests/it/bypass_scrub.rs. No engine source and no config: the defect is in the harness, and the engine reading its configured state root is correct behaviour.
  • **Computable predicate (§2). **common::batten() points the child's state root at a suite-owned directory on every platform, so no spawned batten resolves the developer's store; suites that redirect explicitly still override it.
  • Deliberately not in scope (§2). The admission mechanism itself, mediated_admission.rs (already isolated by fixture), and the per-suite fixture homes CLOUD-619 landed — all correct as they stand.
  • Per process, not per suite (§2). nextest runs each case in its own process, so a process-scoped root keeps state written by one batten() call visible to the next call in the same case — which several cases depend on — while no case can reach another's.
  • Effect (§3). read — a test-harness change.
  • Commit / bump (§6). test(harness), no consumer break and no library break: nothing outside tests/ changes, so mise run semver has nothing to decide.
  • **Test obligation (§7). **crates/batten/tests/it/bypass_scrub.rs, beside the two scrubs it already asserts. Shown able to fail per CLOUD-418, two halves matching that file's own discipline: the mechanism half reddens when the redirect is removed, and an anti-vacuity mirror issues and spends a real admission against the real root and shows the committed protected gate flipping from exit 2 to exit 0 — so the redirect is demonstrably load-bearing rather than tidy.
  • Blockers (§8). None. relatedTo CLOUD-619 (the platform half of this class), CLOUD-1227 (the bypass scrub this extends), CLOUD-1051 (which made the admission the live channel).

Acceptance

  • No spawned batten in the integration suites resolves the developer's state root, on any platform.
  • bypass_scrub.rs asserts both halves, and the anti-vacuity case demonstrates an admission disarming the committed protected gate.
  • The suite is green with a live admission for batten.toml present in the developer's own store — the exact condition that produced the false green.

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 16 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: a9ca7cc0-6ca5-4fed-bacb-8b66bd087daf

📥 Commits

Reviewing files that changed from the base of the PR and between 8a2ecf9 and 604815b.

📒 Files selected for processing (14)
  • batten.toml
  • crates/batten/src/doctor.rs
  • crates/batten/src/hook.rs
  • crates/batten/src/spec.rs
  • crates/batten/src/surface.rs
  • crates/batten/tests/it/bypass_scrub.rs
  • crates/batten/tests/it/cli.rs
  • crates/batten/tests/it/common/mod.rs
  • crates/batten/tests/it/gh_guard.rs
  • crates/batten/tests/it/mediated_verbs.rs
  • crates/batten/tests/it/pipeline_shapes.rs
  • crates/batten/tests/it/preset_segments.rs
  • crates/batten/tests/it/refusal_ceiling.rs
  • crates/batten/tests/it/shell_write_advisory.rs

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@wenzowski wenzowski changed the title fix(hook): make the wiring gates honest and actionable Make the hook-wiring gates honest and actionable Sep 2, 2026
@wenzowski
wenzowski force-pushed the claude/wiring-gates-honest branch from b26ee81 to 3f89401 Compare September 2, 2026 20:34
@wenzowski wenzowski changed the title Make the hook-wiring gates honest and actionable Make the wiring gates honest, and the suites that judge them hermetic Sep 2, 2026
`hook wire duplicate` fired correctly and named no remedy. Its two routes
were both `document` — AGENTS.md and `.claude/rules/toolchain.md` — and
neither document mentions `batten wiring reclaim`, the verb that removes the
registration and takes the rule to exit 0.

Its class actively pointed away from the remedy: "the launcher's own merged
settings, which live outside this repository" is true about where the file
lives and false about what can fix it, and it is the sentence a reader
reasons from. `doctor.rs`'s `MERGED_SIBLING` doc said the same thing more
plainly — "editing the repository cannot remove the registration" — which is
a statement about editing tracked files that reads as a statement about this
repository's reach.

Measured 2026-09-02 (CLOUD-1339 §3): a session read the refusal, read that
doc, concluded the condition was unfixable from here, spent
`HK_SKIP_STEPS=hooks-wiring-check` on three commits, wrote that conclusion
into two commit messages on main-bound history, and put a three-option menu
to a human. All of it was closed by one command the refusal could have named.
A second session reproduced the same dead end today before finding the verb.

So the class says it is repairable and says what the repair does not buy — a
launcher that registers at session start registers again next session, so
this is a REPAIR and not a fix, and a reader must not go looking for a
permanent one. The command route is FIRST, because a reader who follows only
the first route has to reach the thing that clears the refusal.

The generalisable half is deliberately split rather than taken here.
Measured over the committed registry: 87 of 130 `[[verdict]]` rows carry no
command or task route and no `no_fix_reason`, so a gate asserting that
obligation fires on 87 rows on its first run — the false-positive-first-
firing shape CLOUD-199 prices and `privileged-lane.rego:60-65` records. That
needs its own row and a triage pass, not a clause in this one.

Verified: `batten policy explain "hook wire duplicate"` lists
`verb run first  command  batten wiring reclaim` first;
`verdict-routes-resolve` and `remedy-authorship` both exit 0.

Closes: CLOUD-1339

Admits: 7ada5204221a96b63ea99a358227f06f94c8d71eb92c1d6bdba2f532652eb0b5
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 8c95e02
Admits-epoch: 740d686e61edc7dad9b8a104e1f1f406ab53254158581a5a05a9452914dd6a47
Admits-author: alec@wenzowski.com
Admits-prev: 7c8bc6d7c40b7bd40a10433b44d540707913c7fd6e78be19d55f6b3e95984e55
Admits-answer-lost: The refusal keeps naming only two prose documents, neither of which mentions `batten wiring reclaim`. Measured 2026-09-02 (CLOUD-1339 §3): that cost one session three commits with `hooks-wiring-check` switched off via HK_SKIP_STEPS, a false claim about this repository being unable to remove the registration written into two commit messages on main-bound history, and a three-option menu put to a human that one command already answered.
Admits-answer-precondition: The change is a new [[verdict.route]] entry and a class rewrite on the `hook wire duplicate` row. batten.toml is the ONLY surface where a [[verdict]] row declares its routes — no verb adds one — so writing the protected path directly is the only route left. The write lands in the PR diff for CLOUD-1339, where a reviewer sees it.
Admits-answer-rejected-route: `config read first` targets batten.toml itself — reading the file is not a change to it, and the change IS to that file. `patch run first` is `git restore`, which discards a working-tree change; I am adding one, not reverting one.

Refs: CLOUD-1339
`batten hook --harness <x>` was spelled independently in three places with
nothing linking them: the `SURFACE` row that declares it, the generator in
`hook::wiring_command`, and the diagnostic in `doctor::reaches_engine`. Five
committed wiring files carry it as data.

A disagreement between them is not ordinary drift, it is a SILENT FAIL-OPEN.
An unknown subcommand is a clap error, which is `ExitCode::Usage` (1), and
`exit.rs` states the consequence as a design property: every host reads
anything but 0/2 as "the hook itself failed, let the call through". So three
literals that disagree do not break loudly — they turn enforcement off across
every harness while `doctor hooks` reports green. `reaches_engine` matched the
literal `"hook"`, so against a settings file naming a command the surface no
longer declares it returned true: the one diagnostic built for this exact
failure was blind to it.

So both consumers now derive from the row, anchored on `CommandDecl::id` and
never on `path`. That is the field's own contract — `path` is "the one thing
about a row that is expected to change", so a derivation keyed on it re-breaks
on exactly the rename it exists to survive.

A fourth loose literal goes with them: the binary's own name, which the
diagnostic matched as a file stem. It is `surface::BINARY` now, beside the row
the generator emits, because those two agreeing is what makes a registration
reach the engine.

No fallback literal anywhere. A surface declaring no mediation row yields a
command with no verb in it, which matches nothing, so every registration reads
as drift — loud. Emitting `"hook"` when the declaration is gone would be the
fourth spelling again, and it would report healthy.

Shown able to fail, per CLOUD-418:

- `wiring_naming_an_undeclared_path_does_not_reach_the_engine` asserts
  `reaches_engine` is FALSE against `batten adjudicate --harness claude-code`
  — the shape a half-done rename leaves in a committed wiring file. It passed
  as healthy before this change. Reverting either consumer to a literal reds it.
- `the_emitted_argv_is_the_rows_path_and_its_required_flags` pins the
  derivation itself.
- `the_mediation_row_resolves` makes a deleted or renamed `id` loud, since that
  is the one edit the anchor does not survive.

The five committed wiring files and their fixtures are byte-identical, which is
this row's stated acceptance and the reason it takes no bump.

`mise run test:cargo`: 4126/4126 green.

Closes: CLOUD-1191

Refs: CLOUD-1191
…d it

CLOUD-1191 removed three unlinked spellings of the mediation verb; two
test assertions still carried a fourth and a fifth as literals, and both
fail in the direction that asserts nothing.

`doctor.rs`'s Windows-image case hardcoded
`/opt/bin/batten.exe hook --harness claude-code`. `spec.rs`'s
`the_mediation_entrypoint_is_never_read_only` hardcoded `"hook"` — after
a rename that case would ask whether a path the surface no longer
declares is read-only, get "not in the allowlist" for the trivial reason,
and pass green while pinning nothing. That is the same false green
CLOUD-1191 exists to remove, one level down, in the test that guards it.

Both now derive from `surface::mediation()` / `mediation_argv()`, so the
row is the single authority the rest of the change already made it.

Found by attempting CLOUD-1192's rename against this branch: the
derivation carried the generator and the diagnostic without an edit, and
these two literals are what went red. Measurement recorded on CLOUD-1192;
the rename itself is not in this change.

Refs: CLOUD-1191
… the real repository

`common::batten()` scrubs the ambient environment twice — every `BATTEN_`
variable the surface declares, and every bypass name beside it. Both are
walks over ENVIRONMENT VARIABLES, and an admission is not one: CLOUD-1051
retired `BATTEN_FILED_HERE_BYPASS` and its siblings precisely so that
suppressing a refusal would cost a signed record in the state store rather
than a knowable string. So the channel that replaced the scrubbed ones is
unreachable from the scrub by construction.

`bypass_scrub.rs` already knew half of this. `the_hatch_is_load_bearing`
had to stop observing the hatch through a protected-path refusal, and its
comment says why: "that class declares an override route and the boundary
honours a spent admission for it, so the variable stopped being its way
out." The suite recorded that an admission had replaced the variable, and
left the store ambient.

Measured 2026-09-02, a false green in the unsafe direction: a spent
admission for `batten.toml` in the developer's own store turned
`cli.rs::the_committed_protected_paths_fire_on_a_mutating_verb`
green-side — `mv batten.toml elsewhere.toml` answered exit 0 where the
case demands 2, so the suite reported that the committed protected-path
policy refuses a write while a record on that machine was admitting it.

State is keyed by repository, so the affected suites are exactly those
whose subject IS the committed configuration and which therefore run at
the real root: `cli.rs`'s hook helpers, `mediated_verbs.rs` (whose
`AUTHORITY` is `batten.toml`), `gh_guard.rs`, `pipeline_shapes.rs`,
`refusal_ceiling.rs`, `shell_write_advisory.rs`, `preset_segments.rs`.
They cannot escape it with a fixture home, because the segment is derived
from the root that carries the config.

NOT A DEFAULT ON EVERY SPAWN, which was the first shape tried and is
wrong. A fixture suite may spawn a child that writes the store and read it
back in-process — `admission.rs`'s
`a_correctly_answered_override_completes_end_to_end` does, through
`admission::load`, which resolves the root from the PARENT's environment.
Redirecting only the child splits the two. That case is not the defect:
its subject is a scratch repo, so it already has a segment of its own.

`state_roots` lives in `common/` rather than in the asserting suite
because that module is the one place the variables may be named at all,
which `primitives::no_suite_sets_the_state_dir_variables_itself` enforces
— a case that re-typed them would become the copy that audit refuses
while claiming there are none.

Shown able to fail, both halves, matching this file's own discipline: the
mechanism half reddens when the redirect is removed, and an anti-vacuity
mirror issues and spends a real admission against the real root and shows
the committed protected gate flipping from exit 2 to exit 0.

Refs: CLOUD-1373
@wenzowski
wenzowski marked this pull request as ready for review September 3, 2026 00:04
@wenzowski
wenzowski force-pushed the claude/wiring-gates-honest branch from 3f89401 to 604815b Compare September 3, 2026 00:04
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 604815b into main Sep 3, 2026
10 of 11 checks passed
@wenzowski
wenzowski deleted the claude/wiring-gates-honest branch September 3, 2026 00:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant