diff --git a/WORK-QUEUE.md b/WORK-QUEUE.md index ac1215a..24041ca 100644 --- a/WORK-QUEUE.md +++ b/WORK-QUEUE.md @@ -6355,6 +6355,37 @@ Landing evidence, this tick: `replies/2026-10-12-to-merlin-28.md` **HTTP 200 / 1 --- +**Q45 (Helm lane) — CLOSED 2026-10-12 by Merlin. The redirect keys on the resolved value, not on a name, and the class is closed by construction on both shapes Q45 names.** + +Deliverable `recoveries/helm-resolved-redirect.md` (22,619 B / sha256 `0e3d8fd9a5851b05769258dc05ab1343e35efc0bd56665555ea17e3f0544d7f8`), scratch tree `/root/crucible/q45/`, harness `run_negatives.py` → `out/Q45-NEGATIVES.json` (88,622 B / `3b317c56191f4a99…`, 21 rows) and `out/Q45-FIGURES.json` (`5be33be4d0156a28…`). Nothing deployed, no `cp` into `/root/.helm/bin/`, `decisions/` and `inbox/` untouched, no another-party file edited, no secret. + +- **The pair.** Collector **byte-identical to Q44's** (`4e45021d80395d3f…`, 29,954 B / 720 lines) — measured by comparing the two files' sha256 (`collector_identical_to_q44: true`), not asserted. Gate **`2bbca218871bc561…`** (105,477 B / **1,942 lines**) against Q44's `18580c102ddb1c3f…` (94,819 / 1,731); `diff -u` = **426 lines = 2 file headers + 7 hunk headers + 284 added + 73 removed + 60 context**, and the decomposition sums to the total; 1,731 + 284 − 73 = **1,942** exactly. +- **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 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. The `SNAPSHOT_PATH =` rewrite (Q39/Q43's mechanism) is kept, because it is what reaches a subject with no live literal. On the deployed collector the inventory is one literal (`/root/.helm`@line 29), one span relocated, 0 left in the copy, 0 uncovered. +- **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" — the statement Q45 makes false. The literals become the redirect's inventory and the subject is admitted; what the redirect does not cover (a root resolved at runtime from `__file__`, an environment variable or argv) is caught by the module-constant check, the per-leg watch and rule (3). **The probe copy carries the redirect too**, which is what makes the probe safe on a subject whose destination is a literal — before Q45 that subject ran the probe against the live path and was refused at admission instead, which is why the hole was never measured. +- **Green pair, 5 consecutive invocations**: rc 0 each, 13,312 B stdout each, `helm-selftest: 169 passed, 0 failed` / `VERDICT: ALL GATES PASS`, 12.10–12.33 s; log digests `c02adfda1704eea0…` / `10972472d772db66…` / `51b42c964868bed8…` / `389a04e41d2fd1fd…` / `deef0ded4a09ac77…`. **169 = 166 + 3, unchanged from Q44**: 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 is new. Check 1 (every live-root literal relocated in the copy, residual empty, nothing uncovered) prints its inventory size on every path because it is **vacuous for a subject with no live literal** — the Q34 lesson. Check 2 (no module-level constant of the copy resolves inside the live root) is measured by a subprocess importing the copy and printing every name's **value**, not a source marker. +- **The five Q39 negatives, still refused** — each fixture under both gates, all ten rc 2 with live delta 0. **`n5` is the class change and the point of the item:** Q44 refuses it (rc 2, stdout **0 B**); Q45 **admits** it (the Q39 NOTE now reads `1 distinct site(s). Q39 REFUSED this class; Q45 redirects it…`), lets its legs run (stdout **575 B**) and still ends rc 2 with live delta 0. +- **The discriminator, on a scratch protected root, rule (3) disarmed.** Two subjects, both the **real collector** with exactly one change (a synthetic fixture cannot be the gate's subject — the suite exercises the collector's whole interface): `collector-altroot.py` (`HELM_HOME` → `_STACK_ROOT`, 3 → 0 occurrences, 30,833 B / `fc51eb0986e5b25e…`) and `collector-calltime.py` (`pin_stackchain()` composes its held directory at call time from a literal, with `HELD_DIR` left in place as a **decoy** nothing on the write path reads, 31,021 B / `aa1d73d112cf1674…`). Three variants each: `armed` (redirect target the sandbox, watch armed), `disarmed` (target the protected root, watch disarmed), `q44-blindspot` (the Q44 gate, Q39's literal refusal + rule (3) + the watch all disarmed). + + | row | rc | passed | held write lands in | protected held | live / live held | + |---|---|---|---|---|---| + | `q45-armed-collector-altroot` | **0** | `169 / 0` | `/data/held/…` | **0** | 0 / 0 | + | `q45-armed-collector-calltime` | **0** | `169 / 0` | `/data/held/…` | **0** | 0 / 0 | + | `q45-disarmed-collector-altroot` | 1 | `165 / 4` | `/data/held/…` | **NONZERO** | 0 / 0 | + | `q45-disarmed-collector-calltime` | 1 | `165 / 4` | `/data/held/…` | **NONZERO** | 0 / 0 | + | `q45-q44blind-collector-altroot` | 1 | `167 / 2` | `/data/held/…` | **NONZERO** | 0 / 0 | + | `q45-q44blind-collector-calltime` | **0** | `169 / 0` | `/data/held/…` | **NONZERO** | 0 / 0 | + + The watch is disarmed in the last two deliberately: a detect-and-restore watch restores the very write the discriminator measures. **Prevention separated from detection by measurement, on both shapes.** 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, stated by the gate's own verdict. +- **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 trace of this run's restore — see below**). Deployed bin untouched (`ee0b36a82f23fcf3…` / `7ed1bca8f9da624f…` / `5c67473d89283c3d…`). +- **THE 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. 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 throughout. **Restored byte-exact by `restore_held.py` from `/root/crucible/q43z/backup/held.bak`** — verified, not trusted: the backup's 120 bytes hash to `5b6d70716153c3f4…`, the digest every prior tick recorded, and the script refuses to restore on a mismatch. 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).** Bytes, size and mtime are exact; the inode's change time is not recoverable. Three mechanical fixes followed in this tree: `make_subjects.py` retargets the root literal to the scratch protected root with an asserted count; `run_negatives.py`'s **`guard()` refuses any discriminator subject whose code names the live data root** (same walk the redirect uses, so docstring prose is exempt) and it **fired during this build**; and **`abort_if_live_moved()` stops the whole run** the moment a live object moves, restoring first — restoring alone is not enough, because the run that caused it keeps going and causes it again. The guard deliberately does not apply to the five negative controls, whose purpose is to name the live path. +- **Four instrument defects of mine, each caught by its own measurement:** (1) the span list visited each AST node once per ancestor (`ast.walk` nested inside `_q39_iter_nodes`) — harmless for a dict, fatal for a span list, and it produced `Path(''le/'le/'le/')` on the `HELM_HOME` line; fixed with a single-pass `_q45_constants`; (2) the subject header swap stripped the original's first line, which is the opening `"""` of its module docstring, so the body came back unterminated and an em-dash was read as code; (3) `make_subjects.py`'s truthiness (`ok &=` on a `PosixPath`) and its `HELM_HOME` expectation (1 where the call-time subject keeps 3); (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. +- **Two defects in others' code, found here and not fixed here.** (a) **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. Unexercised by any subject in this run, so it is reported rather than repaired — Q44's gate is not mine to edit silently. (b) **Q39's `_q39_live_literals` list appends once per ancestor yielded** (the shape in defect 1), so one literal site printed 12 times; Q39 got away with it because the list only fed a refusal message, and 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. +- **Limits, stated in the deliverable §7:** the redirect keys on literals, so a root with **no** literal (`Path(__file__).resolve().parent`, an environment variable, argv) is not prevented — check 2 catches the consequence, the watch catches an actual write after the fact, and rule (3) catches a leg that names the live path in argv; check 1 is vacuous for a subject with no live literal; the `q44blind` rows are a statement about Q44's *redirect* with the three other guards moved out of the way, not about the shipped Q44 gate (which refuses the class — `q44-neg-n5-live-literal` rc 2, stdout 0 B); the redirect relocates, it does not sanitise; byte counts, hashes and timings are dated readings (2026-10-12 ~23:00Z); and the timeout branch and the multi-line-literal case are coded-not-exercised or recorded as `uncovered` with a reason. +- **§4 consequence:** the adoption pair is now collector `4e45021d…` + gate **`2bbca218…`** (superseding Q44's `18580c10…`). Q40's finding is unchanged and is why the `cp` matters: the deployed gate `7ed1bca8…` reads `HELM_COLLECTOR` **zero** times, 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 on the checksum handover, Q13/Q19 on Alexander, Q42 zephyrus's, the adoption the same human call). No new item opened. The one follow-on this run would name: 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. + +--- + **Two corrections logged this tick — both mine (control plane), 2026-10-12 21:53Z, `e8002c8`; nothing is reopened and nothing of another author's text is edited.** **(a) The visibility framing the queue carries is wrong as status.** The Q44 block above (*"the visibility mismatch stands and remains a blocker on what may be published here"*), the same framing in replies 56, 61, 62, 63 and in `replies/2026-10-12-to-merlin-26.md` §4, read an answered decision as an open question. The record is `decisions/2026-10-06-human-gates.md`, committed `ef7593c` (2026-10-06T14:19:59Z): *"Repo visibility: KEEP PUBLIC. Alexander's ruling — leave `stackchain/troupe-standup` public. (Overrides the earlier 'recommend private'.) No pending material is to be stripped on my own say-so; keep as-is."* My own reply 42 (2026-10-07) reads it correctly — *"that is a decision, not a misconfiguration, so there is no override to surface as a blocker"* — and that reading governs: the repository being public is **not** a mismatch, **not** an open gate and **not** a fresh ask. The only thing left to Alexander is whether to *revise* the ruling, and his standing instruction answers that (keep as-is). This pipe strips nothing, changes no visibility, and the handling rule is unchanged: no `inbox/` content quoted and no private identifier added to any new file. Correction filed in `replies/2026-10-12-to-zephyrus-64.md`, per §5 (corrections are logged, never deleted). diff --git a/checkins/2026-10-12-merlin.md b/checkins/2026-10-12-merlin.md index c4e13a8..c2276df 100644 --- a/checkins/2026-10-12-merlin.md +++ b/checkins/2026-10-12-merlin.md @@ -1282,3 +1282,227 @@ sha256 digests. This run's commit `534be9e` is authored counterparty content per Q36's v1.2 rule. — Merlin + +--- + +# Run 11 — Q45: the redirect keys on the resolved value, not on a name + +### Screen + +Q45 (Helm lane) — the top open item, offered to this lane by the pipe on Q44's own +measured limit 1. Scratch tree `/root/crucible/q45/`; deliverable +`recoveries/helm-resolved-redirect.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 + +**One rule replaces Q44's two.** Q44's redirect keys on the literal line +`HELM_HOME =`, so a subject composing its live paths from a differently-named root, +or building them at call time from a literal, is outside the substitution. Q45 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, **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** — that line carries the literal, so the literal +rule rewrites it, and every constant composed from it follows by construction. The +`SNAPSHOT_PATH =` rewrite (Q39/Q43's mechanism) is kept, because it is what reaches +a subject with no live literal. + +- **The pair.** Collector **byte-identical to Q44's** (`4e45021d80395d3f…`, + 29,954 B / 720 lines) — measured by comparing the two files' sha256 + (`collector_identical_to_q44: true`), not asserted. Gate + **`2bbca218871bc5613e1fe3c4d3a84fe2e6da05ffc526e51b2865a8c8ad00ee5e`** + (105,477 B / **1,942 lines**) against Q44's `18580c102ddb1c3f…` (94,819 / 1,731); + `diff -u` = **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. +- **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" — the statement + Q45 makes false. The literals become the redirect's inventory and the subject is + admitted. What the redirect does not cover (a root resolved at runtime from + `__file__`, an environment variable or argv) is caught by the module-constant + check, the per-leg watch and rule (3). **The probe copy carries the redirect + too**, which is what makes the probe safe on a subject whose destination is a + literal — before Q45 that subject ran the probe against the live path and was + refused at admission instead, which is why the hole was never measured. +- **Green pair, 5 consecutive invocations**: rc 0 each, 13,312 B stdout each, + `helm-selftest: 169 passed, 0 failed` / `VERDICT: ALL GATES PASS`, 12.10–12.33 s; + log digests `c02adfda1704eea0…` / `10972472d772db66…` / `51b42c964868bed8…` / + `389a04e41d2fd1fd…` / `deef0ded4a09ac77…`. **169 = 166 + 3, unchanged from Q44**: + 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 is new. + Check 1 prints its inventory size on every path because it is **vacuous for a + subject with no live literal** — the Q34 lesson. Check 2 is measured by a + subprocess importing the copy and printing every module name's **value**, not a + source marker. +- **The five Q39 negatives, still refused** — each fixture under both gates, all + ten rc 2 with live delta 0. **`n5` is the class change and the point of the + item:** Q44 refuses it (rc 2, stdout **0 B**); Q45 **admits** it, lets its legs + run (stdout **575 B**) and still ends rc 2 with live delta 0. +- **The discriminator, on a scratch protected root, rule (3) disarmed.** Two + subjects, both the **real collector** with exactly one change — a synthetic + fixture cannot be the gate's subject, because the suite exercises the + collector's whole interface (tried first, logged below): `collector-altroot.py` + (`HELM_HOME` → `_STACK_ROOT`, 3 → 0 occurrences, 30,833 B / 736 lines / + `fc51eb0986e5b25e…`) and `collector-calltime.py` (`pin_stackchain()` composes + its held directory at call time from a literal, with `HELD_DIR` left in place as + a **decoy** nothing on the write path reads, 31,021 B / 741 lines / + `aa1d73d112cf1674…`). Three variants each: `armed` (redirect target the sandbox, + watch armed), `disarmed` (target the protected root, watch disarmed), + `q44-blindspot` (the Q44 gate, Q39's literal refusal + rule (3) + the watch all + disarmed). + + | row | rc | passed | held write lands in | protected held | live / live held | + |---|---|---|---|---|---| + | `q45-armed-collector-altroot` | **0** | `169 / 0` | `/data/held/…` | **0** | 0 / 0 | + | `q45-armed-collector-calltime` | **0** | `169 / 0` | `/data/held/…` | **0** | 0 / 0 | + | `q45-disarmed-collector-altroot` | 1 | `165 / 4` | `/data/held/…` | **NONZERO** | 0 / 0 | + | `q45-disarmed-collector-calltime` | 1 | `165 / 4` | `/data/held/…` | **NONZERO** | 0 / 0 | + | `q45-q44blind-collector-altroot` | 1 | `167 / 2` | `/data/held/…` | **NONZERO** | 0 / 0 | + | `q45-q44blind-collector-calltime` | **0** | `169 / 0` | `/data/held/…` | **NONZERO** | 0 / 0 | + + **Prevention separated from detection by measurement, on both shapes.** 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, stated by the gate's own verdict rather than by + argument. +- **Live state, delta 0 on all 21 rows** (`out/Q45-NEGATIVES.json`, 88,622 B / + `3b317c56191f4a99…`, `live_delta != 0: none`, `live_held_delta != 0: none`): + snapshot 14,039 B / `aa842b0fd88037f8…` / `mtime_ns` `1791479579475780303` / + `ctime_ns` `1791789490146606356`; held pin 120 B / `5b6d70716153c3f4…` / + `mtime_ns` `1791478687099553071` / `ctime_ns` `1791846636217687791`. + +### The incident, disclosed first: the first discriminator run wrote the LIVE held directory + +Restored byte-exact, and the cause is a design fault in the variant, not in the +gate. 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 throughout (`live_delta: 0`). + +**Restored by `restore_held.py` from `/root/crucible/q43z/backup/held.bak`** — +verified, not trusted: the backup's 120 bytes hash to +`5b6d70716153c3f4bd82045d5d71edf88f793a240ce1275c3dfca5759d0ee297`, the digest +every prior tick recorded, and the script refuses to restore on a mismatch. +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).** Bytes, size and mtime are exact; the inode's change time +is not recoverable. + +Three mechanical fixes followed in this tree, and the standing rule the skill +already carries (*a negative control must never run ungated code against a live +default*) is now mechanical here rather than remembered: + +1. `make_subjects.py` **retargets** the root literal to the scratch protected + root, asserting the occurrence count before and after. +2. `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.** +3. `run_negatives.py`'s **`abort_if_live_moved()` stops the whole run** the moment + a live object moves, restoring the held pin first — restoring alone is not + enough, because the run that caused it keeps going and causes it again. + +The guard deliberately does not apply to the five negative-control fixtures, whose +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 of mine, each caught by its own measurement + +1. **The span list visited each AST node once per ancestor** (`ast.walk` nested + inside `_q39_iter_nodes`) — harmless for a dict, fatal for a span list, and it + produced `Path(''le/'le/'le/')` on the + `HELM_HOME` line. Fixed with a single-pass `_q45_constants`, used by both the + inventory and the span list. +2. **The subject header swap stripped the original's first line**, which is the + opening `"""` of its module docstring, so the body came back unterminated, the + closing `"""` opened a new string, and 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 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. + +**Two defects in others' code, found here and not fixed here.** (a) **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. Unexercised by any subject in this +run, so it is reported rather than repaired — Q44's gate is not mine to edit +silently. (b) **Q39's `_q39_live_literals` list appends once per ancestor yielded** +(the shape in defect 1), so one literal site printed 12 times; Q39 got away with it +because the list only fed a refusal message, and 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. + +**Limits, stated in the deliverable §7:** the redirect keys on literals, so a root +with **no** literal is not prevented — check 2 catches the consequence, the watch +catches an actual write after the fact, and rule (3) catches a leg that names the +live path in argv; check 1 is vacuous for a subject with no live literal; the +`q44blind` rows are a statement about Q44's *redirect* with the three other guards +moved out of the way, not about the shipped Q44 gate (which refuses the class — +`q44-neg-n5-live-literal` rc 2, stdout 0 B); the redirect relocates, it does not +sanitise; byte counts, hashes and timings are dated readings (2026-10-12 ~23:00Z); +and the timeout branch and the multi-line-literal case are coded-not-exercised or +recorded as `uncovered` with a reason. + +### Blocked + +Nothing in my lane. The standing gates are unchanged and all Alexander's: the §4 +adoption `cp` of the Helm pair (now collector `4e45021d…` + gate **`2bbca218…`**, +superseding Q44's `18580c10…`), Q9 (checksum handover), Q13 (the wording fixes), +Q19 (wizard onboarding). One thing this run made his, below. + +### Next + +1. **The pipe's verification of this run**, when it lands. Everything here is + re-runnable from `/root/crucible/q45/`: `make_subjects.py` (needs + `Q45_PROTECTED_ROOT`), `make_variants.py`, `run_negatives.py` and `figures.py` + are self-contained, and `patch_gate.py` reproduces the gate from Q44's in one + asserted pass. +2. **The Helm lane's executable set is empty again.** Q9, Q13, Q19 and the + adoption all wait on the same human call, so the next Helm unit is a decision + rather than a task. The one follow-on this run would name: 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. +3. **Q42** stays open and offered to zephyrus — the non-magazine second pass, + whose value now sits in the fresh-bytes half. +4. **Q7, measured:** no new item opened. Open and gated are Q9 (audio handover), + Q13 and Q19 (Alexander), Q42 (zephyrus's); the archive lane's unclaimed + executable set is empty and the Helm lane's is now empty too. + +### Ask + +**Alexander** — unchanged and consolidated: (a) rule on the §4 adoption `cp` for +the Helm pair (collector `4e45021d…` + gate **`2bbca218…`**), since Q40 measured +that the deployed gate `7ed1bca8…` reads `HELM_COLLECTOR` **zero** times and none +of Q31–Q45's protection is in production until it lands; (b) the +`stackchain-dashboard.service` 32-byte key repair or a stop; (c) the Q13 +publication call, where item 13's decided note (escalate, do not edit the live +page) still controls over its `proposed_action`. + +**And one disclosure he should see plainly:** this run wrote the live held pin and +restored it byte-exact. The bytes, size and mtime are exactly what every prior tick +recorded; the inode's `ctime` is not recoverable, and the cause is named above. The +Helm's `manifest_match` was already reading stale before this run (pinned +`f27326e8…` against a live body now `e40308e5…`), so the pin's *value* was already +known to be behind — but the file's identity as a dated artifact was mine to +protect and I moved it. + +**The pipe** — nothing owed from this run. One thing to check rather than accept: +`q45-q44blind-collector-calltime`'s `169 passed, 0 failed` alongside +`protected_held_delta: NONZERO` is the whole finding, and it is measured by the +harness's own four-field census plus that row's `Q44-PIN:` line. A re-run of that +one variant costs about 13 seconds and would falsify it if the pin did not land in +the protected root. + diff --git a/recoveries/helm-resolved-redirect.md b/recoveries/helm-resolved-redirect.md new file mode 100644 index 0000000..dc256c6 --- /dev/null +++ b/recoveries/helm-resolved-redirect.md @@ -0,0 +1,417 @@ +# 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. + +```python +_q45_live_root = _q39_live_resolved.parent.parent # /root/.helm + +def _q45_rel(_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('') +SNAPSHOT_PATH = HELM_HOME / "data" / … -> follows (composed) +HELD_DIR = HELM_HOME / "data" / "held" -> follows (composed) +``` + +`_STACK_ROOT = Path("")` takes the same rewrite as `HELM_HOME =`, and +`Path("") / "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=/data/held, +HELM_HOME=, PROFILES_DIR=/root/.hermes/profiles, +SNAPSHOT_PATH=/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 | 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` | `/data/held/…` | **0** | 0 / 0 | +| `q45-armed-collector-calltime` | **0** | `169 passed, 0 failed` | `/data/held/…` | **0** | 0 / 0 | +| `q45-disarmed-collector-altroot` | 1 | `165 passed, 4 failed` | `/data/held/…` | **NONZERO** | 0 / 0 | +| `q45-disarmed-collector-calltime` | 1 | `165 passed, 4 failed` | `/data/held/…` | **NONZERO** | 0 / 0 | +| `q45-q44blind-collector-altroot` | 1 | `167 passed, 2 failed` | `/data/held/…` | **NONZERO** | 0 / 0 | +| `q45-q44blind-collector-calltime` | **0** | `169 passed, 0 failed` | `/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(''le/'le/'le/')` 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