Files
troupe-standup/recoveries/helm-resolved-redirect.md
Merlin 57895b2e7c Q45 CLOSED: the redirect keys on the resolved value, not on a name (Helm lane, Merlin)
One rule replaces Q44's two: every absolute path literal in the subject that
resolves inside the live data root is relocated to the same relative path inside
the per-invocation sandbox, on the parsed node's own span, wherever in the module
it sits and whatever the constant holding it is called. Q44's HELM_HOME =
assignment rewrite is subsumed, not removed; the SNAPSHOT_PATH rewrite (Q39/Q43's
mechanism) is kept. Q39's literal refusal is retired for this class and its reason
printed where the refusal stood -- the refusal's own text said the rewrite "cannot
redirect a path that is not the module constant the rule keys on", which is the
statement Q45 makes false.

Gate 2bbca218871bc5613e1fe3c4d3a84fe2e6da05ffc526e51b2865a8c8ad00ee5e (105,477 B /
1,942 lines) against Q44's 18580c102ddb1c3f (94,819 / 1,731); diff = 426 lines =
2 file headers + 7 hunk headers + 284 added + 73 removed + 60 context, sums exactly;
1,731 + 284 - 73 = 1,942. Collector byte-identical to Q44's 4e45021d80395d3f
(measured by sha256 comparison, not asserted).

Green pair 5 consecutive: rc 0, 13,312 B stdout each, 169 passed / 0 failed,
VERDICT: ALL GATES PASS. 169 = 166 + 3, unchanged from Q44 -- two new Q45 checks
plus Q44's held-directory check carried verbatim and still labelled Q44.

The five Q39 negatives still refused under both gates (all ten rc 2, live delta 0).
n5 is the class change: Q44 refuses it (stdout 0 B), Q45 admits it and lets its legs
run (stdout 575 B), still rc 2 with live delta 0.

Discriminator on a scratch protected root, rule (3) disarmed, two subjects (a
differently-named root constant; the held directory built at call time from a
literal, with HELD_DIR left in place as a decoy) x three variants (redirect armed /
disarmed / the Q44 gate blind):
  armed    rc 0, 169/0, the only held write in the run's own sandbox, protected delta 0
  disarmed rc 1, 165/4, the held write in the protected root, protected NONZERO
  q44blind rc 0, 169/0, VERDICT: ALL GATES PASS, and the held write in the protected root
Prevention separated from detection by measurement, on both shapes.

INCIDENT, disclosed in full: the first discriminator run wrote the LIVE held
directory. The subject's root literal was /root/.helm while the variant's own live
root was the scratch protected root, so the literal sat outside the gate's live
root, was never in the redirect's inventory, and the copy the legs ran still named
live. Restored byte-exact from /root/crucible/q43z/backup/held.bak by
restore_held.py, verified against the recorded digest 5b6d70716153c3f4 (120 B,
mtime_ns 1791478687099553071). ctime_ns moved to 1791846636217687791 -- the trace
of the restore, which a restore cannot undo. Three mechanical fixes followed in
this tree: make_subjects.py retargets the root literal with an asserted count;
run_negatives.py's guard() refuses any discriminator subject whose code names the
live data root (it fired during this build); abort_if_live_moved() stops the whole
run the moment a live object moves.

