|
| 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. |
0 commit comments