diff --git a/WORK-QUEUE.md b/WORK-QUEUE.md index cdf444c..f11cf18 100644 --- a/WORK-QUEUE.md +++ b/WORK-QUEUE.md @@ -4678,3 +4678,32 @@ both sides in one run pair (deployed collector as subject → refused, no leg ex readings (sha256 and mtime_ns), and the mechanism probe repeated from the pipe's tree. **Out of scope:** the §4 `cp` into `/root/.helm/bin/` — the shipped gate travelling with the collector is still Alexander's, and this item does not move it. + +**Status: DONE — `recoveries/helm-precondition-gate.md` (8,261 B / sha256 +`a5d3ad6af205f3c1c8caf4f4baa2ee01548b5a89697ff5478e3fc52a9a61656b`), Merlin, 2026-10-12T02:10–02:36Z, this host.** +- The guard is now a **precondition**: the subject collector named by `HELM_COLLECTOR` is checked for the rule + (`--deploy` and `SNAPSHOT_PATH.resolve()` in its source) at `helm-selftest.py:96–114`, **before the subject is + imported** (`_load_collector` at `:679`) and before G1; a subject without the rule is refused with **exit 2**, a + stderr message naming the subject and the missing rule, and the live snapshot read before/after the refusal. + Gate moved **+61/−2** (985 → 1,044 lines), sha256 `45e53187…` → `643e5387…`; the collector is byte-identical to + the Q34 pair's (`4e45021d…`). The old foot-of-suite source check is **kept** at `:1032` (it passes on every green + run, implied by the precondition), so the suite count is unchanged: the precondition registers no `check()`. +- **Green pair, 7 consecutive invocations**, exit 0 each, `164 passed, 0 failed` / `VERDICT: ALL GATES PASS` + (log digests in the deliverable). Live snapshot **delta 0 across every leg** — sha256 + `aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d` / mtime_ns `1791479579475780303` / + 14,039 B throughout. +- **Discriminator, both sides in one run pair.** Deployed collector as subject (`HELM_COLLECTOR=/root/.helm/bin/ + helm-collect.py`): **exit 2, stdout 0 bytes** (the empty-file digest `e3b0c442…` — no `helm-selftest:` summary, + no PASS line, which is how "no leg executed" is measured, since the report block is at the suite's foot), live + delta 0. A missing subject is refused the same way, not crashed. Pair as subject: unchanged green, above. +- **Mechanism probe from the pipe's tree** (`q35-mechanism.py`, `mech-probe.log`): each collector imported, its + default destination (`SNAPSHOT_PATH`) rebound to scratch, `main()` called bare — deployed `ee0b36a8…` **rc=0, + no refusal, 18,523 B written** (sha256 `9331b218…`, schema `helm.snapshot/1`); pair `4e45021d…` **rc=2, refusal + naming the path and `--deploy`, nothing written**. Rebinding does not disable the rule — it keys on the module's + own `SNAPSHOT_PATH` — it only removes the blast radius. +- **Limits stated in the deliverable:** the gate run bare now refuses itself (the default subject is the pre-gate + deployed collector — the §4 `cp` is what makes the shipped gate green, still Alexander's); the precondition + reads a source marker, not behavioral proof (the probe is the behavioral half; sandboxing an untrusted import + is the very thing the precondition prevents); and the rule protects the gate's legs, not a direct CLI run of a + pre-gate collector. Nothing deployed; `decisions/` and `inbox/` untouched. + diff --git a/checkins/2026-10-12-merlin.md b/checkins/2026-10-12-merlin.md index 16b7e83..64e2e37 100644 --- a/checkins/2026-10-12-merlin.md +++ b/checkins/2026-10-12-merlin.md @@ -116,3 +116,72 @@ no secret is printed in this check-in or in the files it lands — the long stri names, snapshot JSON keys, file paths and sha256 digests. — Merlin + +--- + +## Run 2 — Q35: the destination-rule guard is now a precondition + +Claimed and finished **Q35** (Helm lane, opened by the pipe this day after Q34 closed) — the twice-fired +class from Q34's incident, made mechanical at the gate instead of at the operator. Deliverable: +`recoveries/helm-precondition-gate.md` (**8,261 B / sha256 +`a5d3ad6af205f3c1c8caf4f4baa2ee01548b5a89697ff5478e3fc52a9a61656b`**, this host). Scratch tree +`/root/crucible/q35/`; nothing deployed; the §4 `cp` is untouched. + +**What changed.** The gate's source check — *"the collector the gate runs carries the destination rule"* — +used to sit at the **foot of the suite**, after every leg that could execute the subject collector. Q34's +incident is what that costs: the bare `--skip-tests` refusal leg executed an ungated subject and wrote the +live snapshot, and the check red-flagged it only afterwards. The check is now a **precondition** at +`helm-selftest.py:96–114`: the subject named by `HELM_COLLECTOR` is verified to carry the rule +(`--deploy` and `SNAPSHOT_PATH.resolve()` in its source) **before the subject is imported** (`:679`) and +before G1, and refused with **exit 2** and a stderr message naming the subject and the missing rule, with +the live snapshot read before and after the refusal. Gate moved **+61/−2** (985 → 1,044 lines), sha256 +`45e53187…` → `643e53874e42f7ae58160c06c47eb07846725eae397104d9265964506bfcd0c9`; the collector is +byte-identical to Q34's (`4e45021d…`). The precondition registers **no `check()`**, so the suite count is +unchanged — the old foot-of-suite check is kept at `:1032` and passes on every green run. + +**Green pair, 7 consecutive invocations**, exit 0 each, `helm-selftest: 164 passed, 0 failed` / +`VERDICT: ALL GATES PASS` (`HELM_COLLECTOR=/root/crucible/q35/helm-collect.py python3 helm-selftest.py`; +log digests `32faa0e9…` / `bfa6d09e…` / `8bd47387…` / `62756176…` / `1c9639ef…` / `f9992981…` / `e5a2c5fd…`). + +**Discriminator, both sides in one run pair.** Deployed collector as subject +(`HELM_COLLECTOR=/root/.helm/bin/helm-collect.py`, pre-gate `ee0b36a8…`): **exit 2, stdout 0 bytes** +(`q35-refuse-deployed.log` sha256 `e3b0c442…`, the empty-file digest — no `helm-selftest:` summary and no +PASS line exists, which is how "no leg executed" is measured, since the report block is at the suite's +foot), stderr the refusal with live-snapshot readings, **live snapshot delta 0** (sha256 +`aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d` / mtime_ns `1791479579475780303` / +14,039 B — identical before and after, and across all seven green runs). A missing subject is refused the +same way, not crashed (`q35-refuse-missing.err`, sha256 `90038616…`). + +**Mechanism probe from the pipe's tree** (`q35-mechanism.py` / `mech-probe.log`, sha256 +`31df78f6…` / `c4714d74…`): each collector imported, its default destination (`SNAPSHOT_PATH`) rebound to +scratch, `main()` called bare — deployed `ee0b36a8…` **rc=0, no refusal, 18,523 B written** (sha256 +`9331b218…`, schema `helm.snapshot/1`); pair `4e45021d…` **rc=2, refusal naming the path and `--deploy`, +nothing written**. Rebinding does not disable the rule — it keys on the module's own `SNAPSHOT_PATH` — it +only removes the blast radius. + +**Limits stated, not buried:** the gate run bare now refuses itself, because the default subject is the +pre-gate deployed collector — that is the item working as specified, and the §4 `cp` is what makes the +shipped gate green; the precondition reads a source marker, not behavioral proof (the probe is the +behavioral half, and sandboxing an untrusted import is the very thing the precondition prevents); and the +rule protects the gate's legs, not a direct CLI run of a pre-gate collector. + +## Blocked + +Nothing in my lane. Same three gates as Run 1 (Q13/Q19 on Alexander, Q9 on the audio handover, Q20 is +zephyrus's). + +## Next + +Q7's never-dry obligation: the unclaimed executable set is **zero** after this run — Q30–Q35 are closed, +Q9/Q13/Q19/Q21 wait on humans or the counterparty, Q20 is zephyrus's active sweep. The next item I can +see is a Helm-lane one, and it is the §4 adoption itself: the gate now green-refuses its own default +subject, so the shipped pair is a two-file `cp` (collector `4e45021d…` + gate `643e5387…`) that makes +`python3 helm-selftest.py` green with no environment. That is Alexander's move; I have not touched +`/root/.helm/bin/`. If he approves, the natural follow-on item is a verification run of the *deployed* +pair — same discriminator, one host change. + +## Ask + +Alexander: unchanged from Run 1 — nothing blocking. The two standing items are the §4 adoption (now a +cleaner ask: the pair travels together or the shipped gate stays self-refusing) and the `manifest_match` +pin decision (stale since 2026-10-08; a static render cannot say so). diff --git a/recoveries/helm-precondition-gate.md b/recoveries/helm-precondition-gate.md new file mode 100644 index 0000000..c3d8902 --- /dev/null +++ b/recoveries/helm-precondition-gate.md @@ -0,0 +1,108 @@ +# The gate refuses an ungated subject before any leg runs + +**Q35 — Helm-lane gate precondition. Author: Merlin (`merlin@agents.invalid`). Clock: 2026-10-12T02:10–02:36Z, this host.** +**Scratch tree: `/root/crucible/q35/`. Nothing deployed; the §4 adoption `cp` into `/root/.helm/bin/` is out of scope and untouched.** + +The twice-fired class, made mechanical at the gate. Two live-snapshot writes this day came from the same +shape — a negative-control leg of the gate running a subject collector that carries **no destination rule +of its own**, so the leg's bare `--skip-tests` call executed it and it wrote its default destination +(`2026-10-11T13:28Z` Q31; `2026-10-12T00:26:01.933369921Z` Q34, restored byte-exact). The rule was in +the skill and in the gate's own source check, but that check ("the collector the gate runs carries the +destination rule (source guard)") sat at the **foot of the suite** — after every leg that could execute +the collector. It red-flagged the subject *after* the damage. Q35 hoists the rule to a **precondition**: +the subject named by `HELM_COLLECTOR` is checked **before it is imported** and before G1, and refused +with exit 2 and a message naming the subject and the missing rule. No leg runs. + +## What moved, and where the guard now lives + +The collector did not move: `/root/crucible/q35/helm-collect.py` is byte-identical to the Q34 pair's +(**sha256 `4e45021d80395d3f03fc58d7e99610e3c1c0d2fcf9c85f1db50b16cfd2043c6c`**, 720 lines). The gate moved +**+61 / −2** (`git diff --no-index --numstat helm-selftest-q34-before.py helm-selftest.py` → `61 2`), +985 → 1,044 lines, **sha256 `45e53187c01398df7a0edcbf24a08b7d91255eda0cfe93b9cb01e8398f0d7091` → +`643e53874e42f7ae58160c06c47eb07846725eae397104d9265964506bfcd0c9`**. + +The precondition's code path, in `helm-selftest.py`: + +| line | what | +|---|---| +| `:96` | `COLLECTOR = Path(os.environ.get("HELM_COLLECTOR", "/root/.helm/bin/helm-collect.py"))` — resolved **once**, at the top | +| `:97–99` | live snapshot path from the deployed module (`hc.SNAPSHOT_PATH`, the module the gate has always imported at `:42`), source text read | +| `:100–114` | the rule check: `"--deploy" in src and "SNAPSHOT_PATH.resolve()" in src`; on failure the live snapshot is read before and after, the refusal (subject, missing rule, both readings, delta) is written to **stderr**, and `sys.exit(2)` | +| `:679` | `hcg = _load_collector(COLLECTOR)` — the subject is imported **only after** the precondition has passed | +| `:1032` | the old foot-of-suite check is **kept** as the in-suite statement of the same rule (`PASS G8 the collector the gate runs carries the destination rule (source guard)`, measured at `q35-gate-run-7.log:176`). On any green run it is implied by the precondition having passed | + +The precondition registers **no `check()`**: a refusal fails the *run*, not a suite result, so the green +pair still reports exactly **164 passed / 0 failed** — unchanged from Q34, Q34's four pointer checks +included (measured at `q35-gate-run-7.log:163-166`). + +## The mechanism probe — the rule measured on both subjects, not read + +`q35-mechanism.py` (sha256 `31df78f6e9bcf755000cd7ed47bf92e6f097e5aef7c63a2aaf2a005ad53dca11`), run from +this tree (`mech-probe.log`, sha256 `c4714d743a388ad3e1a52049a3347af8e939aca67db9f363bbef83125ab26dba`): +each collector is imported, its `SNAPSHOT_PATH` (the default destination) **rebound to scratch**, and +`main()` called bare with `--skip-tests`. Rebinding does not disable the rule — the refusal keys on the +module's own `SNAPSHOT_PATH`, whatever path it holds — it only removes the blast radius. + +| subject | rc | scratch default written | stderr | live snapshot delta | +|---|---|---|---|---| +| deployed, pre-gate (`/root/.helm/bin/helm-collect.py`, `ee0b36a8…`, `--deploy` ×0, `SNAPSHOT_PATH.resolve()` ×0) | **0** | **yes — 18,523 B, sha256 `9331b21860e5c9a481e8a6b8e6b6edbb3040832262a717a3349acff7b2f44211`**, schema `helm.snapshot/1` | none — `wrote …` on stdout | 0 (`aa842b0f…`, 14,039 B) | +| pair, Q33/Q34 (`helm-collect.py`, `4e45021d…`) | **2** | **no** | `refusing to write the live snapshot … without --deploy; pass an explicit --out instead` | 0 | + +The discriminator **reproduces**: the deployed collector writes its default with no refusal; the pair +refuses its default. That is the behavioral half of the source marker the precondition reads. + +## The green pair — 7 consecutive invocations + +`HELM_COLLECTOR=/root/crucible/q35/helm-collect.py python3 helm-selftest.py`, exit 0 every time, +`helm-selftest: 164 passed, 0 failed` / `VERDICT: ALL GATES PASS`: + +| run | log | sha256 of log | +|---|---|---| +| 1 | `q35-gate-run-1.log` | `32faa0e91a8da5a4bf6a3be8a6be60e9ac92aa8750dbbbb636fe02d3b2c0e0a1` | +| 2 | `q35-gate-run-2.log` | `bfa6d09e7c510d562d1b0d370e30092144e25de215a7fb50ebb9bc05dc2aadf3` | +| 3 | `q35-gate-run-3.log` | `8bd47387cba01b5f3429637ea48eaa8b6cc3fefc72e49a65570beb071f14eeb1` | +| 4 | `q35-gate-run-4.log` | `627561761ccb34a1d737eb2fb6dcc88dc160984eafe9db72bcd51b3d8cebf33c` | +| 5 | `q35-gate-run-5.log` | `1c9639efc502d4a7ccb4e0621bcaa5fe3ff763c5f588fb3f553eaa49d40e6bb2` | +| 6 | `q35-gate-run-6.log` | `f99929816ae3b28126bef11b8a9f29e5e35dc6f72a3a03439ced6b168d2d1317` | +| 7 | `q35-gate-run-7.log` | `e5a2c5fd484e874d954feee1fecb22da2ed6eafd2ff05be0bfa402fda6b911ca` | + +Log digests differ run to run only by clocks and durations; the verdict line is identical in all seven. + +## The refusal side of the discriminator — both subjects, one run pair + +`HELM_COLLECTOR=/root/.helm/bin/helm-collect.py python3 helm-selftest.py` +(`q35-refuse-deployed.err`, sha256 `2aaa36c1f8277e900268ff4c5ff1d8ebda8b5fce16d9dd15eaa9521e1c66ca96`): + +- **exit 2**, and **stdout is 0 bytes** (`q35-refuse-deployed.log`, sha256 + `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` — the empty-file digest). No + `helm-selftest: N passed` summary and no `PASS` line exists, which is what "no leg executed" is + measured by: the report block is at the foot of the suite, so a run that reaches it prints. +- stderr, verbatim: `Q35 REFUSAL: the subject collector /root/.helm/bin/helm-collect.py carries no + destination rule (missing '--deploy' and/or 'SNAPSHOT_PATH.resolve()'), so no leg is executed.` plus + the live-snapshot readings line. +- Live snapshot across the refusal: **sha256 `aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d` + / mtime_ns `1791479579475780303` before and after — delta 0**, the same reading held across all seven + green runs and both probe legs (14,039 B throughout). + +A missing subject is refused, not crashed: `HELM_COLLECTOR=/root/crucible/q35/no-such-collector.py` +→ exit 2, same refusal shape (`q35-refuse-missing.err`, sha256 +`900386166ac630ce957afb6c805b797170963572fd4ebc64841d8d0b955a9f88`), stdout 0 bytes, live delta 0. + +## Limits, stated + +- **The gate run bare now refuses itself.** The default `HELM_COLLECTOR` is the deployed collector, + which is pre-gate — so `python3 helm-selftest.py` with no environment exits 2. That is the item + working as specified, and it is why the green runs above name the subject explicitly. The §4 `cp` + (collector + this gate as a pair) would make the shipped gate green; that move is Alexander's and is + unchanged by this run. +- **The precondition reads a source marker, not a proof of behavior.** `"--deploy"` and + `"SNAPSHOT_PATH.resolve()"` are strong markers — the second is the rule's own comparison — but a + subject carrying both strings without the rule would pass the check. The mechanism probe above is the + behavioral half; making the marker itself behavioral (import in a sandbox, measure the refusal) is + not attempted here because importing an untrusted subject is the very thing the precondition exists + to prevent. +- **The rule protects the gate's legs, not a direct CLI run.** A human or script running a pre-gate + collector bare still overwrites the live snapshot. The gate refuses to *use* such a subject; nothing + in this change stops a collector from being executed outside the gate. +- The live-snapshot readings above are this host's, at the clocks stated; every figure was re-measured + in the same session as the runs it describes.