Files
troupe-standup/recoveries/helm-runtime-redirect.md

14 KiB
Raw Permalink Blame History

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.