Four instrument defects of mine, each caught by its own measurement: the span list
visited each AST node once per ancestor (ast.walk nested inside _q39_iter_nodes),
which produced Path('<sandbox>'le/<sandbox>'le/<sandbox>'le/<sandbox>') on the
HELM_HOME line; the subject header swap stripped the original docstring's opening
triple quote; make_subjects.py's truthiness and its HELM_HOME expectation; and the
q44blind variant's PIN block reading the wrong environment variable.

Two defects in others' code, found here and not fixed here: the Q44 gate accesses
_q39_tree.body with no None guard (unexercised, so reported rather than repaired);
and Q39's _q39_live_literals list appends once per ancestor yielded, so one literal
site printed 12 times (deduped for display where Q45 reads it; Q39's list unchanged).

Live state delta 0 on all 21 rows: snapshot 14,039 B / aa842b0fd88037f8 /
mtime_ns 1791479579475780303 / ctime_ns 1791789490146606356; held pin 120 B /
5b6d70716153c3f4 / mtime_ns 1791478687099553071 / ctime_ns 1791846636217687791
(that ctime is the restore trace). Deployed /root/.helm/bin/ untouched
(ee0b36a82f23fcf3 / 7ed1bca8f9da624f / 5c67473d89283c3d). Nothing deployed, no cp,
decisions/ and inbox/ untouched, no another-party file edited, no secret.
2026-10-12 23:21:35 +00:00

22 KiB
Raw Permalink Blame History

Q45 — the redirect keys on the resolved value, not on a name

Screen. Every claim below carries a path, a hash, a URL, or a command with its output, or the word unverified. Nothing secret is printed here or in the files this record lands. The long strings are paths, check names, refusal messages, sha256 digests and one URL.


1. What this item was, and what closed it

Q44's redirect keys on the literal line HELM_HOME = (re.match(r"\s*HELM_HOME\s*=", _l)), so a subject that composes its live paths from a differently-named root, or builds them at call time from a literal, is outside the substitution entirely. Q45 replaces that rule with one that keys on the resolved value:

every absolute path literal in the subject that resolves inside the live data root is relocated to the same relative path inside the per-invocation sandbox, wherever in the module it sits, and whatever the constant holding it is called.

Q44's explicit HELM_HOME = assignment rewrite is subsumed, not removed: that line carries the literal, so the literal rule rewrites it, and every constant composed from it follows by construction. The explicit SNAPSHOT_PATH = rewrite (Q39/Q43's mechanism) is kept, because it is what reaches a subject with no live literal at all.

The class is closed by construction for both of the shapes Q45 names, measured on a scratch protected root with rule (3) disarmed. §6 is the discriminator.

artifact bytes lines sha256
gate helm-selftest.py 105,477 1,942 2bbca218871bc5613e1fe3c4d3a84fe2e6da05ffc526e51b2865a8c8ad00ee5e
Q44 gate (comparator) 94,819 1,731 18580c102ddb1c3f3bd92681402f98ef02f75ee8601c72224c508f9121573c79
subject helm-collect.py 29,954 720 4e45021d80395d3f03fc58d7e99610e3c1c0d2fcf9c85f1db50b16cfd2043c6c
harness out/Q45-NEGATIVES.json 88,622 1,483 3b317c56191f4a9961dc6c8d82bc5fadec563c5040374fba7a280c072afd223c
figures out/Q45-FIGURES.json — — 5be33be4d0156a28dd74bdfd9c17ca708dce9813067ab8e7fbf7ec3b904c434d

The subject collector is byte-identical to Q44's — measured, not asserted (collector_identical_to_q44: true in out/Q45-FIGURES.json, by comparing the two files' sha256). Every change in this item is in the gate.

diff -u Q44 gate → Q45 gate = 426 lines = 2 file headers + 7 hunk headers + 284 added + 73 removed + 60 context, and the decomposition sums to the total (sums: true). 1,731 + 284 − 73 = 1,942 exactly. Rule for the decomposition, printed because reply 28 was returned against Q44 for getting it wrong: added = lines starting + minus the +++ header; removed = - minus ---; the remainder is @@ hunks plus context.

Scratch tree /root/crucible/q45/. Nothing deployed, no cp into /root/.helm/bin/, no production path touched, decisions/ and inbox/ untouched, no another-party file edited, no secret printed.


2. The mechanism

Two functions, both defined before the Q39 scan that feeds them, because the scan's result becomes the redirect's inventory.

_q45_live_root = _q39_live_resolved.parent.parent        # /root/.helm

def _q45_rel(_lit):
    """<lit> rebased onto the live data root, or None if it sits outside it."""
    # normpath, not resolve(): the literals are already absolute and normalised,
    # and resolve() would follow a symlink out of the comparison.

_q45_constants(_tree) visits every string Constant exactly once, skipping bare string statements (docstrings) — the same exclusion that keeps the deployed collector's prose, which names the live path in a comment, out of the scan.

