From c559d33131d76b6789a05fd5636fc1469d892c31 Mon Sep 17 00:00:00 2001 From: Merlin Date: Tue, 13 Oct 2026 16:08:17 +0000 Subject: [PATCH] helm: the Q45 s9 follow-on closed -- the home-directory channel (collector 4e45021d unchanged; gate 78b290ecf2d93e0c3087e5ede8baef5c10ca52986706d5da69a13a619dbdd9f, 140,365 B / 2,677 lines / 126 registrations, on the Q51 gate e4967da9, diff 6 hunks + 122 - 3 = 2,677 lines; disarmed twin be7b306a one line different) The last runtime-resolution route in Q45 s7-limit-1's family with neither a literal nor an ast-visible env read: HELM_HOME = Path.home() / ".helm" composes the live root because CPython's posixpath.expanduser reads HOME itself. The channel is live in the shipped subject -- the real collector calls Path.home() twice (its himalaya search). Mechanism: an ast inventory of home-database reads (_q52_home_sites, :786) and, inside Q50's one rebuild function (two callers, probe :977 / legs :1162), a redirect of HOME + USERPROFILE to /q52-home -- a redirect, not a refusal -- with a conditional Q52 NOTE and one new check("Q52", :1204). A --q52-witness extra flag (:1261) runs the copy with --deploy --pin --skip-tests --out , the pin shape the suite itself refuses (Q44's rule), because the class's write path is unreachable from the green run. Measured: green 5/5 at 176 passed / 0 failed (= 175 + 1), 14,044 B stdout, live delta {}; the real collector's own .home() read makes the check non-vacuous. Discriminator, five cells under the digest-verified guard: armed x collector-home.py (d62888c4) -- the copy resolves inside the sandbox and the pin lands at q52-home/.helm/data/held/stackchain-home.html.sha256 in the sandbox, live delta 0; armed x collector-expanduser.py (99c49d39) -- same; disarmed x home -- HOME stays /root, the copy resolves HELM_HOME=/root/.helm and the live pin moves to sha256 caff1f504855264a / mtime_ns 1791907100434371499, restored byte-exact; disarmed without the witness flag -- the foot's own check fails (FAIL Q52, home_reads=['.home()'] HOME='/root', 174 passed / 2 failed); direct invocation, ambient env -- the live pin moves (the class's floor). Regressions 11/11 hold; live delta 0 on bytes/sha256/mtime across all 21 invocations (snapshot 14,039 B / aa842b0f, held pin 120 B / 5b6d7071); ctime moved on both objects (1791907371554631194), the guard restores of the two deliberately-live rows -- disclosed. Final tree 0 sandboxes; deployed /root/.helm/bin untouched (7ed1bca8, grep -c HELM_COLLECTOR = 0 -- Q40 stands, so none of Q31-Q52 is in production); disk 46 GiB free of 154. Five defects of mine logged first in the deliverable: the witness's missing local subprocess import; the witness anchored before the leg copy was written; the witness's live census reading the held directory (IsADirectoryError); the NOTE claiming a redirect that had not fired (the disarmed twin's own FAIL contradicted it -- the NOTE is now derived from the actual env); and the first matrix measuring nothing because the real collector pins only under --pin, which the suite refuses. Limits stated as the item's own: the original-subject legs inherit the ambient environment (Q48's residual); pwd.getpwuid().pw_dir bypasses HOME (the inventory names it, the redirect cannot relocate it); the inventory is static (Q46's limit). Files: recoveries/helm-home-channel-guard.md (13,173 B / sha256 3f813ca23202590c675f7ac7587f0d5e7d8b0ff9ee9f4f0711996530a2d6d7db), WORK-QUEUE.md (897,771 B), checkins/2026-10-13-merlin.md (Run 18). Scratch tree /root/crucible/q52/ not committed. No deployment, decisions/ and inbox/ untouched, no another-party file edited, no secret printed. The Helm lane's executable set is empty again; the adoption cp (collector 4e45021d + gate 78b290ec, superseding e4967da9) remains Alexander's. --- WORK-QUEUE.md | 65 +++++++ checkins/2026-10-13-merlin.md | 98 +++++++++++ recoveries/helm-home-channel-guard.md | 233 ++++++++++++++++++++++++++ 3 files changed, 396 insertions(+) create mode 100644 recoveries/helm-home-channel-guard.md diff --git a/WORK-QUEUE.md b/WORK-QUEUE.md index 1233906..6af58e6 100644 --- a/WORK-QUEUE.md +++ b/WORK-QUEUE.md @@ -7586,3 +7586,68 @@ claim of mine is corrected. 2026-10-13 15:37Z (control plane; `date -u` read at **nothing** (no commit exists between `b7601a0` 14:15:22Z and `7d76bf2` 15:14:24Z), which is the v1.3 cost behaving as its note describes. If Alexander would rather the pipe's own queue-only blocks did not re-arm the monitor at all, that is a change to `troupe_pipe_monitor.py` and his call — not one this tick takes on its own. + +--- + +--- + +## Q45 §9 follow-on — the home-directory channel *(Helm lane; Merlin)* + +**The named follow-on, closed.** The item Merlin's Q45 §7/Q45 §9 follow-on line left open and every check-in +since has carried: *"resolve every path the subject declares **at runtime**, rather than rewriting the literals it +declares statically."* Q48 discharged the environment-value and argv halves; **the home-directory channel was the +remainder and is now closed.** Deliverable: `recoveries/helm-home-channel-guard.md` (**13,173 B / sha256 +`3f813ca23202590c675f7ac7587f0d5e7d8b0ff9ee9f4f0711996530a2d6d7db`**, this host, measured after the write below**). Scratch tree `/root/crucible/q52/`; nothing deployed, +no `cp` into `/root/.helm/bin/`, `decisions/` and `inbox/` untouched, no another-party file edited, no secret printed. + +**The class.** `HELM_HOME = Path.home() / ".helm"` — a root composed from the **user database**. CPython's +`posixpath.expanduser` reads `HOME` itself and `Path.home()` calls it, so the composed path is the live root +(`/root/.helm`) while Q45's literal scan sees only `".helm"` (not absolute), Q48's env inventory finds no +`os.environ` access in the subject's own AST, Q49's default redirect keys on that same inventory, and Q48's argv +rule sees no root argument. The channel is live in the shipped subject: the real collector calls `Path.home()` +**twice** (`/root/crucible/q48/helm-collect.py` lines 297–298, its himalaya config search), on the collector +byte-identical since Q33 (`4e45021d80395d3f…`, 720 / 29,954). + +**The pair.** Collector untouched (measured by sha256). Gate **`78b290ecf2d93e0c…`** (**140,365 B / 2,677 lines / +126 registrations**) on the Q51 gate `e4967da9…` (134,427 / 2,558 / 125); +`out/DIFF-vs-q51.txt` = **169 lines = 6 hunks + 122 added − 3 removed**, and 2,558 + 122 − 3 = 2,677 exactly. +Disarmed twin `be7b306a…` (140,390 / 2,677 / 126) — one line different. **Mechanism:** an ast inventory of +home-database reads (`_q52_home_sites`, gate :786) and, inside Q50's one rebuild function (two callers, :977 probe / +:1162 legs), a redirect of `HOME` + `USERPROFILE` to `/q52-home` — a redirect, not a refusal — with a +conditional `Q52 NOTE` on stderr and one new `check("Q52")` (:1204): when the subject reads the home database, the +legs' `HOME` must be the sandbox home; vacuous otherwise. A `--q52-witness` extra flag (:1261) runs the copy with +`--deploy --pin --skip-tests --out ` — the pin shape the suite itself refuses (Q44's rule) — because +the class's write path is structurally unreachable from the green run. + +**Measured, not asserted.** Green pair **5/5**: rc 0, **176 passed / 0 failed** (= 175 + 1), 14,044 B stdout / +1,216 B stderr, live delta `{}`, the real collector's own `.home()` read making the check non-vacuous +(`PASS Q52 …`; `Q52 NOTE: … HOME redirected to /q52-home (/root -> /q52-home)`). +**The discriminator, five cells under the digest-verified guard** (`out/Q52-MATRIX.json`): armed × +`collector-home.py` (`HELM_HOME = Path.home()/".helm"`, `d62888c4…`) — the copy resolves inside the sandbox, the +pin lands at `q52-home/.helm/data/held/stackchain-home.html.sha256` **inside the sandbox**, live delta 0; armed × +`collector-expanduser.py` (`99c49d39…`) — same; **disarmed × home** — `HOME` stays `/root`, the copy resolves +`HELM_HOME=/root/.helm`, the live pin moves to sha256 `caff1f504855264a…` / mtime_ns `1791907100434371499` and is +restored byte-exact by the guard; disarmed without the witness flag — the foot's own check fails +(`FAIL Q52 … home_reads=['.home()'] HOME='/root' expected='/q52-home'`, 174/2); direct invocation with +the ambient environment — the live pin moves (the class's floor). Regressions **11/11**: nine refusals rc 2, +`collector-latin1.py` / `collector-bom.py` green at 14,044 B, live delta `{}` on all eleven. + +**Live state, delta 0 on bytes/sha256/mtime across all 21 invocations** (5 green + 5 matrix + 11 regressions): +snapshot **14,039 B / `aa842b0fd88037f8…` / mtime_ns `1791479579475780303`**; held pin **120 B / +`5b6d70716153c3f4…` / mtime_ns `1791478687099553071`**. **ctime moved on both objects** +(`1791907371554631194`) — the guard restores of the two deliberately-live rows; disclosed, not hidden. Final tree +0 sandboxes; deployed `/root/.helm/bin/` untouched (`7ed1bca8…`, `grep -c HELM_COLLECTOR` = **0** — Q40 stands, so +none of Q31–Q52 is in production); disk 46 GiB free of 154. + +**Limits, stated in the record as the item's first limits:** the original-subject legs still inherit the ambient +environment (Q48's residual); a subject reading `pwd.getpwuid(...).pw_dir` directly bypasses `HOME` and the +redirect cannot see it (the inventory names it); the inventory is static (Q46's limit); `USERPROFILE` is +redirected alongside `HOME` as a no-op on this host, for portability. + +**Consequence beside it (reply 36 §3, restated):** the run-wide Q44 instrument now fails on a *restore trace* +alone — ctime is the only stamp separating `delta {}` from "moved" in the run-wide record. That is the +conservative direction for a detector and is not reopened here. + +**Open after this item, Helm lane:** the §4 adoption `cp` — now collector `4e45021d…` + gate **`78b290ec…`** +(superseding `e4967da9…`) — Q9 (checksum handover), Q13 (wording fixes), Q19 (wizard onboarding), Q42 +(zephyrus's). **The Helm lane's executable set is empty again; the next Helm unit is a decision, not a task.** diff --git a/checkins/2026-10-13-merlin.md b/checkins/2026-10-13-merlin.md index f896d4d..a3db7d8 100644 --- a/checkins/2026-10-13-merlin.md +++ b/checkins/2026-10-13-merlin.md @@ -796,3 +796,101 @@ paths and sha256 digests. This run's commit is authored `Merlin /q52-home`; a conditional `Q52 NOTE` on stderr and +one new `check("Q52")` (:1204). A `--q52-witness` extra flag (:1261) runs the copy with `--deploy --pin +--skip-tests --out ` — the pin shape the suite refuses (Q44's rule) — because the class's write path +is structurally unreachable from the green run. + +**Fixtures, verified against the interpreter first** (`make_fixtures_q52.py`): `collector-home.py` +(`d62888c4c9470d30…`, 29,956 B) and `collector-expanduser.py` (`99c49d395467709d…`, 29,974 B), each +`py_compile`-clean and each resolving `HELM_HOME /tmp//.helm` under a scratch HOME — the class measured, +not asserted. + +**Green pair, five consecutive invocations** (`run_green_q52.py`): rc 0 each, **176 passed / 0 failed** (= 175 + 1), +ALL GATES PASS, 14,044 B stdout / 1,216 B stderr, 12.34–12.73 s, live delta `{}`. The real collector's own +`.home()` read makes the check non-vacuous: `PASS Q52 the subject's home-directory reads resolve inside the sandbox +when the subject reads the home database`, `Q52 NOTE: … HOME redirected to /q52-home (/root -> /q52-home)` +(`out/q52-green-1.{out,err}`). Q44 and Q50 stay green on the same runs. + +**The discriminator, five cells under the digest-verified guard** (`run_matrix_q52.py` → `out/Q52-MATRIX.json`): +armed × `collector-home.py` — the copy resolves `HELM_HOME=/q52-home/.helm`, the pin lands at +`q52-home/.helm/data/held/stackchain-home.html.sha256` **inside the sandbox**, live delta 0; armed × +`collector-expanduser.py` — same, on the second channel; **disarmed × home** — `HOME` stays `/root`, the copy +resolves `HELM_HOME=/root/.helm` and the live pin moves to sha256 `caff1f504855264a…` / mtime_ns +`1791907100434371499`, restored byte-exact by the guard; **disarmed without the witness flag** — the foot's own +check fails (`FAIL Q52 … home_reads=['.home()'] HOME='/root' expected='/q52-home'`, 174 passed / 2 +failed); **direct invocation, ambient env** — the live pin moves, guard restores (the class's floor). Prevention +and detection measured separately. + +**Regressions 11/11** (`run_regressions_q52.py` → `out/Q52-REGRESSIONS.json`): nine refusals rc 2 (n1–n5, noparse, +dep-unparseable, undecodable, broken_dep); `collector-latin1.py` and `collector-bom.py` green at 14,044 B; live +delta `{}` on all eleven. `VERDICT: ALL REGRESSIONS HOLD`. + +**Live state, delta 0 on bytes/sha256/mtime across all 21 invocations** (5 green + 5 matrix + 11 regressions): +snapshot **14,039 B / `aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d` / mtime_ns +`1791479579475780303`**; held pin **120 B / +`5b6d70716153c3f4bd82045d5d71edf88f793a240ce1275c3dfca5759d0ee297` / mtime_ns `1791478687099553071`**. +**ctime moved on both objects** (`1791907371554631194`) — the guard restores of the two deliberately-live rows; +disclosed, not hidden. Final tree 0 sandboxes; deployed `/root/.helm/bin/` untouched (`7ed1bca8…`, `grep -c +HELM_COLLECTOR` = **0** — Q40 stands); disk 46 GiB free of 154. + +**Five defects of mine, logged first in the deliverable:** the witness's missing local `subprocess` import; the +witness anchored before the leg copy was written; the witness's live census reading the held *directory* +(`IsADirectoryError`); the NOTE claiming a redirect that had not fired (the disarmed twin's own FAIL contradicted +it — the NOTE is now derived from the actual environment); and the first matrix measuring nothing because the real +collector pins only under `--pin`, which the suite refuses — the witness flag exists because of that. + +### Blocked + +Nothing in my lane. Same standing gates, all Alexander's: the **§4 adoption `cp`** — now collector `4e45021d…` + +gate **`78b290ec…`** (superseding `e4967da9…`); Q40's finding stands (the deployed gate `7ed1bca8…` reads +`HELM_COLLECTOR` zero times), so none of Q31–Q52's protection is in production until it lands — Q9 (checksum +handover), Q13 (wording fixes), Q19 (wizard onboarding), Q42 (zephyrus's). + +### Next + +1. **The pipe's verification of this run**, when it lands. Everything re-runs from `/root/crucible/q52/`: + `make_fixtures_q52.py`, `patch_gate_q52.py`, `run_green_q52.py`, `run_matrix_q52.py`, + `run_regressions_q52.py`, `final_state.py`. +2. **The Helm lane's executable set is now empty.** The Q45 §9 follow-on is closed; the adoption, Q9, Q13/Q19 and + Q42 are human- or counterparty-gated. **No new item opened** — the next Helm unit is a decision, not a task. + The two limits the record states as the item's own — the original-subject legs' ambient environment (Q48's + residual) and `pwd.getpwuid()` bypassing `HOME` — are the only named candidates, and neither is a task until + someone says the class matters. + +### Ask + +Alexander: nothing blocking. The standing items, consolidated: (a) rule on the **§4 adoption `cp`** — collector +`4e45021d…` + gate **`78b290ec…`**, the pair that makes `python3 helm-selftest.py` green with no environment and +puts Q31–Q52's protection into production (the deployed gate today has no subject-selection path at all); (b) the +**`manifest_match` pin** decision (stale since 2026-10-08); (c) the **Q13 publication call** (item 13's decided +note — escalate, do not edit the live page — still controls over its `proposed_action`). diff --git a/recoveries/helm-home-channel-guard.md b/recoveries/helm-home-channel-guard.md new file mode 100644 index 0000000..2f0311d --- /dev/null +++ b/recoveries/helm-home-channel-guard.md @@ -0,0 +1,233 @@ +# Q52 — the home-directory channel + +**Merlin, Helm lane, 2026-10-13.** The last route in Q45's §7-limit-1 family +that carries neither a literal nor an ast-visible environment read. Scratch +tree `/root/crucible/q52/`; nothing deployed, no `cp` into `/root/.helm/bin/`, +`decisions/` and `inbox/` untouched, no another-party file edited, no secret. + +> Screen. Every claim below carries a path, a hash, a URL, or a command with +> its output, or the word `unverified`. + +--- + +## 1. The class + +The subject resolves its root from the **user database**: + +```python +HELM_HOME = Path.home() / ".helm" +``` + +CPython's `posixpath.expanduser` reads `HOME` from `os.environ` **itself**, +and `Path.home()` calls it, so: + +- **Q45's literal relocation** sees no absolute live-root literal — the only + string is `".helm"`, which does not resolve inside the live root on its + own; +- **Q48's environment inventory** walks the subject's own AST for + `os.environ` accesses and finds none — the read happens inside the + interpreter; +- **Q49's default redirect** keys on the same inventory; +- **Q48's argv rule** sees no root argument; +- **`__file__`** needs no mechanism (Q48's note) but is not this class; + +yet the composed path is the live root `/root/.helm`, and the subject pins +the live held directory. The channel is live in the shipped subject, not +hypothetical: the real collector calls `Path.home()` **twice** — its himalaya +config search, `/root/crucible/q48/helm-collect.py` lines 297–298 +(`grep -n 'Path.home()'` → 2 hits), on the collector byte-identical since Q33 +(`4e45021d80395d3f…`, 720 lines / 29,954 B, sha256 measured this run). + +## 2. The pair + +**Collector untouched** — every change is in the gate, as the item requires +(`4e45021d…` re-measured by sha256). + +| | bytes / lines | registrations | sha256 | +|---|---|---|---| +| Q51 gate (base) | 134,427 / 2,558 | 125 | `e4967da9bdc45378…` | +| **Q52 gate (shipped)** | **140,365 / 2,677** | **126** | **`78b290ecf2d93e0c…`** | +| Q52 disarmed twin | 140,390 / 2,677 | 126 | `be7b306afa7c5b3a3…` | + +`out/DIFF-vs-q51.txt` = **169 lines = 6 hunks + 122 added − 3 removed**, and +2,558 + 122 − 3 = 2,677 exactly (counted by `grep -c`, not asserted). +Registration count `grep -cE '^[[:space:]]*check\('` = 126; the disarmed twin +differs from the shipped gate in exactly one line (the redirect cut), as in +Q50's and Q51's pairs. + +**The mechanism, one function, two call sites, one check.** + +1. **The inventory** — `_q52_home_sites()` (gate line 786): every + home-database read in the subject's own AST — `Path.home()`, a bare + `.home()`, `expanduser` (attribute or bare), `pwd.*`. Module-level + `_q52_home_reads` holds the result for both rebuild callers. +2. **The redirect** — inside `_q50_rebuild_env` (line 815), the same function + that already serves the admission probe (Q50) and the legs: when the + subject reads the home database, `HOME` **and** `USERPROFILE` are + redirected to `/q52-home`. A redirect, not a refusal; the + collector's own `Path.home()` reads keep working, against the sandbox's + home. Probe caller at :977, legs caller at :1162 — both take the new + four-tuple, so the two paths cannot drift. +3. **The NOTE** — one `Q52 NOTE` on stderr, derived from the **actual** + environment, so it cannot claim a redirect that did not fire (see defect 4). +4. **The check** — `check("Q52", …)` at :1204, registered at the legs' site: + when the subject reads the home database, the legs' `HOME` must be the + sandbox home; with no home-database read it passes vacuously and the NOTE + says so. +5. **The witness** — `--q52-witness` (line 1261, after the leg-copy write): + runs the copy the way the suite never does — `--deploy --pin --skip-tests + --out ` — under the rebuilt environment, prints one JSON + object and exits before the foot. The green run never passes the flag, so + the block cannot affect it. It exists because the suite itself refuses a + `--pin` leg (Q44's rule), so the pin shape is unreachable from the suite + (defect 5). + +## 3. The fixtures, verified against the interpreter first + +Two transforms of the real collector (`make_fixtures_q52.py`, asserted count +1 each): + +| fixture | transform | bytes | sha256 | +|---|---|---|---| +| `collector-home.py` | `HELM_HOME = Path.home() / ".helm"` | 29,956 | `d62888c4c9470d30…` | +| `collector-expanduser.py` | `HELM_HOME = Path(os.path.expanduser("~")) / ".helm"` | 29,974 | `99c49d395467709d…` | + +The interpreter's own verdict, measured by `make_fixtures_q52.py`'s probe +(`runpy` + a scratch `HOME`): both fixtures `rc 0` with +`HELM_HOME /tmp//.helm` and `HELD_DIR /tmp//.helm/data/held` +— **each resolves inside a scratch HOME**, and with the ambient HOME (`/root`) +the same composition is the live root. That is the class, measured rather +than asserted. + +## 4. The green pair, five consecutive invocations + +`run_green_q52.py` (subject `/root/crucible/q48/helm-collect.py`, +`Q48_ROOT` unset; logs `out/q52-green-{1..5}.{out,err}`, artifact +`out/Q52-GREEN.json`): **rc 0 each, 176 passed / 0 failed**, `VERDICT: ALL +GATES PASS`, **14,044 B stdout / 1,216 B stderr**, 12.34–12.73 s, live delta +`{}` on all five. 176 = 175 + 1; no pre-existing check moved. + +The control that matters: the real collector **reads the home database**, so +the redirect fires on every green run and the check is non-vacuous — + +``` +PASS Q52 the subject's home-directory reads resolve inside the sandbox when the subject reads the home database +Q52 NOTE: the subject reads the home database at 1 site(s) (.home()); HOME redirected to + /root/crucible/q52/helm-legs-abis_h7c/q52-home (/root -> /root/crucible/q52/helm-legs-abis_h7c/q52-home) +``` + +(`out/q52-green-1.out`, `out/q52-green-1.err`). The himalaya source is +`UNREACHABLE` either way — the binary is absent from this host — so the +redirect moves an already-empty search, and `Q44`/`Q50` stay green: +`PASS Q50 the admission probe's spawn moved neither live object`, `PASS Q44 +the live held directory is unchanged by every leg in the run`. + +## 5. The discriminator, five cells, every one under the guard + +`run_matrix_q52.py` → `out/Q52-MATRIX.json` (guard: `q52_guard.py`, refuses +to restore unless the backup digest matches the recorded `5b6d7071…`). + +| row | gate | subject | rc | live moved | what the witness shows | +|---|---|---|---|---|---| +| `q52-armed-home` | Q52 `78b290ec…` | `collector-home.py` | 0 | **no** | `HOME_witness_env` = `/q52-home`; the copy's `HELM_HOME=/q52-home/.helm`, `HELD_DIR=/q52-home/.helm/data/held`; the pin landed at `q52-home/.helm/data/held/stackchain-home.html.sha256` **inside the sandbox**; live snapshot `14,039 B / aa842b0f…`, live pin `120 B / 5b6d7071…` — unchanged | +| `q52-armed-expand` | Q52 `78b290ec…` | `collector-expanduser.py` | 0 | **no** | same, on the `expanduser` channel (2 inventory sites: `.home()`, `expanduser` — the collector's own himalaya search plus the transform) | +| `q52-dis-home` | Q52 disarmed `be7b306a…` | `collector-home.py` | 0 | **YES** | `HOME_witness_env` = `/root`; the copy resolves `HELM_HOME=/root/.helm` — the LIVE root; the live pin moved to sha256 `caff1f504855264a…` / mtime_ns `1791907100434371499`, restored byte-exact by the guard | +| `q52-dis-check` | Q52 disarmed `be7b306a…` | `collector-home.py` | 1 | no | the foot's own check fails: `FAIL Q52 … home_reads=['.home()'] HOME='/root' expected='/q52-home'`, `174 passed, 2 failed` | +| `q52-raw-home` | none — direct invocation | `collector-home.py` | 0 | **YES** | the class with nothing in the path: `--deploy --pin --skip-tests --out `, ambient env — the live pin moves, guard restores | + +Prevention and detection are measured separately: the armed rows redirect +and the pin lands in the sandbox; the disarmed row, same subject, writes +live; the check fails when the redirect is cut. The class is real, one +mechanism away, and the mechanism closes it. + +## 6. Regressions, 11/11 + +`run_regressions_q52.py` → `out/Q52-REGRESSIONS.json`: `n1–n5`, `noparse-with-markers.py`, +`dep-unparseable.py`, `collector-undecodable.py`, `broken_dep.py` all **rc 2** +(Q39 refusals / Q46 parse guard / repair 3 / Q47 read path / Q35 source +precondition); `collector-latin1.py` and `collector-bom.py` **green at 14,044 +B** — the read-path fix's subjects unaffected. Live delta `{}` on all eleven. +`VERDICT: ALL REGRESSIONS HOLD`. + +## 7. Live state + +**Delta 0 on bytes / sha256 / mtime_ns across all 21 invocations** (5 green + +5 matrix + 11 regressions): snapshot `/root/.helm/data/snapshot.json` +**14,039 B / `aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d` +/ mtime_ns `1791479579475780303`**; held pin +`/root/.helm/data/held/stackchain-home.html.sha256` **120 B / +`5b6d70716153c3f4bd82045d5d71edf88f793a240ce1275c3dfca5759d0ee297` / +mtime_ns `1791478687099553071`**. + +**ctime moved on both objects** — `1791907371554631194` — the in-gate and +guard restores of the `q52-dis-home` and `q52-raw-home` rows, which wrote +live by design. The stamp a restore cannot undo (Q43); disclosed, not hidden. + +Final tree: **0 sandboxes** (`glob` for `helm-legs-*` / `helm-q39-*` → `[]`). +Deployed `/root/.helm/bin/` untouched (`helm-selftest.py` 33,647 B / +`7ed1bca8…` / 2026-10-11 11:13:31Z, `grep -c HELM_COLLECTOR` = **0** — Q40's +finding stands, so none of Q31–Q52 is in production). Disk **46 GiB free of +154 (71 % used)** (`df -h /`). + +## 8. Five defects of mine, logged first + +1. **The witness crashed on a missing import.** `subprocess` is imported at + module level *below* the witness block (repair 5), so the first witness + run died with `NameError: name 'subprocess' is not defined` after the + copy had already been built. Fixed with a local import in the block, + asserted in the patcher. +2. **The witness was anchored before the copy was written.** The first + placement ran `subject-legs.py` before `_q43_subject.write_text(...)`, so + it died on `FileNotFoundError`. Re-anchored on the copy write; the + patcher asserts the ordering (inventory < rebuild < callers < check < + copy write < witness). +3. **The witness's live census read a directory.** `live_held_after` used + `_q44_held_live.read_bytes()`, which is the held *directory* in this + lineage — `IsADirectoryError`. Fixed to `_q50_public(_q50_census())`, the + same both-objects census the window detector uses. +4. **The NOTE claimed a redirect that had not fired.** The first version + printed "HOME redirected to …" whenever the subject read the home + database — and the disarmed twin's own `FAIL Q52` line contradicted it. + The NOTE is now derived from the actual environment, so the disarmed twin + prints `HOME NOT redirected -- HOME in the legs' environment is '/root'`. + The same defect class as Q50's disarmed twin NOTE; caught here before the + pipe could. +5. **The first matrix measured nothing.** The real collector pins only under + `--pin`, and the suite refuses a `--pin` leg (Q44's rule), so the first + four rows — subjects run without the pin shape — moved nothing and + proved nothing. The `--q52-witness` flag exists because of this: the + class's write path is structurally unreachable from the green suite, the + same shape Q48's witness was built for. + +## 9. Limits, stated as the item's first limits + +- **The original-subject legs still inherit the ambient environment** + (Q43's resolution leg, G8's dry-run leg, G8's in-process deploy leg): a + subject whose root resolves at import time in the gate's own process + writes live from a leg the redirect does not cover. Q48's residual, + unchanged by this item. The witness covers the copy's environment only. +- **`pwd` reads bypass `HOME`.** `posixpath.expanduser` prefers `HOME` when + set (so the redirect covers it), but a subject calling + `pwd.getpwuid(os.getuid()).pw_dir` directly reads the user database and + the redirect cannot see it. The inventory *names* such a site; the + redirect does not relocate it. Stated, not fixed. +- **The inventory is static.** Home reads behind indirection — `getattr`, + aliasing, a helper module — are outside every static inventory's reach + (carried from Q46). +- **`USERPROFILE` is redirected alongside `HOME`** on this host although it + is a Windows variable and unset here; the redirect is a no-op for it, and + the pair keeps the mechanism portable rather than POSIX-specific. + +## 10. Ask + +Alexander's, unchanged: (a) the **§4 adoption `cp`** — collector +`4e45021d…` + gate **`78b290ec…`**, the pair that makes +`python3 helm-selftest.py` green with no environment and puts Q31–Q52's +protection into production (the deployed gate today has no +subject-selection path at all); (b) the **`manifest_match` pin** decision +(stale since 2026-10-08); (c) the **Q13 publication call** (item 13's +decided note — escalate, do not edit the live page — still controls over its +`proposed_action`). This run added no new ask, and wrote and restored the +live held directory twice, by design, under the guard (disclosed in §7). + +— Merlin