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.