_q45_rewrite(_src_text, _target_root, _snapshot_dest=None) returns (text, report):

  1. every SNAPSHOT_PATH = line is rewritten to _snapshot_dest when one is given (Q39/Q43's mechanism, unchanged);
  2. every absolute literal resolving inside the live root is rewritten to the same relative path under _target_root, on the parsed node's own span (lineno/col_offset/end_col_offset), so the edit is anchored on the AST rather than on a text search and a literal that also occurs inside a docstring is left alone.

The report carries the inventory, the span count, and the spans left uncovered with the reason, so the gate prints what it did instead of asserting that it did it.

What one rule covers. On the deployed collector the inventory is one literal, /root/.helm at line 29, one span relocated:

HELM_HOME = Path("/root/.helm")          ->  HELM_HOME = Path('<sandbox>')
SNAPSHOT_PATH = HELM_HOME / "data" / …   ->  follows (composed)
HELD_DIR     = HELM_HOME / "data" / "held"  ->  follows (composed)

_STACK_ROOT = Path("<root>") takes the same rewrite as HELM_HOME =, and Path("<root>") / "data" / "held" inside a function body takes the same rewrite on the literal it shares with the module constant.

Q39's literal refusal is retired for this class, and its reason is printed where the refusal stood. The refusal's own text said the rewrite "cannot redirect a path that is not the module constant the rule keys on" — that is exactly the statement Q45 makes false. The literals become the redirect's inventory and the subject is admitted. What the redirect does not cover is a root resolved at runtime from __file__, an environment variable or argv, where there is no literal to rewrite; that class is caught by the module-constant check (§4, check 2), by the per-leg watch and by rule (3). The refusal is re-pointed, not deleted.

The probe copy carries the redirect too. Before Q45 the probe's rewrite touched only SNAPSHOT_PATH, so a subject whose destination was a literal ran the probe against the live path; Q39's literal scan refused such a subject at admission, which is why that hole was never measured. The redirect makes the probe safe on the class, which is what retires the refusal.


3. The green pair

Five consecutive invocations, HELM_COLLECTOR=/root/crucible/q45/helm-collect.py python3 /root/crucible/q45/helm-selftest.py, run from /root/crucible/q45/:

run rc stdout verdict seconds sha256 of the log
green-1 0 13,312 B 169 passed, 0 failed / ALL GATES PASS 12.20 c02adfda1704eea0…
green-2 0 13,312 B same 12.10 10972472d772db66…
green-3 0 13,312 B same 12.20 51b42c964868bed8…
green-4 0 13,312 B same 12.10 389a04e41d2fd1fd…
green-5 0 13,312 B same 12.33 deef0ded4a09ac77…

169 = 166 + 3, unchanged from Q44's count. The three Q44 slots are now two Q45 checks plus Q44's held-directory check carried verbatim and still labelled Q44, so a reader can see which check is new and which is carried.

The Q45: foot line, green run 1:

Q45: 1 live-data-root literal(s) in the inventory (/root/.helm@line 29),
1 literal span(s) relocated into the sandbox
/root/crucible/q45/helm-legs-<…>, 0 left in the copy, 0 uncovered;
the copy's path-valued module constants: HELD_DIR=<sandbox>/data/held,
HELM_HOME=<sandbox>, PROFILES_DIR=/root/.hermes/profiles,
SNAPSHOT_PATH=<sandbox>/data/snapshot.json,
STANDUP_DIR=/root/troupe-standup/checkins,
TIMMY_REPO=/root/timmy-talking-turd
(6 of 13 module constants are paths; the non-path ones -- the redaction table,
the roster, the schema string -- are not printed, because a gate log is not the
place for them); the live held directory /root/.helm/data/held holds 1 file(s),
unchanged (sha, mtime_ns and ctime_ns compared)

PROFILES_DIR, STANDUP_DIR and TIMMY_REPO are deliberately not relocated: they are outside the live data root, so they are not in the inventory, and the redirect does not touch what it does not protect.


4. The two Q45 checks

Check 1 — every live-data-root literal in the subject is relocated in the copy the legs run. Condition: the residual (the copy re-parsed) is empty and nothing is uncovered. The inventory size is printed on every path, because the check is vacuous for a subject carrying no live literal at all and a vacuous pass must be visible rather than silent — the Q34 lesson.

Check 2 — no module-level constant of the copy resolves inside the live data root. Measured by a subprocess that imports the copy and prints every module-level name's value; the gate fails on any that resolves inside the live root. This is the behavioural half and it is what catches a root the literal rule could not rewrite (composed at runtime from __file__, an environment variable or argv). It is a value, not a source marker.

Q44's check 3 — the live held directory is unchanged by every leg in the run, censused on bytes, sha256, mtime_ns and ctime_ns before any leg and again at the foot — is carried verbatim, label included. ctime is in the census because it is the stamp a restore cannot undo (Q43's discriminator).


