Skip to content

Commit 21563e4

Browse files
Merge pull request #94 from dollspace-gay/fix/rewire-control-plane-gates-93
fix(harness): re-wire the de-wired gates + route the control plane (#93)
2 parents 8c022a6 + 904f4bc commit 21563e4

9 files changed

Lines changed: 880 additions & 2 deletions

File tree

.claude/settings.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@
1616
],
1717
"matcher": "Write|Edit"
1818
},
19+
{
20+
"hooks": [
21+
{
22+
"command": "HOOK=\"$(git rev-parse --show-toplevel 2>/dev/null)/tooling/spec-discipline.py\"; if [ -f \"$HOOK\" ]; then python3 \"$HOOK\"; else exit 0; fi",
23+
"timeout": 5,
24+
"type": "command"
25+
}
26+
],
27+
"matcher": "Read"
28+
},
1929
{
2030
"hooks": [
2131
{
@@ -46,6 +56,21 @@
4656
}
4757
],
4858
"matcher": "Write|Edit|Bash"
59+
},
60+
{
61+
"hooks": [
62+
{
63+
"command": "HOOK=\"$(git rev-parse --show-toplevel 2>/dev/null)/tooling/spec-discipline.py\"; if [ -f \"$HOOK\" ]; then python3 \"$HOOK\"; else exit 0; fi",
64+
"timeout": 5,
65+
"type": "command"
66+
},
67+
{
68+
"command": "HOOK=\"$(git rev-parse --show-toplevel 2>/dev/null)/tooling/anti-pattern-gate.py\"; if [ -f \"$HOOK\" ]; then python3 \"$HOOK\"; else exit 0; fi",
69+
"timeout": 5,
70+
"type": "command"
71+
}
72+
],
73+
"matcher": "Write|Edit"
4974
}
5075
],
5176
"SessionStart": [

.design/tooling/control-plane.md

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
# Control Plane — the gate that guards the gates
2+
3+
<!--
4+
tier: 3-component
5+
status: draft
6+
governs: tooling/control-plane-check.py + the control-plane files it and
7+
doc-drift.py now pin (.claude/settings.json, .claude/agents/*.md) +
8+
the `make control-plane` Makefile target and its CI step. Explicitly
9+
NOT `scripts/audit.sh`, which this component leaves byte-identical
10+
(the doc-drift decision-5 precedent).
11+
audited-content-sha256: 383403a2dd8c325e76f22e4f2986b820f093d8eaf8bceb7f1fdf27c9c2972382
12+
thesis-refs:
13+
- thermite-design.md §1 (trust relocated: "a skeptical third party can audit in minutes")
14+
- thermite-design.md §8 (#[slag]: the unverified residue is LOUD, never silent)
15+
issue: crosslink #93
16+
prior-arc:
17+
- .design/tooling/doc-drift-tripwire.md (the sibling gate: pinned freshness for
18+
routed design docs. This component is its missing complement — doc-drift pins
19+
the CONTENT of what the routes govern; nothing pinned whether the routes are
20+
WIRED. Same 0/1/3 exit contract, same fixture-oracle test convention.)
21+
-->
22+
23+
## Summary
24+
25+
`tooling/spec-discipline.py` and `tooling/anti-pattern-gate.py` are the two
26+
agent-facing enforcement gates. They are real, tested, and tracked — and they
27+
only ever fire because `.claude/settings.json` wires them into Claude Code's
28+
`PreToolUse`/`PostToolUse` events. That wiring is the entire control plane, and
29+
until this component nothing checked it.
30+
31+
On 2026-06-21 commit `5581b65f` ("Stage-3 REQ-1: the @bv clause tag,
32+
parse-gated") removed three hook entries from `.claude/settings.json`: the
33+
`PostToolUse`/`Read` recorder and the `PreToolUse`/`Write|Edit` pair invoking
34+
both gates. The commit message is entirely about the `@bv` clause tag and never
35+
mentions the settings change; the crosslink-generic entries survived while the
36+
project-specific ones vanished. The signature is a `crosslink init` regenerating
37+
`settings.json` from its generic template — the file is tracked but
38+
machine-authored, so a routine (or `--force`) re-init clobbers it.
39+
40+
Both gates were therefore **dormant for the entire Stage-3 arc**, while:
41+
42+
- `README.md:172` — "`.claude/settings.json` (also tracked) wires these into
43+
Claude Code's `PreToolUse`/`PostToolUse` events, so they enforce automatically
44+
— no setup."
45+
- `goal.md:183` — "### Spec-discipline (enforced by `tooling/spec-discipline.py`)"
46+
- all four `.claude/agents/acto-*.md` — "The spec-discipline hook enforces these
47+
reads before it lets you edit."
48+
49+
This is precisely the **"asserted enforcement that isn't"** failure mode the
50+
methodology exists to prevent, and it survived weeks of ACToR loops because
51+
nothing in the repo could see it.
52+
53+
## The blind spot this closes
54+
55+
`tooling/doc-drift.py` pins design↔source freshness for every file reachable
56+
from `tooling/spec-routes.toml`. It is thorough, CI-enforced, and it even routes
57+
its own gate (`tooling/doc-drift.py``.design/tooling/doc-drift-tripwire.md`,
58+
REQ-11's dogfood). But:
59+
60+
```
61+
$ grep -nE "\.claude|settings\.json|agents/" tooling/spec-routes.toml
62+
(no output)
63+
```
64+
65+
No route covered the control plane. The design layer governed every source file
66+
in the workspace **except the file that decides whether the governance runs**
67+
so a `settings.json` regression was structurally invisible, and an
68+
`.claude/agents/*.md` regression with it. (That second gap is not hypothetical:
69+
`acto-critic.md` and `acto-doc-author.md` carry `model: fable` in frontmatter
70+
while their bodies say "Opus — always. … Never substitute." Nothing catches the
71+
contradiction because agent defs are not routed. Reconciling that is out of
72+
scope here — see Open questions OQ-1 — but routing them is not.)
73+
74+
The custom anti-pattern rules also have no CI backstop: there is no
75+
`clippy.toml`, and `ci.yml:95` runs stock `cargo clippy --workspace
76+
--all-targets -- -D warnings` with no `disallowed_*` / `unwrap_used` lints. So
77+
the anti-pattern gate being dead was not compensated elsewhere (OQ-2).
78+
79+
## Design decisions (resolved here, grounded below)
80+
81+
1. **Assert the wiring, don't regenerate it.** A merge step that re-injects the
82+
entries after `crosslink init` would be silent repair — and silent repair of
83+
a control plane is how you stop noticing that something keeps breaking it.
84+
The gate FAILS LOUDLY and prints the exact JSON to paste back. The human sees
85+
every occurrence.
86+
87+
2. **Matcher coverage, not matcher equality.** A required wiring is satisfied
88+
when the entry's matcher fires for every required tool. `Write|Edit|Bash`
89+
covers a `Write|Edit` requirement; `Write` alone does not. Equality would
90+
false-fail on a harmless reorder; substring matching would pass a matcher
91+
that silently dropped `Edit`. Alternative-set containment is the predicate
92+
that means what the requirement means.
93+
94+
3. **A malformed `settings.json` is a FINDING (exit 1), not INCONCLUSIVE.**
95+
Claude Code loads no hooks at all from a settings file it cannot parse, so
96+
unparseable is maximally gate-dead. Exit 3 is reserved for failures of the
97+
gate's own environment (git absent / not a repo) — the `doc-drift.py` REQ-9
98+
and `scripts/audit.sh` precedent: a gate that fails open is a silent pass
99+
(R-HONEST-3).
100+
101+
4. **Wired-but-absent is its own defect class.** Every hook command is
102+
`if [ -f "$HOOK" ]; then … else exit 0; fi`-guarded, so a wiring naming a
103+
script that does not exist degrades to a silent no-op — indistinguishable
104+
from success at runtime. `MISSING-SCRIPT` names it separately from
105+
`MISSING-WIRING` because the fix is different (restore the file, not the
106+
JSON).
107+
108+
5. **Not part of `make audit`.** Hook wiring is a development-discipline
109+
invariant, not a link in the proof-trust chain. This mirrors doc-drift's
110+
decision 5 exactly, and `scripts/audit.sh` is left byte-identical.
111+
112+
6. **The control-plane routes are DECLARATIVE for spec-discipline, ENFORCED for
113+
doc-drift.** `is_gated_path` in `tooling/spec-discipline.py` requires a
114+
`.rs` extension, a `thermite-`/`forge` crate dir, and a `src/` component, so
115+
it structurally cannot gate `.claude/settings.json` or a `.md` agent def —
116+
the same honest limitation `doc-drift-tripwire.md` REQ-11 records for
117+
`tooling/*.py` (its OQ-5). The routes added here therefore do not make
118+
spec-discipline block control-plane edits; they make the control plane
119+
**content-pinned under `doc-drift.py`**, which IS CI-enforced. Changing
120+
`settings.json` or an agent def without re-pinning this doc fires the
121+
tripwire.
122+
123+
## Requirements
124+
125+
- **REQ-1 (settings.json is the subject).** The gate loads the tracked
126+
`.claude/settings.json` from the repo root. Absent or unparseable is a
127+
finding (`UNPARSEABLE`, exit 1), never a traceback and never exit 3.
128+
129+
- **REQ-2 (required-wiring predicate).** For each entry in `REQUIRED_HOOKS`
130+
— a `(event, tools, script, claim)` tuple — the gate asserts some hook entry
131+
under `event` has a matcher COVERING every tool in `tools` (per decision 2)
132+
and names `script` in one of its commands. A failure emits `MISSING-WIRING`,
133+
the doc line that would go false, and the JSON entry to restore.
134+
The three required wirings are exactly the three `5581b65f` removed:
135+
`PostToolUse`/`Read``spec-discipline.py`;
136+
`PreToolUse`/`Write|Edit``spec-discipline.py`;
137+
`PreToolUse`/`Write|Edit``anti-pattern-gate.py`.
138+
139+
- **REQ-3 (wired implies present).** Each required `script` must exist at its
140+
repo-relative path. A wiring whose script is absent emits `MISSING-SCRIPT`
141+
(decision 4).
142+
143+
- **REQ-4 (deterministic report).** Findings print in `REQUIRED_HOOKS` order,
144+
one block per requirement, with the literal tokens `WIRED` /
145+
`MISSING-WIRING` / `MISSING-SCRIPT` / `UNPARSEABLE` (R-CODE-5). Two runs over
146+
an unchanged tree produce byte-identical stdout.
147+
148+
- **REQ-5 (exit contract).** `0` = every required hook wired and present;
149+
`1` = at least one finding; `3` = the gate could not determine the answer
150+
(git absent / not a repo). Mirrors `doc-drift.py` REQ-9.
151+
152+
- **REQ-6 (control plane routed).** `tooling/spec-routes.toml` carries routes
153+
for `.claude/settings.json`, `.claude/agents/*.md`, and
154+
`tooling/control-plane-check.py`, all governed by this doc, so `doc-drift.py`
155+
content-pins them (decision 6).
156+
157+
- **REQ-7 (CI enforcement).** The gate runs in the `checks` job of
158+
`.github/workflows/ci.yml` and via `make control-plane`, so the assertion is
159+
a red build and not a thing someone remembers to run.
160+
161+
## Acceptance criteria
162+
163+
- **AC-1**: with the three entries removed from `.claude/settings.json` (the
164+
verbatim post-`5581b65f` file), `python3 tooling/control-plane-check.py`
165+
exits 1 and names all three missing wirings and both script paths.
166+
- **AC-2**: with the entries restored, the gate exits 0 and prints one `WIRED`
167+
line per requirement.
168+
- **AC-3**: a wiring whose script is absent from disk exits 1 with
169+
`MISSING-SCRIPT`, distinct from `MISSING-WIRING`.
170+
- **AC-4**: an unparseable `settings.json` exits 1 with `UNPARSEABLE` and no
171+
`Traceback` on stderr.
172+
- **AC-5**: invoked with no `--root` outside a git repository, the gate exits 3
173+
and never 0.
174+
- **AC-6**: a matcher of `Write|Edit|Bash` satisfies a `Write|Edit`
175+
requirement; a matcher of `Write` alone does not.
176+
- **AC-7**: `scripts/audit.sh` is byte-identical to its pre-component state.
177+
178+
## Verification
179+
180+
`tooling/tests/test_control_plane.py` — nine hand-authored oracle fixtures
181+
(O-1..O-9), the same convention as `test_doc_drift.py`: build a throwaway
182+
control plane in a tmpdir, run the gate by subprocess with `--root`, assert
183+
against expected values the spec fixes, never against the tool's own output
184+
(R-CHAR-3).
185+
186+
O-2 is load-bearing: its fixture is the **verbatim** de-wired `settings.json`
187+
that `5581b65f` left on `main`, so if the gate ever stops catching the exact
188+
regression it was built for, the suite goes red.
189+
190+
Run: `make control-plane-test`, or `python3 -m unittest discover -s tooling/tests`.
191+
192+
## REQ status
193+
194+
| REQ | Status | Evidence |
195+
| --- | --- | --- |
196+
| REQ-1 (settings.json is the subject) | SHIPPED | `SETTINGS_RELPATH = ".claude/settings.json"` + `def evaluate` in `tooling/control-plane-check.py`; the absent/unparseable branches return `(EXIT_FAIL, [UNPARSEABLE …])`. Non-test consumer: the `control-plane gate (hook wiring)` step in `.github/workflows/ci.yml` and `make control-plane`. Verification: O-4/O-5 in `tooling/tests/test_control_plane.py`. |
197+
| REQ-2 (required-wiring predicate) | SHIPPED | `REQUIRED_HOOKS` + `def _matcher_covers` + `def _entry_commands` + `def _restore_snippet` in `tooling/control-plane-check.py`. Non-test consumer: as REQ-1. Verification: O-1 (all wired → exit 0), O-2 (the verbatim `5581b65f` fixture → exit 1, three findings), O-6/O-7 (matcher coverage) in `tooling/tests/test_control_plane.py`. |
198+
| REQ-3 (wired implies present) | SHIPPED | the `if not (root / script).is_file():` branch emitting `MISSING_SCRIPT` in `def evaluate`. Non-test consumer: as REQ-1. Verification: O-3. |
199+
| REQ-4 (deterministic report) | SHIPPED | `def evaluate` iterates `REQUIRED_HOOKS` in declaration order; no set/dict iteration reaches the output. Non-test consumer: as REQ-1. Verification: O-8 (two runs byte-identical). |
200+
| REQ-5 (exit contract) | SHIPPED | `EXIT_OK`/`EXIT_FAIL`/`EXIT_INCONCLUSIVE` + `class EnvironmentError3` + the `except EnvironmentError3` arm in `def main`. Non-test consumer: CI reads the exit status. Verification: O-9 (non-git cwd → exit 3, never 0). |
201+
| REQ-6 (control plane routed) | SHIPPED | the `# tooling — the control plane gating itself` block in `tooling/spec-routes.toml`: three `[[route]]` entries (`.claude/settings.json`, `.claude/agents/*.md`, `tooling/control-plane-check.py`) all `design = ".design/tooling/control-plane.md"`. Non-test consumer: `def load_doc_files in tooling/doc-drift.py` inverts the table and content-pins this doc's governed set. Verification: `python3 tooling/doc-drift.py` reports this doc CURRENT at the pinned aggregate. |
202+
| REQ-7 (CI enforcement) | SHIPPED | `.github/workflows/ci.yml` `checks` job step `control-plane gate (hook wiring)``python3 tooling/control-plane-check.py`; `Makefile` targets `control-plane` / `control-plane-test`. Verification: the step is sequenced with the sibling `doc-drift tripwire` step in the same job. |
203+
204+
## Open questions
205+
206+
- **OQ-1 (agent-def self-contradiction).** `.claude/agents/acto-critic.md` and
207+
`acto-doc-author.md` declare `model: fable` in frontmatter while their bodies
208+
read "Opus — always. … Never substitute." Routing them (REQ-6) makes any
209+
future edit re-pin this doc, but the gate does not yet assert
210+
frontmatter↔body consistency, and resolving which side is correct is a
211+
harness-behavior decision for the maintainer, not a mechanical fix. A
212+
follow-up could add a `REQUIRED_AGENTS` block asserting declared `model:` and
213+
`tools:` against a pinned expectation.
214+
215+
- **OQ-2 (anti-pattern rules have no CI backstop).** There is no `clippy.toml`;
216+
`ci.yml:95` is stock `cargo clippy --workspace --all-targets -- -D warnings`
217+
with no `disallowed_*` / `unwrap_used` lints. This component restores and pins
218+
the hook, but the hook is still the only enforcement of those rules — a
219+
clone that never runs Claude Code gets none of them. A `clippy.toml`
220+
encoding the same rules would make the property hold in CI independent of the
221+
harness.
222+
223+
- **OQ-3 (read-only roles are conventional, not capability-enforced).**
224+
`acto-critic` / `acto-doc-author` withhold `Edit` but retain `Write` and
225+
unrestricted `Bash`, so "the critic cannot modify production code" is patched
226+
with prose (`acto-critic.md:37`) on the `Write`/`Bash` vectors. The critic
227+
genuinely needs `Write` to author failing tests, so the fix is not "drop
228+
`Write`" — it is a read-only mount or a path-scoped write allowlist. Out of
229+
scope here; recorded so the gap is not lost.

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,15 @@ jobs:
5151
- name: cargo fmt
5252
run: cargo fmt --all --check
5353

54-
- name: doc-drift gate tests (tooling/tests)
54+
- name: tooling gate tests (tooling/tests)
5555
run: python3 -m unittest discover -s tooling/tests
5656
- name: doc-drift tripwire (design-doc freshness)
5757
run: python3 tooling/doc-drift.py
58+
# The gate that guards the gates (crosslink #93): asserts the two
59+
# agent-facing hooks are still WIRED in the tracked settings.json, which
60+
# `crosslink init` regenerates and 5581b65f silently clobbered.
61+
- name: control-plane gate (hook wiring)
62+
run: python3 tooling/control-plane-check.py
5863
- name: req-status lint (source-comment status consistency)
5964
run: python3 tooling/req-status.py
6065
- name: req-registry gate (canonical status inventory)

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
# ACToR harness read-state (per-worktree)
3838
.crosslink/.spec-reads.json
3939
tooling/__pycache__/
40+
__pycache__/
4041

4142
# Rust build artifacts
4243
target/

Makefile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Thermite — convenience targets. The build/test system is Cargo; these are
22
# thin entry points. `make audit` is the headline: a FULL TRUST-CHAIN
33
# re-derivation a skeptic runs on their own machine (see scripts/audit.sh).
4-
.PHONY: audit audit-fast check test fmt clippy gauntlet doc-drift doc-drift-ci doc-drift-worktree doc-drift-test req-status req-status-test req-registry req-registry-test
4+
.PHONY: audit audit-fast check test fmt clippy gauntlet doc-drift doc-drift-ci doc-drift-worktree doc-drift-test req-status req-status-test req-registry req-registry-test control-plane control-plane-test
55

66
DOC_DRIFT_CI_BASE ?= origin/main
77
DOC_DRIFT_CI_HEAD ?= HEAD
@@ -108,3 +108,15 @@ req-registry:
108108

109109
req-registry-test:
110110
@python3 -m unittest discover -s tooling/tests -v
111+
112+
# The gate that guards the gates (crosslink #93). doc-drift pins the CONTENT of
113+
# what the routes govern; this asserts the two agent-facing hooks are actually
114+
# WIRED in the tracked .claude/settings.json — the file `crosslink init`
115+
# regenerates, and which 5581b65f silently de-wired for the whole Stage-3 arc.
116+
# Not part of `make audit`: hook wiring is a development-discipline invariant,
117+
# not a link in the proof-trust chain (the doc-drift decision-5 precedent).
118+
control-plane:
119+
@python3 tooling/control-plane-check.py
120+
121+
control-plane-test:
122+
@python3 -m unittest discover -s tooling/tests -v

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ agent needs to work the repo **ships in the repo**:
170170
no stubs/TODOs), and the **route table** (`spec-routes.toml`: which file maps to
171171
which design doc). `.claude/settings.json` (also tracked) wires these into Claude
172172
Code's `PreToolUse`/`PostToolUse` events, so they enforce automatically — no setup.
173+
That last sentence is itself CI-checked: `crosslink init` regenerates
174+
`settings.json` from a generic template and once silently dropped both gates for
175+
a whole stage (#93), so the **control-plane gate** (`control-plane-check.py`,
176+
`make control-plane`) asserts every hook the docs claim is live is actually wired,
177+
and the route table pins the control plane under `doc-drift.py`.
173178
- **`.claude/hooks/`** (gitignored — environment infra, *not* project source) — the
174179
crosslink issue-tracking + session machinery (`work-check.py` = an active issue is
175180
required before any edit, plus session/heartbeat/prompt hooks). These are regenerated

0 commit comments

Comments
 (0)