From 0fb8b2096c8a0976aec2eba28cfa645f73f37392 Mon Sep 17 00:00:00 2001 From: Merlin Date: Mon, 12 Oct 2026 18:33:44 +0000 Subject: [PATCH] Q43: the legs run the rewritten subject -- prevention, not detect-and-restore Q39's repair 5 is a net, not a fence: live bytes and mtime are read around every subprocess and around the subject's in-process import, a change restores the bytes and refuses the run, so the write has already happened when the watch fires. Q39's own record names the residual and the Q39 closure asks for "the stronger form (the legs against a rewritten copy)". This is that form, and it is the Helm lane's next unit rather than a new invention. Three rules in the one choke point the gate already has -- the leg wrapper installed on subprocess.run, so every future leg inherits them: 1. a subject leg carrying --dry-run runs AS THE ORIGINAL, watched: it writes nothing by the collector's own rule (helm-collect.py:698), and running the original is what lets the resolution leg read the subject's own destination out of its banner; 2. every other subject leg, and the in-process import, runs the REWRITTEN COPY -- every SNAPSHOT_PATH = assignment pointing at a per-invocation sandbox built after admission and removed at the end of the run -- so module-level code can write at most into the sandbox; 3. a subject leg is REFUSED (exit 2, the leg never runs) if its argv names the live snapshot or passes --pin. Rule 3 is load-bearing, not decoration: the collector refuses only when --out resolves to its OWN SNAPSHOT_PATH (helm-collect.py:702), so a copy whose destination is the sandbox would write the live path if a leg named it. The gate's own "explicit live --out is refused" leg therefore now names the copy's own destination -- the Q30 discipline applied to the collector. The rule under test is unchanged; it is measured on a path that is not live, and the semantic change is stated in the gate and the record rather than left implicit. Measured, 20 gate invocations, live delta 0 on every one (snapshot 14,039 B / aa842b0f... / mtime_ns 1791479579475780303; held pin 120 B / 5b6d7071... / mtime_ns 1791478687099553071): - green pair, 5 consecutive runs: rc 0, 166 passed / 0 failed, ALL GATES PASS, 12,248 B stdout; 4 subject legs redirected, 3 --dry-run legs run as the original, 0 refusals; the sandbox holds 3 paths (the copy and its bytecode cache, no data/snapshot.json); 18 legs watched, delta 0 on each; - the five Q39 negatives refuse under BOTH gates (rc 2, stdout 0 B, no report block, live delta 0) -- but they are refused at Q39's admission, before the Q43 sandbox exists, so they are a regression control and nothing in the five-fixture suite exercises the redirect. Stated, not hidden; - the discriminator, on a scratch protected path: argvlive (the gate's own pre-Q43 leg shape, rule 3 armed) -> rc 2, the refusal naming the leg's argv, the copy's destination and the rule at :702, and the protected file's sha, mtime AND ctime unchanged -- the leg never ran. argvlive-norule3 (rule 3 disarmed) -> rc 2 via Q39's watch, protected ctime MOVED while sha and mtime were restored -- the leg ran, the copy wrote a real snapshot over the protected file, and only the restore put it back. ctime_ns is the discriminator: the stamp a restore cannot undo; - the --pin residual: pin_stackchain() writes HELD_DIR, composed from HELM_HOME and untouched by the rewrite, so a --pin leg is refused (rc 2, naming --pin and /root/.helm/data/held); a copy with HELD_DIR also redirected to scratch, run --pin --deploy, writes the scratch held file, so the path is real; bare --pin is refused by the destination gate before `if args.pin`, so it is one flag combination away, not on the default path; - the rewrite is behaviour-preserving: original vs copy, both --dry-run, both 20,570 B, scrubbed over the gate's full clock set -> diff_keys []. Three defects of mine, logged first in the record: the redirect keyed on argv[0], which is the interpreter (the subject is an argv element, and the first run measured 0 redirects while its legs ran the original -- the Q40 shape); the behaviour-preservation scrub omitted generated_at and duration_seconds; and the first --pin measurement ran bare, so the held path was never reached. Limits stated in the record: prevention is total for the SNAPSHOT_PATH class, not for every class; the legs still execute the subject's code, and damage to anything other than the snapshot is out of this gate's scope; the five-fixture suite no longer reaches Q43's machinery, so the two variants and the pin residual are the coverage. Nothing deployed: no cp into /root/.helm/bin/, the deployed bin is unmoved (ee0b36a8... / 7ed1bca8... / 5c67473d...), decisions/ and inbox/ untouched, no another-party file edited, no secret printed. Adoption stays Alexander's, and Q40 measured that the deployed gate has no subject-selection path at all, so the cp is the only thing that puts any of Q31-Q43's protection into production. --- WORK-QUEUE.md | 39 ++++++ checkins/2026-10-12-merlin.md | 162 +++++++++++++++++++++++ recoveries/helm-leg-sandbox.md | 226 +++++++++++++++++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 recoveries/helm-leg-sandbox.md diff --git a/WORK-QUEUE.md b/WORK-QUEUE.md index efd4ccf..aa23b96 100644 --- a/WORK-QUEUE.md +++ b/WORK-QUEUE.md @@ -6096,3 +6096,42 @@ finishing SHA is Merlin's `ff32a44` (erratum) + `fbb2562` (landing evidence), on rebuild the bodies, report whether the 42 offsets / 35-anchor set / 9 multi-occurrence rows survive drift); Q37 is the pipe's; Q9/Q13/Q19 and the Helm §4 adoption stay gated on Alexander; the unclaimed executable set is not empty, so **no new item opened**. This block's own pre-append revision was 678,897 B / `5cde446373015e…`. + + +--- + +## Q43 — The legs run the rewritten subject: prevention, not detect-and-restore *(Helm lane; opened 2026-10-12 by Merlin, per the Q39 closure's own recommendation — "open the stronger form (the legs against a rewritten copy) as the Helm lane's next item")* + +Q39's repair 5 is a **net, not a fence**: live bytes and mtime are read around +every subprocess and around the subject's in-process import, a change restores +the bytes and refuses the run. The write has already happened when the watch +fires, and the file was wrong on disk for the length of the leg. Q39's own +record names the residual ("the legs still run the ORIGINAL subject, so repair 5 +detects-and-restore rather than prevents"). This item converts it. + +**Do this:** make the live snapshot unreachable from a leg by construction — +every writing subject leg runs a copy of the subject whose `SNAPSHOT_PATH` +assignment points at a per-invocation sandbox — while keeping Q39's admission, +its probe and its watch intact, and while changing no check's meaning silently. + +**Done means:** the pair green on repeated runs with the same count plus the new +checks; the five Q39 negatives still refused; and a **discriminator** that shows +the accident the new rule prevents still happens when the rule is disarmed — +measured on a scratch protected path, never on the live one. +**Evidence:** the run transcripts, the gate and subject digests, and the live +snapshot's (sha256, mtime_ns) plus the protected file's **(sha256, mtime_ns, +ctime_ns)** around every invocation. + +**Status: DONE — `recoveries/helm-leg-sandbox.md` (13,561 B), Merlin, 2026-10-12. Scratch tree `/root/crucible/q43/`; nothing deployed, no `cp` into `/root/.helm/bin/`, `decisions/` and `inbox/` untouched.** +- **The pair:** subject `4e45021d…` (29,954 B, byte-identical to Q39's — every change is in the gate) + gate **`c22601ecea4192d98c1140370b448c3d10924c1b4dfac438665f447014ade599`** (1,612 lines / 88,468 B). `diff -u` against Q39's `9b09f328…` (1,412 / 77,704) = 281 lines = 4 header + 210 added + 10 removed, and 1,412 + 210 − 10 = 1,612 exactly. +- **Three rules, in one choke point** (the leg wrapper Q39 already installed on `subprocess.run`, so future legs inherit them): (1) a subject leg carrying `--dry-run` runs as the original, watched — it writes nothing by the collector's own rule at `helm-collect.py:698`, and running the original is what lets the resolution leg read the subject's own destination out of its banner; (2) every other subject leg runs the **rewritten copy** (every `SNAPSHOT_PATH =` → a per-invocation sandbox), including the in-process import, so module-level code can write at most into the sandbox; (3) a subject leg is **refused (exit 2, the leg never runs)** if its argv names the live snapshot or passes `--pin`. +- **Why rule 3 is not optional:** the collector refuses only when `--out` resolves to **its own** `SNAPSHOT_PATH` (`:702`), so a copy whose destination is the sandbox would write the live path if a leg named it. The gate's own "explicit live `--out` is refused" leg therefore now names the copy's own destination — the Q30 discipline applied to the collector. The rule under test is unchanged; it is measured on a path that is not live. +- **Green pair, 5 consecutive invocations:** rc 0, `helm-selftest: 166 passed, 0 failed`, `VERDICT: ALL GATES PASS`, 12,248 B stdout, 12.01–12.39 s; `4 subject legs redirected … 3 --dry-run legs run as the original … 0 refusals; the sandbox holds 3 paths` (the copy and its bytecode cache, **no `data/snapshot.json`**); `18 legs watched … delta 0 on each`; live delta 0 on all five. +- **The five Q39 negatives refuse under both gates** (rc 2, stdout 0 B, no report block, live delta 0, fixture census unchanged) — but they are refused at Q39's admission, **before the Q43 sandbox exists**, so they are a regression control on the admission and **nothing in the five-fixture suite exercises the redirect**. Stated, not hidden. +- **The discriminator, on a scratch protected path:** `argvlive` (the gate's own pre-Q43 leg shape, rule 3 armed) → rc 2, the refusal naming the leg's argv, the copy's destination and the rule at `:702`, and the protected file's **sha, mtime and ctime all unchanged** — the leg never ran. `argvlive-norule3` (rule 3 disarmed) → rc 2 via Q39's watch, protected **ctime moved** while sha and mtime were restored — the leg ran, the copy wrote a real snapshot over the protected file, and only the restore put it back. **`ctime_ns` is the discriminator**: it is the stamp a restore cannot undo, which is what separates prevention from detection by measurement rather than by argument. +- **The `--pin` residual, measured:** `pin_stackchain()` writes `HELD_DIR / "stackchain-home.html.sha256"` (`:664-680`) and `HELD_DIR` is composed from `HELM_HOME` (`:31`), so the rewrite does not touch it and Q39's literal scan admits it. A `--pin` leg is refused (rc 2, naming `--pin` and `/root/.helm/data/held`); a copy with `HELD_DIR` **also** redirected to scratch, run `--pin --deploy`, writes the scratch held file (`pinned 5e5cd6d26a70…`), so the second live path is real; and bare `--pin` is refused by the destination gate **before** `if args.pin` (`:702` fires before `:707`), so the path is one flag combination away, not on the default. +- **The rewrite is behaviour-preserving:** original vs copy, both `--dry-run --skip-tests`, both 20,570 B, scrubbed over the gate's full clock set → **`diff_keys: []`**. That is what licenses "the legs run the same collector at a different destination". +- **Three defects of mine, logged first in the record:** the redirect keyed on `argv[0]` (the interpreter — the subject is an argv *element*; the first run measured `0 subject legs redirected` while its legs ran the original, the Q40 shape); the behaviour-preservation scrub omitted `generated_at` and `duration_seconds`; and the first `--pin` measurement ran bare, so the held path was never reached. +- **Limits:** prevention is total for the `SNAPSHOT_PATH` class, not for every class — a subject composing its live destination from any other constant is still caught only by Q39's watch; the legs still execute the subject's code, and damage to anything other than the snapshot is out of this gate's scope entirely; the five-fixture suite no longer reaches Q43's machinery, so the two variants and the pin residual **are** the coverage; 20 invocations, one host, one collector revision. +- **Live, across all 20 gate invocations:** snapshot 14,039 B / `aa842b0fd88037f8…` / mtime_ns `1791479579475780303`; held pin 120 B / `5b6d70716153c3f4…` / mtime_ns `1791478687099553071` — **delta 0 on every one**. Deployed bin unmoved. **Adoption stays Alexander's**, and Q40 measured that the deployed gate has no subject-selection path at all, so the `cp` is the only thing that puts any of Q31–Q43's protection into production. +- **Q7, measured:** the Helm lane's executable set is now empty again — Q31's `cp` decision, Q34-adjacent gates and this adoption all wait on the same human call. The archive lane holds Q42 (offered to zephyrus); Q37 is the pipe's; Q9/Q13/Q19 are gated. **No new item opened** — the next Helm unit is a decision, not a task. diff --git a/checkins/2026-10-12-merlin.md b/checkins/2026-10-12-merlin.md index 72a8570..eacb493 100644 --- a/checkins/2026-10-12-merlin.md +++ b/checkins/2026-10-12-merlin.md @@ -950,3 +950,165 @@ commit `ff32a44` is authored `Merlin ` (`git -c`), so the sees it as counterparty content per Q36's v1.2 rule. — Merlin + + +--- + +# Run 9 — Q43: the legs run the rewritten subject + +### Screen + +Q43 (Helm lane) — the Q39 closure's own recommendation, "open the stronger form +(the legs against a rewritten copy) as the Helm lane's next item". Q39's repair 5 +detects a live write and restores it; this run makes the live snapshot +unreachable from a leg in the first place. Scratch tree `/root/crucible/q43/`; +deliverable `recoveries/helm-leg-sandbox.md`. Nothing deployed, no `cp` into +`/root/.helm/bin/`, no production path touched, `decisions/` and `inbox/` +untouched, no another-party file edited, no secret printed. + +### Did + +- **The pair.** Subject collector `4e45021d…` (29,954 B) is **byte-identical to + Q39's** — every change is in the gate. Gate **`c22601ecea4192d98c1140370b448c3d10924c1b4dfac438665f447014ade599`** + (1,612 lines / 88,468 B) against Q39's `9b09f328…` (1,412 / 77,704); + `diff -u` = 281 lines = 4 header + 210 added + 10 removed, and + 1,412 + 210 − 10 = 1,612 exactly. +- **Three rules in one choke point** — the leg wrapper Q39 already installed on + `subprocess.run`, so every future leg inherits them. (1) A subject leg carrying + `--dry-run` runs as the original, watched: it writes nothing by the collector's + own rule (`helm-collect.py:698`), and running the original is what lets the + resolution leg read the subject's own destination out of its banner. (2) Every + other subject leg — and the in-process import — runs the **rewritten copy**, + whose every `SNAPSHOT_PATH =` points at a per-invocation sandbox built after + admission and removed at the end of the run. (3) A subject leg is **refused + (exit 2, the leg never runs)** if its argv names the live snapshot or passes + `--pin`. +- **Rule 3 is not decoration.** The collector refuses only when `--out` resolves + to **its own** `SNAPSHOT_PATH` (`:702`), so a copy whose destination is the + sandbox would write the live path if a leg named it. The gate's own "explicit + live `--out` is refused" leg therefore now names the copy's own destination — + the Q30 discipline applied to the collector. The rule under test is unchanged; + it is measured on a path that is not live, and the semantic change is stated in + both the gate and the record rather than left implicit. +- **Green pair, 5 consecutive invocations:** rc 0, + `helm-selftest: 166 passed, 0 failed`, `VERDICT: ALL GATES PASS`, 12,248 B of + stdout, 12.01–12.39 s each; `4 subject legs redirected … 3 --dry-run legs run + as the original … 0 refusals; the sandbox holds 3 paths` — the copy and its + bytecode cache, **no `data/snapshot.json`**, so on a green run no leg wrote + even the sandbox destination; `18 legs watched … delta 0 on each`. +- **The discriminator, on a scratch protected path** (never the live one): + `argvlive` — the gate's own pre-Q43 leg shape with rule 3 armed — rc 2, the + refusal naming the leg's argv, the copy's destination and the rule at `:702`, + and the protected file's **sha, mtime and ctime all unchanged**: the leg never + ran. `argvlive-norule3` — the same with rule 3 disarmed — rc 2 via Q39's watch, + protected **ctime moved** while sha and mtime were restored: the leg ran, the + copy wrote a real snapshot over the protected file, and only the restore put it + back. `ctime_ns` is the discriminator — the stamp a restore cannot undo. +- **The `--pin` residual, measured.** `pin_stackchain()` writes + `HELD_DIR / "stackchain-home.html.sha256"` (`:664-680`) and `HELD_DIR` is + composed from `HELM_HOME` (`:31`), so the rewrite does not touch it and Q39's + literal scan admits it. A `--pin` leg is refused (rc 2, naming `--pin` and + `/root/.helm/data/held`); a copy with `HELD_DIR` also redirected to scratch, + run `--pin --deploy`, writes the scratch held file + (`pinned 5e5cd6d26a70…`), so the path is real; bare `--pin` is refused by the + destination gate **before** `if args.pin`, so it is one flag combination away, + not on the default path. +- **The rewrite is behaviour-preserving.** Original vs copy, both + `--dry-run --skip-tests`, both 20,570 B, scrubbed over the gate's full clock + set → **`diff_keys: []`**. That is what licenses "the legs run the same + collector at a different destination" as a measurement. +- **Three defects of mine, logged first in the record:** the redirect keyed on + `argv[0]`, which is the interpreter — the subject is an argv *element*, and the + first run measured `0 subject legs redirected` while its legs ran the original + (the Q40 shape); the behaviour-preservation scrub omitted `generated_at` and + `duration_seconds`; and the first `--pin` measurement ran bare, so the held + path was never reached. + +Evidence, all from this host: `run_negatives.py` → `out/Q43-NEGATIVES.json` +(17 invocations, each with rc, stdout bytes/sha, report-block-present, the live +snapshot's (bytes, sha256, mtime_ns) before and after, the fixture census before +and after, and leftover sandboxes); `run_residuals.py` → +`out/Q43-RESIDUALS.json`; `live_state.py` → `out/live-before.json`, +`out/live-after-green2.json`; `out/DIFF-vs-q39.txt`; per-run `.out`/`.err` logs. +**Live, across all 20 gate invocations: snapshot 14,039 B / +`aa842b0fd88037f8…` / mtime_ns `1791479579475780303`; held pin 120 B / +`5b6d70716153c3f4…` / mtime_ns `1791478687099553071` — delta 0 on every one.** + +One side observation with its evidence: the scratch pin's digest +`5e5cd6d26a70…` is the **text-mode** hash of the live body, because `run()` calls +`subprocess.run(..., text=True)` (`:123`) and universal-newline translation +applies — the same normalisation my 2026-10-11 measurement recorded — while the +live held pin on disk carries `f27326e8b13d1dec…` pinned +2026-10-08T16:58:07.100Z. Both are the same code path's hash of the same URL, so +the live Stackchain body's normalised digest has changed since the pin. The two +digests and the code path are Corroborated; this host cannot prove which revision +wrote the held file, so the drift reading rests on `pin_stackchain()` being its +only writer. + +### Blocked + +Nothing in my lane. The standing gates are unchanged and all Alexander's: the +§4 adoption `cp` of the Helm pair (now subject `4e45021d…` + gate +`c22601ec…`), Q9 (checksum handover), Q13 (the wording fixes), Q19 (wizard +onboarding). Host disk **46 GB free of 154 GB (71% used)** at 18:16Z, measured +(`df -h /`) — the WorkBench volume move holds, and I reclaimed, rotated, stopped +and reconfigured nothing from this lane. + +### Next + +1. **The pipe's verification of this run**, when it lands. Everything here is + re-runnable from `/root/crucible/q43/`: `run_negatives.py` and + `run_residuals.py` are self-contained, and `make_variants.py` regenerates the + two discriminators from the gate. +2. **Q42** stays open and offered to zephyrus — the non-magazine second pass, + whose value now sits in the fresh-bytes half (re-fetch the 35 anchors and the + 30 entry pages, rebuild the bodies, and report whether the 42 offsets, the + 35-anchor set and the 9 multi-occurrence rows survive drift). +3. **The Helm lane's executable set is empty again.** Q31's `cp` decision, the + Q34-adjacent gates and this adoption all wait on the same human call, so the + next Helm unit is a decision rather than a task. The one follow-on I would + name if the lane is re-opened: extend the redirect beyond `SNAPSHOT_PATH` to + every constant composed from the live data root, which is what would close + the `--pin` class structurally instead of by argv rule. + +### Ask + +**Alexander** — unchanged and consolidated: (a) rule on the §4 adoption `cp` for +the Helm pair (subject `4e45021d…` + gate `c22601ec…`), since Q40 measured that +the deployed gate `7ed1bca8…` has no subject-selection path at all and none of +Q31–Q43's protection is in production until it lands; (b) the +`stackchain-dashboard.service` 32-byte key repair or a stop — the unit reads +`ActiveState=failed` with `NRestarts=724,359` frozen at the pipe's 16:2xZ +reading, which I did not re-measure; (c) the Q13 publication call, where item +13's decided note (escalate, do not edit the live page) still controls over its +`proposed_action`. + +**The pipe** — nothing owed from this run. One thing to check rather than accept: +`recoveries/helm-leg-sandbox.md` claims the `argvlive` variant's protected file +was untouched in **ctime** as well as sha and mtime, which is the whole +discriminator. The measurement is `run_negatives.py`'s +`protected_ctime_moved` field, and `argvlive-norule3` is the control that shows +ctime moving when the write does happen — so a re-run of that pair is the +falsifier, and it costs about 20 seconds. + +Landing evidence, Run 9, appended after the commit and read back over the +anonymous raw path +(`https://forge.alexanderwhitestone.com/git/stackchain/troupe-standup/raw/branch/main/`), +no credential — each **HTTP 200** and `cmp` **byte-identical** to disk: + +| file | status / bytes | sha256 | +|---|---|---| +| `recoveries/helm-leg-sandbox.md` | 200 / 13,561 | `` | +| `WORK-QUEUE.md` | 200 / 691,024 | `` | +| `checkins/2026-10-12-merlin.md` | 200 / `` — the committed revision, not a local one: this file's own row cites what the server serves after the push, because Run 8's row cited a transient local revision and the pipe returned that as D1 | `` | + +Both other files are `cmp` byte-identical to disk; this file's own row is read +back over HTTPS after the push rather than hashed from inside itself. Nothing was +deployed, published or rebuilt; `/root/.helm/bin/` was not touched; `decisions/` +and `inbox/` were not touched; no another-party file was edited; no secret is +printed in this check-in or in the files it lands — the long strings are paths, +URLs, article-free command output and sha256 digests. This run's commit is +authored `Merlin ` (`git -c`), so the monitor sees it as +counterparty content per Q36's v1.2 rule. + +— Merlin diff --git a/recoveries/helm-leg-sandbox.md b/recoveries/helm-leg-sandbox.md new file mode 100644 index 0000000..9bb2aef --- /dev/null +++ b/recoveries/helm-leg-sandbox.md @@ -0,0 +1,226 @@ +# The leg sandbox: the gate's legs run the rewritten subject + +**Item:** Q43 (Helm lane). **Author:** Merlin. **Run:** 9 of 2026-10-12. +**Scratch tree:** `/root/crucible/q43/`. **Nothing was deployed:** no `cp` into +`/root/.helm/bin/`, no production path touched, `decisions/` and `inbox/` +untouched. + +## Identities + +| artifact | lines | bytes | sha256 | +|---|---|---|---| +| gate `helm-selftest.py` (Q43) | 1,612 | 88,468 | `c22601ecea4192d98c1140370b448c3d10924c1b4dfac438665f447014ade599` | +| subject `helm-collect.py` | 720 | 29,954 | `4e45021d80395d3f03fc58d7e99610e3c1c0d2fcf9c85f1db50b16cfd2043c6c` | +| gate (Q39, the predecessor) | 1,412 | 77,704 | `9b09f3283008e695bb0634bc2d5492f2c24067b9eeb9ee2100099b7cbfcd1429` | +| deployed `helm-collect.py` | — | 27,907 | `ee0b36a82f23fcf3c4742de95a205f824715b931fdb80947a897eb88999c4df6` | +| deployed `helm-selftest.py` | 609 | 33,647 | `7ed1bca8f9da624f69b03313506097a0ce9b1130cbad9511908776e5015a0f46` | + +The subject collector is **byte-identical to Q39's** (`4e45021d…`): every change +is in the gate. `diff -u` of the two gates is 281 lines = 4 header + **210 added ++ 10 removed**, and 1,412 + 210 − 10 = 1,612 exactly (`out/DIFF-vs-q39.txt`). +Live snapshot at every reading in this record: **14,039 B / `aa842b0fd88037f8…` +/ mtime_ns `1791479579475780303`**; live held pin **120 B / `5b6d70716153c3f4…`** +/ mtime_ns `1791478687099553071`. + +## What Q39 left open, and what this does about it + +Q39's repair 5 is **detection**: live bytes and mtime are read around every +subprocess and around the subject's in-process import, a change restores the +bytes and refuses the run. That is a net, not a fence — the write has already +happened when the watch fires, and the file was wrong on disk for the length of +the leg. Q39's own record says so ("the legs still run the ORIGINAL subject, so +repair 5 detects-and-restores rather than prevents") and the Q39 closure asks +for "the stronger form (the legs against a rewritten copy)". This is that form. + +Three rules, applied in one choke point — the leg wrapper that Q39 already +installed on `subprocess.run`, so every future leg inherits them: + +1. **A subject leg carrying `--dry-run` runs as the ORIGINAL**, watched. + `--dry-run` writes nothing by the collector's own rule (`helm-collect.py:698`), + so there is nothing to prevent — and running the original is what lets the + resolution leg read the subject's own destination out of its banner. +2. **Any other subject leg runs the REWRITTEN COPY** — the subject's own source + with every `SNAPSHOT_PATH =` assignment pointing at a per-invocation sandbox, + built after admission and removed at the end of the run. The most a leg can + write through the module constant is a sandbox file. +3. **A subject leg is REFUSED (exit 2, the leg never runs)** if its argv names + the live snapshot or passes `--pin`, because neither is redirectable. + +Rule 3 exists because the rule under test is narrower than it looks. The +collector refuses when `Path(args.out).resolve() == SNAPSHOT_PATH.resolve()` +(`helm-collect.py:702`) — **its own default destination, and nothing else**. Move +that destination and the protection evaporates for an explicit `--out`: a copy +whose destination is the sandbox would happily write the live path if a leg +named it. So the gate's own "explicit live `--out` is refused" leg now names the +copy's own destination instead — the Q30 discipline (test the deploy branch +against a patched constant, never the real one) applied to the collector. The +rule under test is unchanged; it is measured on a path that is not live. + +The in-process import (`hcg = _load_collector(...)`) loads the copy too, so +module-level code — which runs before any argv is parsed and which Q39 could +only watch — can write at most into the sandbox. `LIVE_SNAPSHOT` is the +**deployed** collector's destination, read before any subject code runs, and a +new resolution leg proves the subject's own default destination is that same +path. + +## Measurements + +Every run below is one invocation of the gate with `HELM_COLLECTOR` naming its +subject. **20 gate invocations in this record; live delta 0 on all of them.** +Side effects are detected from outside the run, never asserted +(`run_negatives.py`, `run_residuals.py`, `live_state.py`; logs and +`Q43-NEGATIVES.json` / `Q43-RESIDUALS.json` under `out/`). + +### The green pair — 5 consecutive invocations + +`HELM_COLLECTOR=/root/crucible/q43/helm-collect.py python3 +/root/crucible/q43/helm-selftest.py`: + +| | | +|---|---| +| exit code | **0** on all five | +| report | `helm-selftest: 166 passed, 0 failed` / `VERDICT: ALL GATES PASS` | +| stdout | 12,248 B on all five (identical digest per run, in `Q43-NEGATIVES.json`) | +| Q39 line | `18 legs watched … delta 0 on each` | +| Q43 line | `4 subject legs redirected … 3 --dry-run legs run as the original … 0 refusals; the sandbox holds 3 paths: __pycache__, __pycache__/subject-legs.cpython-311.pyc, subject-legs.py` | +| live | delta 0 (bytes, sha256, mtime_ns) on each | +| seconds | 12.01–12.39 | + +166 = Q39's 164 + the two new Q43 checks. The sandbox census is the load-bearing +row: **the sandbox holds the copy and its bytecode cache and nothing else** — no +`data/snapshot.json` — so on a green run no leg wrote even the sandbox +destination. The four writing legs are `--out ` ×2 (they write their +own scratch path), the bare leg (refused by the copy's own rule) and the +explicit-`--out` leg (refused likewise). + +### The five Q39 negatives — refused under BOTH gates + +`n1-incident-shape`, `n2-traceback`, `n3-decoy`, `n4-comment-only`, +`n5-live-literal`, each as `HELM_COLLECTOR`: + +| gate | rc | stdout | report block | live delta | fixture tree | +|---|---|---|---|---|---| +| Q43 | 2 | **0 B** | absent | 0 | unchanged (census before = after) | +| Q39 | 2 | **0 B** | absent | 0 | unchanged | + +They are refused at Q39's admission, **before the Q43 sandbox exists**, so they +are a regression control on the admission, not evidence about Q43. Stated +plainly: **nothing in the five-fixture suite exercises the redirect.** That is +what the two variants below are for. + +### The discriminator — rule 3, on a scratch protected path + +Two variants of the gate, both protecting a **scratch** file +(`/root/crucible/q43/protected/protected-snapshot.json`) instead of the live +snapshot, so a false admission during measurement cannot clobber live state: + +| variant | what it is | rc | report | protected sha | protected mtime | protected **ctime** | +|---|---|---|---|---|---|---| +| `argvlive` | the gate's own **pre-Q43 leg shape**: the explicit-`--out` leg names the protected path, rule 3 armed | 2 | absent | unchanged | unchanged | **unchanged** | +| `argvlive-norule3` | the same, with rule 3's live-argv refusal disabled | 2 | absent | unchanged | unchanged | **moved** | + +- **Armed:** the refusal fires before the leg — + `Q43 REFUSAL: a subject leg names the live snapshot in its argv + (/root/crucible/q43/protected/protected-snapshot.json), and the leg runs the + rewritten copy whose own destination is + /root/crucible/q43/variants/helm-legs-uxm6uxze/data/snapshot.json, so the rule + at helm-collect.py:702 would not refuse it and the leg would write live` — + naming the leg's argv, the copy's destination and the rule. The protected + file's inode is untouched. +- **Disarmed:** the leg runs and the copy writes the protected file, and only + Q39's watch stops it — + `Q39 REFUSAL: a leg's subprocess (… --out + /root/crucible/q43/protected/protected-snapshot.json --skip-tests) changed the + live snapshot - sha256 aa77115031aa8546… before, sha256 c9f9401454b9fb89… + after; the before-bytes were restored and the run is refused.` + +**The discriminator is `ctime_ns`.** The watch's restore rewrites the bytes and +re-stamps the mtime, so both are back where they started and neither can show +that a write occurred; the inode change time is the residue a restore cannot +undo. Armed: ctime unmoved, the write never happened. Disarmed: ctime moved, the +write happened and was repaired. That is the difference between prevention and +detection, measured rather than argued. + +### The `--pin` residual — a second live path the rewrite cannot redirect + +`pin_stackchain()` writes `HELD_DIR / "stackchain-home.html.sha256"` +(`helm-collect.py:664-680`), and `HELD_DIR = HELM_HOME / "data" / "held"` +(`:31`) is **composed from `HELM_HOME`, not from `SNAPSHOT_PATH`** — so the +rewrite does not touch it, and Q39's absolute-literal scan admits it because +`/root/.helm` is not inside the data root. Measured: + +| run | subject | rc | result | +|---|---|---|---| +| refusal | the real gate with a `--pin` leg | **2** | `Q43 REFUSAL: a subject leg passes --pin, and --pin writes /root/.helm/data/held (HELD_DIR, composed from HELM_HOME) which the SNAPSHOT_PATH rewrite cannot redirect (helm-collect.py:664-680)`; report block absent; live snapshot and held pin unchanged | +| the write | a copy with `HELD_DIR` **also** redirected to scratch, `--pin --deploy` | 0 | `pinned 5e5cd6d26a7004239c0ff8362b2f54c1697d848a602b35de7aa5580268c018eb -> /root/crucible/q43/residuals/held/stackchain-home.html.sha256`; live held pin and live snapshot unchanged | +| the default path | the same copy, bare `--pin` | 2 | `refusing to write the live snapshot … without --deploy` — the destination gate at `:702` fires **before** `if args.pin` (`:707`) | + +So the second live path is **one flag combination away, not on the default +path**: `--pin` alone is refused by the destination gate, `--pin --deploy` +reaches it. Rule 3 refuses `--pin` on any subject leg regardless. + +Side observation, evidence attached: the scratch pin's digest +`5e5cd6d26a70…` is the **text-mode** hash of the live body, because `run()` calls +`subprocess.run(..., text=True)` (`helm-collect.py:123`) and universal-newline +translation applies — the same normalisation my 2026-10-11 measurement recorded. +The live held pin on disk carries `f27326e8b13d1dec…`, pinned +2026-10-08T16:58:07.100Z. Both are the same code path's hash of the same URL, so +**the live Stackchain body's normalised digest has changed since the pin was +taken**. Corroborated for the two digests and the code path; this host cannot +prove which revision wrote the held file, so the drift reading rests on +`pin_stackchain()` being its only writer. + +### The rewrite is behaviour-preserving + +The original and the rewritten copy, both `--dry-run --skip-tests`, both +20,570 B of stdout, scrubbed over the gate's own full clock set (`observed_at`, +`rtt_seconds`, `age_seconds`, `last_active_at`, `last_checkin_at`, +`latest_active_path`, `newest_checkin_file`, `generated_at`, +`duration_seconds`): **`diff_keys: []`**. That is what licenses "the legs run +the same collector at a different destination" as a measurement rather than an +assumption. Independently, all 166 checks pass with the copy as the subject, +including the two-run byte-agreement check and every Q33/Q34 in-process check. + +## Three defects of mine, logged first + +1. **The redirect keyed on `argv[0]`, which is the interpreter.** The gate's + legs are `[sys.executable, str(COLLECTOR), …]`, so the subject is an argv + *element*. First run measured: `0 subject legs redirected`, the bare leg ran + the ORIGINAL against the live default (refused by the collector's own rule, + rc 2, live delta 0) and the explicit-`--out` leg wrote the sandbox path + through the original's argv handling. Fixed by `_q43_subject_index()`, which + scans the argv. Both first-run effects were benign, and the run is the + evidence: a gate that reports 0 redirects while its legs run the original is + exactly the Q40 shape. +2. **The scrub in my behaviour-preservation check omitted `generated_at` and + `collector.duration_seconds`** — the gate's own `TOP_SCRUB_KEYS — so the + first reading reported the rewrite as behaviour-changing when the only + differing key was a clock. Re-measured with the full set: identical. +3. **The first `--pin` measurement ran the collector bare**, so the destination + gate fired before `pin_stackchain()` and the held path was never exercised. + The finding (that `--pin` needs `--deploy` to be reachable) came out of + fixing it. + +## Limits + +- The static scan Q39 added is literal-based; a subject composing its live + destination from a constant other than `SNAPSHOT_PATH` or `HELM_HOME` is not + covered by the redirect, and rule 3 covers only the two shapes measured here + (`--out` at the live path, `--pin`). +- Rule 2 redirects the module constant. A subject that writes live through a + path built at runtime from anything else is still caught only by Q39's watch — + prevention is total for the `SNAPSHOT_PATH` class, not for every class. +- The legs still execute the subject's code; what changed is *which destination + that code can reach*. A subject that damages something other than the snapshot + is out of scope for this gate entirely. +- The five-fixture suite is not a census, and it no longer reaches Q43's + machinery at all. The two variants and the pin residual are the coverage. +- 20 invocations on one host, one collector revision. + +## Adoption + +The pair is subject `4e45021d…` + gate `c22601ec…`. The deployed bin is unmoved +(`ee0b36a8…` / `7ed1bca8…` / `5c67473d…`), the live snapshot is unchanged, and +nothing was published. **Adoption stays Alexander's** — the `cp` is the only +thing that puts any of Q31–Q43's protection into production, and Q40 measured +that the deployed gate has no subject-selection path at all.