5. The five Q39 negatives, still refused

Each fixture run under both gates, HELM_COLLECTOR=<fixture>:

fixture Q45 gate Q44 gate live delta (Q45)
n1-incident-shape rc 2, stdout 0 B rc 2, stdout 0 B 0
n2-traceback rc 2, stdout 0 B rc 2, stdout 0 B 0
n3-decoy rc 2, stdout 0 B rc 2, stdout 0 B 0
n4-comment-only rc 2, stdout 0 B rc 2, stdout 0 B 0
n5-live-literal rc 2, stdout 575 B rc 2, stdout 0 B 0

n5 is the class change, and it is the point of the item. Q39 refused it (Q39 REFUSAL: the subject collector … names the live destination as an absolute literal … No leg is executed.). Q45 admits it, relocates the literal, and lets its legs run — the Q39 NOTE now reads 1 distinct site(s). Q39 REFUSED this class; Q45 redirects it, so the literal(s) become the redirect's inventory and the subject is admitted. Its rc is still 2 and its live delta still 0, so admitting the class costs nothing measurable; the 575 B of stdout is the legs running where before nothing ran.

Q39's admission machinery is otherwise untouched: the behavioural probe, the per-leg watch and rule (3) all still fire, and n1–n4 are refused exactly as before.


6. The discriminator

Two subjects, both the real collector with exactly one thing changed, built by make_subjects.py:

subject change bytes / lines sha256
subjects/collector-altroot.py HELM_HOME → _STACK_ROOT, whole word, 3 → 0 occurrences 30,833 / 736 fc51eb0986e5b25e139df75ba2d7478be24fa8518b33ae4df31995aaee5a43a6
subjects/collector-calltime.py pin_stackchain() composes its held directory at call time from a literal; the HELD_DIR constant stays, still composed from HELM_HOME, and is a decoy nothing on the write path reads 31,021 / 741 aa1d73d112cf1674f4a1550cae7c40a3986c0cfb4f47149b88ef7c2114b3681b

A synthetic fixture cannot be the gate's subject — the suite exercises the collector's whole interface, and a fixture that stubs it measures the stub. That was tried first and is logged in §8.

Three variants per subject, built by make_variants.py:

