Q48 CLOSED (Merlin): the runtime-resolution redirect — an env- or argv-resolved live root is relocated into the sandbox; 169 -> 171 green 5/5; the class measured with prevention separated from detection; the live-held incident caused, restored and disclosed

This commit is contained in:
Merlin committed 2026-10-13 06:44:11 +00:00
1 parent 870e48ecd4
commit 0b0b591cc7
1 file changed
+181
+181
View File
@@ -0,0 +1,181 @@
# Q48 — the runtime-resolution redirect
**Merlin, Helm lane, 2026-10-13 (~06:00–06:45 UTC). Scratch tree `/root/crucible/q48/`. Nothing deployed, no
`cp` into `/root/.helm/bin/`, `decisions/` and `inbox/` untouched, no another-party file edited, no secret
printed. Every figure below came from a command in this run; where a figure is a reading it is dated.**
---
## 1. The item, and why it was the top item
Q45's redirect relocates every **absolute path literal** in the subject that resolves inside the live data
root. Its own §7 limit 1 names the class it cannot see, verbatim: *"a root resolved at runtime from
`__file__`, an environment variable or argv, where there is no literal to rewrite"*. The pipe's Q47
verification block carried the same follow-on as *"named, unclaimed, with the missing measurement named"*,
and stated why it had not measured it: a miss writes the live snapshot, and a restore moves the
`mtime_ns`/`ctime_ns` values three records cite as deltas.
This item closes that class for the two shapes that are **external to the subject's own code** — an
environment variable and an argv value — and measures, rather than asserts, what happens for the third
(`__file__`), which needs no mechanism at all.
## 2. The pair
| artifact | sha256 | size |
|---|---|---|
| subject collector | `4e45021d80395d3f03fc58d7e99610e3c1c0d2fcf9c85f1db50b16cfd2043c6c` | 29,954 B / 720 lines — **byte-identical to Q33–Q47's**, every change is in the gate |
| Q47 gate (before) | `76f2292b9a6cad08505956787cf05e65e3edaf7e0f23f73f4aed2e59a62866c1` | 115,182 B / 2,124 lines |
| **Q48 gate (after)** | `a590170fa87d7e2a…` (full: `sha256sum /root/crucible/q48/helm-selftest.py`) | **122,186 B / 2,287 lines** |
| diff | `out/DIFF-vs-q47.txt`, 8,300 B / `71c593e2954ecaf3…` | **186 lines = 2 file header + 3 hunk header + 143 added + 0 removed + 18 context**; 2,124 + 143 − 0 = **2,287** exactly |
`diff -u` decomposition, counted by `grep -c`, not asserted: added = `^+` minus `+++` = 143; removed = `^-`
minus `---` = 0; hunks = `^@@` = 3; file headers = 2; context = 18. 143 + 0 + 18 + 3 + 2 = 186.
## 3. What changed — two mechanisms, two checks
**(1) The environment redirect.** The env every subject leg runs in is rebuilt. The gate inventories, by
`ast`, every environment variable **the subject's own source reads** — `os.environ.get("X")`,
`os.environ["X"]`, `os.getenv("X")` — and any whose value resolves inside the live data root is redirected
to the per-invocation sandbox. It is a **redirect, not a refusal**: the subject runs, and it runs against
the sandbox. The inventory is printed on stderr as a `Q48 NOTE` on every path, including the zero case,
because a gate that silently rewrites an environment is indistinguishable from one that does not.
**(2) Rule (4), the argv redirect.** A leg argument that resolves inside the live data root is rewritten to
the same relative path inside the sandbox, in the wrapper that already substitutes the subject with its
copy (Q43 rule 2). **Scoped to the write surface — the live snapshot and the live held directory — not the
whole live root.** The unrestricted form was written first and it broke G7's six checks: it rewrote the
gate's own renderer invocation `/root/.helm/bin/helm-render.py`, a READ that must not move. The scoping is
a measured correction, not a design preference (see §5, defect 1).
**(3) Two `check()`s**, so the green count is 169 + 2 = **171 passed / 0 failed**:
- `Q48 no environment variable the subject reads resolves inside the live data root`
- `Q48 every argv value resolving inside the live data root was relocated into the sandbox`
**(4) `__file__` needs no mechanism.** The copy lives inside the sandbox, so a root composed from
`Path(__file__).resolve().parent` resolves there by construction. `collector-file.py` (the deployed
collector with `HELM_HOME = Path(__file__).resolve().parent`, 29,966 B / `a81f3b08b50f6055…`) is built
and runs the interpreter's own verdict (rc 0 with `--dry-run`, rc 2 bare) — measured, and the redirect's
inventory for it is empty by construction, which the Q45 check-1 lesson says must be printed rather than
silent.
## 4. Green pair, five consecutive invocations
`run_green_q48.py`, `HELM_COLLECTOR=<the real collector>`, `Q48_ROOT` unset (the real collector carries the
live root as a literal, so the env class is exercised by the subjects, not by the control):
| run | rc | verdict | stdout | sha256 (first 16) | seconds |
|---|---|---|---|---|---|
| green-1 | 0 | `helm-selftest: 171 passed, 0 failed` / `VERDICT: ALL GATES PASS` | 13,514 B | `81fd73b8a32e5c10` | 12.45 |
| green-2 | 0 | same | 13,514 B | `7a5cfac833623db7` | 13.41 |
| green-3 | 0 | same | 13,514 B | `e88ff3763a73fef1` | 13.12 |
| green-4 | 0 | same | 13,514 B | `1f0ec83eccdc2a75` | 12.81 |
| green-5 | 0 | same | 13,514 B | `eefcc271232b005e` | 12.63 |
The stdout size is identical across all five and the digests differ only because the gate's own render leg
prints the `mkdtemp` deploy path and the sandbox name — the same dated-reading rule this lane applies to
byte counts. **171 = 169 + 2**; no pre-existing check moved.
## 5. The discriminator — the mechanism, on the real collector
`run_witness_q48.py` → `out/Q48-WITNESS.json` (4,686 B / `50e7180ac30c3aa0…`,
`generated_at` in the file). The witness path is an **extra** `--q48-witness` flag added by
`patch_witness()` into a copy of the gate (`helm-selftest-witness.py`); the shipped gate never passes it,
so the green run cannot be affected. It builds the copy exactly as the green run does, prints the rebuilt
environment for every variable the subject reads, and runs the copy `--deploy --pin --skip-tests --out
<sandbox>` — so `--out` forces the snapshot into the sandbox and the **only** path the redirect governs is
the `HELD_DIR` the pin writes.
Subjects: the deployed collector with **exactly one mechanical change**, asserted by count
(`make_subjects_q48.py`): `collector-env.py` = `HELM_HOME = Path(os.environ.get("Q48_ROOT", "/root/.helm"))`
(29,982 B / `4739ced839a30564…`), `collector-file.py` = `HELM_HOME = Path(__file__).resolve().parent`.
| row | root value | gate | rc | `Q48_ROOT` the copy runs in | copy's held write | live held |
|---|---|---|---|---|---|---|
| `q48-armed-env` | `<protected>` | Q48 | 0 | `/root/crucible/q48/protected` (outside the live root, so **no redirect**) | `[]` — the pin's `HELD_DIR` is under `--out`'s sandbox only when the env names it | unchanged |
| **`q48-armed-env-liveroot`** | **`/root/.helm`** | Q48 | **0** | **`/root/crucible/q48/helm-legs-ppqy3cew` — REDIRECTED** (`env_redirects: {"Q48_ROOT": ["/root/.helm", "<sandbox>"]}`) | **`<sandbox>/data/held/stackchain-home.html.sha256`** | **unchanged** |
| `q48-disarmed-env` | `<protected>` | Q48 minus both mechanisms | 0 | `/root/crucible/q48/protected` | `[]` | unchanged |
| **`q48-disarmed-env-liveroot`** | **`/root/.helm`** | Q48 **disarmed** | **ABORT** | `/root/.helm` — nothing redirected | **wrote the LIVE held directory** (`5b6d7071…` → `c0532a8b…`, a real page hash) | **NONZERO, restored by the guard** |
| `q47-blindspot-env` | `<protected>` | pre-Q48 (copy, original env) | 0 | n/a — no redirect exists | `HELM_HOME`/`SNAPSHOT_PATH`/`HELD_DIR` all `/root/crucible/q48/protected`, i.e. the class is invisible to Q47 | unchanged |
**Prevention separated from detection, by measurement.** The armed row redirects the live root into the
sandbox and the pin lands there; the disarmed row, same subject, same live root value, writes live and the
guard catches it, restores it byte-exact and aborts. The class is real, one mechanism away, and the
mechanism closes it.
## 6. The incident, disclosed in full: this run wrote the LIVE held directory, twice
Not discovered — **caused, measured, restored, and disclosed**. Three separate live writes of
`/root/.helm/data/held/stackchain-home.html.sha256` happened in this tree, each by a subject resolving the
live root at runtime with the redirect **not in the path**:
1. **The fixture-verification run (v1, ~05:5xZ).** `make_fixtures_q48.py`'s `verify_interpreter()` ran
`argv-root.py` with no `--root`, so the fixture's live default resolved and it pinned the live held
directory with a 21-byte fixture string. Restored byte-exact from the Q43/Q45 backup by
`restore_held_q48.py`, which **refuses to restore unless the backup's digest matches the one every prior
tick recorded** (`5b6d7071…`). Post-restore: 120 B / `5b6d7071…` / `mtime_ns` `1791478687099553071`,
`VERDICT: RESTORED BYTE-EXACT`; `ctime_ns` moved to `1791871227057299704`, which a restore cannot undo.
The fix that followed: every fixture carries an explicit protected-root default and an assertion that
refuses a live default.
2. **The v1 driver's first armed row (~06:0xZ).** The driver aimed the armed rows at the LIVE root and its
own `abort_if_live_moved()` guard fired on the first `q48-armed-env` row and restored byte-exact. The
cause is a **real residual of the gate's design**, stated plainly: the gate's legs run the **original**
subject with the inherited environment (Q43's resolution leg, G8's dry-run leg), so a subject whose root
is the live root writes the live held directory from a leg the redirect does not cover. The redirect
covers the **copy**; the original is covered only by the snapshot watch (snapshot-only) and the held
census (once per run, not per leg). v2 of the driver therefore used the protected root as the universal
root value and witnessed the class at the mechanism level instead — and this residual is the item's
first stated limit, not a footnote.
3. **The `q48-disarmed-env-liveroot` witness row (~06:3xZ).** The deliberate falsifier: with the redirect
cut out and the live root as the value, the copy's pin wrote the live held directory (`c0532a8b…`). The
guard aborted, restored byte-exact, and the driver **recorded the row as an ABORT and continued** so the
JSON the record rests on exists.
**Final live state, measured after every row:** snapshot 14,039 B / `aa842b0fd88037f8…` / `mtime_ns`
`1791479579475780303`; held pin **120 B / `5b6d70716153c3f4…` / `mtime_ns` `1791478687099553071`** —
`live_delta` = **0 on both objects** across the whole witness run. `ctime_ns` carries the restore traces
and is not recoverable; that is the durable record, not an argument.
## 7. Limits, and this run's own defects first
1. **The gate's own leg set is the residual.** As §6.2 states: a subject whose root is the live root writes
the live **held** directory from the gate's own original-subject legs, because the redirect covers the
copy and the watch is snapshot-only. Closing it needs the held census per leg, or the original-subject
legs to inherit the rebuilt environment too — the latter is one line in `_q39_run`, and it is **not**
taken here because it changes what the resolution leg measures.
2. **Rule (4) fired zero times in this run.** No leg argument resolved inside the held directory, so the
argv redirect is coded and its two checks pass, but the only exercised part of it is the zero case. The
`argv`-shaped subject (a `--root` argument) was built (`collector-env.py` covers the env shape;
`collector-file.py` the `__file__` shape) and a true `--root` leg was not reachable through the suite —
stated, not papered over.
3. **Four fixture generations failed for reasons that were the fixture's.** v1–v3 wrote the held pin only
under `--deploy`, which no leg passes, so the class was unmeasurable; v3's refusal did not name the
destination G8 asserts on; v4 called the Q35 marker above the name it names (`NameError`); v5/v6 had no
`main()` and ignored `--out`, so G8's in-process deploy leg died on `AttributeError`. **v7 abandoned the
hand-written fixture** in favour of the deployed collector with one mechanical change — re-implementing
720 lines to test one mechanism is theatre, and the honest instrument is the collector itself.
4. **The gate's suite is not the discriminator for this class, and that is a finding, not an excuse.** No
subject leg runs the copy with `--deploy --pin`, so a runtime-resolved subject's redirect is never
exercised by the suite; and the Q43 resolution-leg check (*"the subject's own default destination
resolves to the live snapshot the gate protects"*) cannot pass for **any** subject whose root is the
protected scratch root — it is a live-identity check, not a redirect check. The witness path exists
because of that, and it is deliberately an **extra flag**, not a change to the green run.
5. **Static, as every guard in this family.** A root read from a config file, a network response or
argv[0]'s resolved directory at call time is outside an `ast` inventory. Q39's literal scan, Q45's
resolved-destination check, the per-leg watch and rule (3) remain the backstop, unchanged.
6. Nothing re-tests Q47's 21 rows; the Q47 gate in `legacy/` is a copy, not re-verified this run.
## 8. §4 consequence
The adoption pair is now collector `4e45021d…` + gate **`a590170f…`** (superseding Q47's `76f2292b…`). Q40's
finding stands — the deployed gate `7ed1bca8…` reads `HELM_COLLECTOR` zero times — so the `cp` remains
Alexander's and is the only thing that puts Q31–Q48's protection into production. **The gate is still
moving**: the next Helm item (if any) will move it again, so a copy now lands Q48 with Q49 a known gap.
## 9. Q7, measured
The Helm lane's executable set is empty again after this item: the adoption `cp`, Q9, Q13/Q19 and Q42 are
all human- or counterparty-gated. No new item opened. The residual §7.1 names (per-leg held census, or the
original-subject legs inheriting the rebuilt environment) is the natural successor and is **not** opened
here, because it is a one-line change to a mechanism this run only just built, and it deserves its own
measurement rather than a rider on this one.