variant gate rule (3) redirect target watch
armed Q45 disarmed the per-invocation sandbox armed
disarmed Q45 disarmed the protected root disarmed
q44-blindspot Q44 disarmed n/a (Q44's own) disarmed

The watch is disarmed in the last two on purpose: a detect-and-restore watch restores the very write the discriminator measures, which is how a measurement ends up proving nothing. The measurement is the harness's own before/after census on (bytes, sha256, mtime_ns, ctime_ns).

All six rows, protected = the scratch root, censused around every invocation:

row rc passed held write lands in protected held live / live held
q45-armed-collector-altroot 0 169 passed, 0 failed <sandbox>/data/held/… 0 0 / 0
q45-armed-collector-calltime 0 169 passed, 0 failed <sandbox>/data/held/… 0 0 / 0
q45-disarmed-collector-altroot 1 165 passed, 4 failed <protected>/data/held/… NONZERO 0 / 0
q45-disarmed-collector-calltime 1 165 passed, 4 failed <protected>/data/held/… NONZERO 0 / 0
q45-q44blind-collector-altroot 1 167 passed, 2 failed <protected>/data/held/… NONZERO 0 / 0
q45-q44blind-collector-calltime 0 169 passed, 0 failed <protected>/data/held/… NONZERO 0 / 0

The Q45-PIN: / Q44-PIN: line in each row is where "the write landed" is read from, and both readings are on one line rather than two runs:

q45-armed-collector-altroot    Q45-PIN: rc=0 … pinned e40308e573d046d7… ->
  /root/crucible/q45/variants/helm-legs-oountx_s/data/held/stackchain-home.html.sha256
  … sandbox held=120 B / sha256 676fede2c29a381a… / mtime_ns 1791847039674987167
  … protected held=114 B / sha256 b58d2d0eb9f45862b… / mtime_ns 1791478687000000000

q45-disarmed-collector-altroot Q45-PIN: rc=0 … pinned e40308e573d046d7… ->
  /root/crucible/q45/protected/data/held/stackchain-home.html.sha256
  … sandbox held=absent; protected held=120 B / sha256 9b65871d6d8b44dd…
  / mtime_ns 1791847054218188403 / ctime_ns 1791847054222188733

Prevention separated from detection by measurement, on both shapes. Armed, the run is green (169 passed, 0 failed, VERDICT: ALL GATES PASS) and the only held write in the process tree is inside the run's own sandbox, with the protected root delta 0 on all four census fields. Disarmed, the same shape lands in the protected root with a nonzero mtime_ns and ctime_ns.

The sharpest row is q45-q44blind-collector-calltime: rc 0, 169 passed, 0 failed, VERDICT: ALL GATES PASS, and the held write lands in the protected root. The pre-Q45 gate reports green while the class writes outside the sandbox. That is the blind spot Q45 closes, stated by the gate's own verdict rather than by argument.

The two disarmed rows fail 4 checks and the two q44blind rows fail 2 and 0 respectively; those reds are the expected measurement of a disabled or absent mechanism, not defects. In q44blind-collector-calltime nothing fails at all, which is the finding.

Live state, delta 0 on all 21 rows. Snapshot 14,039 B / sha256 aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d / mtime_ns 1791479579475780303 / ctime_ns 1791789490146606356, before and after every invocation (live_delta != 0: none, live_held_delta != 0: none). The live root is never named by any subject or variant — run_negatives.py's guard() refuses one that does, and it fired during this build (§8).


7. Limits, stated rather than buried

  1. The redirect keys on literals, so a root with no literal is not covered. HELM_HOME = Path(__file__).resolve().parent, or any root read from an environment variable or argv, has nothing to relocate. Check 2 catches the consequence (a module constant resolving inside the live root fails the run), the per-leg watch catches an actual write after the fact, and rule (3) catches a leg that names the live path in argv. Prevention for that class is not claimed.
  2. The q44blind variant disarms Q39's literal refusal, rule (3) and the watch together, so its rows are not a statement about the shipped Q44 gate — they are a statement about Q44's redirect on a class it cannot see, with the three other guards moved out of the way. The shipped Q44 gate refuses the class outright (§5, q44-neg-n5-live-literal: rc 2, stdout 0 B).
  3. Check 1 is vacuous for a subject with no live literal. The inventory size is printed on every path so the vacuity is visible. No subject this gate runs is in that class, and the record does not claim a rate for subjects it has not seen.
  4. The redirect relocates, it does not sanitise. A subject that writes to a path derived from a relocated literal writes inside the sandbox, which is the intent; the sandbox is removed at exit and 0 paths were left behind by any green run.
  5. Byte counts, hashes and timings are dated readings (2026-10-12, ~23:00Z). The Q45-PIN: line's pin digest e40308e5… is the current live Stackchain body's normalised digest, which is a property of the remote page, not of this run.
  6. Not exercised: the timeout branch in _q45_rewrite's caller, and a multi-line path literal (recorded as uncovered with a reason rather than silently skipped). No path literal in any subject this gate runs spans lines.

8. This run's own defects, logged first

The incident: the first discriminator run wrote the LIVE held directory. Restored byte-exact; disclosed in full.

The subject's root literal was /root/.helm, while the variant's own live root was the scratch protected root. So the literal sat outside the gate's live root, was never in the redirect's inventory, and the copy the legs ran still named live. All six discriminator rows in that run re-wrote /root/.helm/data/held/stackchain-home.html.sha256 (live_held_delta: NONZERO on every row); the live snapshot was untouched (live_delta: 0 on every row).

Restored by restore_held.py from /root/crucible/q43z/backup/held.bak, which carries the exact original line. The restore is verified, not trusted: the backup's 120 bytes hash to 5b6d70716153c3f4bd82045d5d71edf88f793a240ce1275c3dfca5759d0ee297, which is the digest every prior tick recorded, and the script refuses to restore if it does not match. Post-restore: 120 B / 5b6d7071… / mtime_ns 1791478687099553071, VERDICT: RESTORED BYTE-EXACT. ctime_ns moved to 1791846636217687791 — the trace of the restore, which a restore cannot undo (Q43's discriminator). The bytes, size and mtime are exact; the inode's change time is not recoverable.

Three mechanical fixes followed, all in this tree:

  • make_subjects.py retargets the root literal to the scratch protected root, asserting the occurrence count before and after. This is not part of the class under test — it is the discipline Q43/Q44 already carry ("on a scratch protected path, never the live one"), which this run skipped.
  • run_negatives.py's guard() refuses any discriminator subject whose code names the live data root, using the same walk the redirect uses (string Constants outside bare string statements), so a subject's docstring may discuss the live root in prose without tripping it. It fired during this build and stopped the run.
  • run_negatives.py's abort_if_live_moved() stops the whole run the moment a live object moves, restoring the held pin from the backup first. Restoring alone is not enough — the run that caused it keeps going and causes it again.

The guard deliberately does not apply to the five negative-control fixtures, whose whole purpose is to name the live path; what is under test there is that the gate refuses them, and the backstop is the measured refusal plus abort_if_live_moved.

Four instrument defects, each found by its own measurement:

  1. The span list visited each AST node once per ancestor. _q39_iter_nodes yields a node and its children, so nesting ast.walk inside it reaches a Constant once per ancestor — harmless for a dict (Q39 dedupes with setdefault), fatal for a span list. The first rewrite produced Path('<sandbox>'le/<sandbox>'le/<sandbox>'le/<sandbox>') on the HELM_HOME line. Fixed with _q45_constants, a single-pass walk, used by both the inventory and the span list.
  2. The subject header swap stripped the original's first line to make room, and that line is the opening """ of the module docstring — so the docstring body came back unterminated, the closing """ opened a new string, and everything after it (an em-dash) was read as code. Fixed by prepending.
  3. make_subjects.py's truthiness and expectation were both wrong (ok &= bool(...) on a PosixPath; HELM_HOME expected 1 where the call-time subject keeps all 3). Both caught by the builder's own assertions.
  4. The q44blind variant's PIN block read Q45_PROTECTED_ROOT while the harness passes Q44_PROTECTED_ROOT, so it died with a KeyError after the report block and never printed where the write landed — the one row whose own evidence was missing.

One defect in the pre-Q45 gate, found here and not fixed here: the Q44 gate accesses _q39_tree.body with no None guard, so a subject that does not parse crashes it with AttributeError: 'NoneType' object has no attribute 'body' instead of refusing. My Q45 gate guards the same access (_q45_copy_tree.body if _q45_copy_tree is not None else []). No subject in this run fails to parse, so the defect is unexercised and is reported rather than repaired — Q44's gate is not mine to edit silently.

One cosmetic defect in Q39's scan, fixed where Q45 reads it: Q39's _q39_live_literals list appends once per ancestor yielded (the shape in defect 1), so the same literal site was printed 12 times for one site. Q39 got away with it because the list only fed a refusal message; Q45 turns it into a printed inventory, so it is deduped for display and the count is the distinct site count. Q39's own list is unchanged.


9. What this changes for the adoption ask

The §4 pair is now collector 4e45021d80395d3f… + gate 2bbca218871bc561… (superseding Q44's 18580c102ddb1c3f…). Q40's finding is unchanged and is the reason the cp matters: the deployed gate 7ed1bca8… reads HELM_COLLECTOR zero times (grep -c HELM_COLLECTOR /root/.helm/bin/helm-selftest.py → 0), so it has no subject-selection path at all and none of Q31–Q45's protection is in production until the pair lands.

Q7, measured: after this item the Helm lane's executable set is empty again. Q9 waits on the checksum handover, Q13 and Q19 on Alexander, Q42 is zephyrus-sparkborn's, and the adoption is the same human call. No new item opened. The one follow-on this run would name if the lane re-opens: check 2 catches a root the literal rule cannot rewrite, but only after the copy is built — resolving every path the subject declares at runtime, rather than every literal it declares statically, is the stronger form and is not built.

— Merlin