Files
troupe-standup/recoveries/helm-parse-guard.md
T
Merlin a6b1ae3434 Q46 closed: the parse guard
An unparseable subject is refused with exit 2 and a dated message naming the
parse failure, not admitted-then-crashed. Discriminator on all five gates,
digest-identical to the revision history: Q39 rc 1 SyntaxError at the legs
import of the original subject, Q43 rc 1 SyntaxError at the sandbox-copy
import, Q44 rc 1 AttributeError at the unguarded _q39_tree.body, Q45 rc 1
SyntaxError at the sandbox-copy import, Q46 rc 2 dated refusal with stdout 0
B and no sandbox created. dep-unparseable refused by Q39 repair 3 on all five
gates. C2 reproduced: the ROOT glob sees no leftover while the recursive
census sees legacy/q4X/helm-legs-*. Green pair 5x rc 0, 169 passed, 13,312 B,
live delta 0; atexit sandbox cleanup closes the n5 leak measured before and
after. Live snapshot aa842b0f 14,039 B and held pin 5b6d7071 delta 0 across
27 invocations. Adoption pair is now collector 4e45021d + gate ae69d5dd.
2026-10-13 01:38:23 +00:00

169 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> 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 anywhere in this check-in or in the files it lands.
# Q46 — the parse guard: an unparseable subject is refused, not crashed on
**Item:** Q46 (Helm lane), opened by the pipe 2026-10-12T23:4xZ on reply 29's C1 — the last open executable
item in the queue at the time this run started. **Done as specified:** a subject that does not parse is
refused with exit 2 and a dated message naming the parse failure; the four pre-Q46 gates measured on the
same fixture; the sandbox left by a crashed run either not created or removed; the `leftover()` census
extended past the ROOT glob (C2). Scratch tree `/root/crucible/q46/`; nothing deployed; no `cp` into
`/root/.helm/bin/`; `decisions/` and `inbox/` untouched; no another-party file edited; no secret printed.
## 1. The pair
- Subject collector **`4e45021d80395d3f03fc58d7e99610e3c1c0d2fcf9c85f1db50b16cfd2043c6c`** (720 lines /
29,954 B) — **byte-identical to Q45's**; every change is in the gate.
- Q45 gate `2bbca218871bc5613e1fe3c4d3a84fe2e6da05ffc526e51b2865a8c8ad00ee5e` (1,942 lines) — the legacy
copy in this tree (`legacy/q45/helm-selftest.py`) is digest-identical to `/root/crucible/q45/helm-selftest.py`,
so the discriminator runs the real Q45 gate, not an approximation of it.
- Q46 gate **`ae69d5dd735b6f3cfe82b3682b02acf4557b2939d104b9f15b81cffaeef016d4`** (2,032 lines /
109,643 B). `diff -u` against the Q45 gate (`out/DIFF-vs-q45.txt`, 113 lines) = **2 file headers + 3
hunk headers + 90 added + 0 removed + 18 context**, and **1,942 + 90 − 0 = 2,032** exactly. The
decomposition is counted by `grep -c` on the diff, not asserted: 91 `^+` minus the `+++` header = 90
added; 1 `^-` is the `---` header; 18 context.
## 2. What changed
**The parse guard** (gate lines 211–263, after Q35's source precondition, before the admission probe):
`compile()` the subject's own source; a `SyntaxError` is refused with **exit 2** and a **dated** message
naming the subject, the parse failure and its position, with the live snapshot read before and after and
the delta printed. The guard sits before the probe directory (`helm-q39-*`) and before the sandbox
(`helm-legs-*`) are created, so the refused class creates neither. It registers **no `check()`**, so the
green count is unchanged at **169 passed / 0 failed**.
**The atexit sandbox cleanup** (gate lines 694–710): an `atexit` handler removes the per-invocation
sandbox on **every** exit path — refusal, a propagated leg rc, or crash. Silent, so the green pair's
stdout/stderr shape is unchanged. This closes a leak this run measured, not hypothesised (§5).
**The class is split, and both halves are measured:**
- a subject whose **own source** does not parse is refused by the parse guard (exit 2, dated);
- a subject that parses but whose **imported dependency** does not parse raises **with a traceback**
and is already refused by Q39's repair 3 ("a crash is not a refusal") — measured on all five gates
(§4, the `dep-unparseable` rows). The parse guard is the gap between them, not a second layer over
repair 3.
## 3. The discriminator — two fixtures, five gates, ten invocations
Fixtures (this tree, `fixtures/`): `noparse-with-markers.py` (Q35 markers present, `def broken(:` at
line 13 — the pipe's C1 fixture, sha256 `7a8872f353419f4a…`), and `dep-unparseable.py` (parses, carries
the markers and a `SNAPSHOT_PATH` assignment, imports the unparseable `broken_dep.py`; sha256
`f621d4718ec83b19…` / `6b8c8a725c23dc07…`). Harness: `run_negatives.py` → `out/Q46-NEGATIVES.json`
(58,433 B / sha256 `028b6b1c68ab48cd3c7a5b1a4b698fbd6ada3a823232106d8ba44cb352fcd392`, 21 rows,
`generated_at 2026-10-13T01:31:42.240730+00:00`). Every row censuses the live snapshot and held
directory on (bytes, sha256, mtime_ns, ctime_ns) before and after; the leftover census is taken **two
ways** — the ROOT-anchored glob and a recursive `rglob`.
| row | rc | stdout | refusal / crash | leftover (recursive) | leftover (ROOT glob) | live delta |
|---|---|---|---|---|---|---|
| q46-dis-q39-noparse | **1** | 575 B | `Traceback… SyntaxError: invalid syntax` (legs import the ORIGINAL subject) | `[]` | `[]` | 0 |
| q46-dis-q43-noparse | **1** | 575 B | `Traceback… SyntaxError` (in-process import of the sandbox copy) | `legacy/q43/helm-legs-jkij8r1x` | `[]` | 0 |
| q46-dis-q44-noparse | **1** | 0 B | `AttributeError: 'NoneType' object has no attribute 'body'` (`_q39_tree.body`, gate `:482`) | `legacy/q44/helm-legs-8tkclld3` | `[]` | 0 |
| q46-dis-q45-noparse | **1** | 575 B | `Traceback… SyntaxError` (sandbox-copy import) | `legacy/q45/helm-legs-wgn7u3v2` | `[]` | 0 |
| q46-dis-q46-noparse | **2** | 0 B | **`Q46 REFUSAL: … does not parse - invalid syntax (noparse-with-markers.py, line 13) (line 13, offset 12) - refused at 2026-10-13T01:31:45.419033+00:00, before the admission probe, before any leg, and before any sandbox is created.`** | `[]` | `[]` | 0 |
| q46-dis-{q39,q43,q44,q45,q46}-dep-unparseable | **2** (all five) | 0 B | `Q39 REFUSAL: … a bare run against the sentinel exited rc=1 (stderr: Traceback …` — repair 3, dated live census in the message | `[]` | `[]` | 0 |
The four crashed rows are the defect the item names, reproduced on gates digest-identical to the
deployed-revision history (`9b09f328…`, `c22601ec…`, `18580c10…`, `2bbca218…`). The Q44 row's crash site
is the unguarded `_q39_tree.body` access the Q45 record reported as unexercised (§8(a)) — now exercised,
and it happens **after** the sandbox is created (`legacy/q44/helm-selftest.py:462` mkdtemp, `:482` the
crash), which is why a sandbox is left. The Q39 row leaves none because Q39's legs run the **original**
subject and crash before any sandbox exists — the pre-Q43 shape. **C2 is reproduced mechanically on the
crashed rows:** the ROOT-anchored glob reports `[]` while the recursive census reports the sandbox in the
gate's own directory (`legacy/q4X/`), because a gate creates its sandbox beside itself.
## 4. The green pair — five consecutive invocations, logs on disk
`run_green.py` → `out/Q46-GREEN.json` and `out/q46-green-{1..5}.{out,err}`:
| run | rc | passed | stdout | seconds | live delta | leftover |
|---|---|---|---|---|---|---|
| 1 | 0 | 169 | 13,312 B | 12.62 | 0 | `[]` |
| 2 | 0 | 169 | 13,312 B | 12.19 | 0 | `[]` |
| 3 | 0 | 169 | 13,312 B | 12.22 | 0 | `[]` |
| 4 | 0 | 169 | 13,312 B | 12.32 | 0 | `[]` |
| 5 | 0 | 169 | 13,312 B | 12.17 | 0 | `[]` |
Green log digests (stdout): `c02adfda1704eea0…` / `10972472d772db66…` / `51b42c964868bed8…` /
`389a04e41d2fd1fd…` / `deef0ded4a09ac77…` (harness run) — the `run_green.py` re-measurement writes the
same bodies to disk. **169 = 166 + 3, unchanged from Q45**: the parse guard registers no check, and the
atexit handler prints nothing, so the green pair's shape is byte-stable against Q45's.
## 5. The n5 sandbox leak — found by measurement, closed by the atexit handler
The five Q39 negative fixtures under the Q46 gate (regression control): **all five rc 2, live delta 0,
held delta 0, no leftover** (`q46-neg-n1…n5` rows). The `n5` row is the interesting one: n5 is
**admitted** (Q45 redirects its live literal), its legs run, and it ends rc 2 through the collector's own
destination rule (`refusing to write the live snapshot <sandbox>/data/snapshot.json without --deploy`,
`out/q46-noatexit-n5.err`) — and on the **pre-atexit** gate it **left its sandbox behind**.
- Pre-atexit gate (the Q46 gate minus the atexit block), sha256
`1fd889ca95c81910aead88c0aa9d445cb91ffd1caecc411958160ad3b04a4260`, run with n5: **rc 2**, leftover
**`work/helm-legs-3fancddl`** (`out/q46-noatexit-n5.out` / `.err`, the leak measured live).
- Post-atexit gate (the shipped `ae69d5dd…`), the same fixture in the harness: **rc 2, leftover `[]`**.
The leak is not the item's class (n5 parses), but the item's done criterion — "the sandbox a crashed run
leaves behind is either not created or removed by the refusal path" — generalises to every early exit,
and the measurement said the refusal path did not cover a propagated leg rc. The atexit handler is the
fix, and the before/after is measured on the same fixture, same gate, same host.
## 6. Live state — delta 0 across every invocation
Across all 21 harness rows, the 5 green runs and the noatexit run (`out/Q46-NEGATIVES.json`,
`out/Q46-GREEN.json`, re-measured after the runs):
- live snapshot `/root/.helm/data/snapshot.json`: **14,039 B / sha256
`aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d` / mtime
`2026-10-08 17:12:59.475780303 +0000`** (mtime_ns `1791479579475780303`, ctime_ns `1791789490146606356`
— the ctime is the Q45 restore trace, unchanged);
- live held pin `/root/.helm/data/held/stackchain-home.html.sha256`: **120 B / sha256
`5b6d70716153c3f4bd82045d5d71edf88f793a240ce1275c3dfca5759d0ee297`** / mtime_ns `1791478687099553071`,
ctime_ns `1791846636217687791` (the 2026-10-12T23:10:36Z restore trace).
Deployed bin untouched: `helm-collect.py` `ee0b36a82f23fcf3…`, `helm-selftest.py` `7ed1bca8f9da624f…`,
`helm-render.py` `5c67473d89283c3d…`. Nothing deployed, nothing published. Final tree state: **0
sandboxes** (`find /root/crucible/q46 -name 'helm-legs-*' -o -name 'helm-q39-*'` → 0), fixture
destination directories removed by `cleanup_final.py`. Host disk **46 GB free of 154 GB (71 % used)**
(`df -h /`, run time).
## 7. Limits, stated not buried
1. The guard is **static**: it compiles the subject's own source text. A `SyntaxError` inside code the
subject *generates* at runtime is out of its reach — and out of every static guard's; nothing in this
gate's path executes generated code.
2. The dependency class is **covered by repair 3, not by this guard** — measured on all five gates
(§3). If repair 3 were ever removed, the dependency class would crash again; the guard does not
subsume it.
3. `atexit` does not run on `os._exit` or `SIGKILL`. Coded-not-exercised; the pre-atexit leak is the
measured failure mode it closes, and both SIGKILL classes are outside the gate's control anyway.
4. The guard protects the gate's legs, not a direct CLI run of a pre-gate collector (the Q35 limit,
unchanged).
5. The discriminator's legacy gates run from copies in this tree; their sandboxes land beside the
copies, which is exactly C2's shape and is why the recursive census is the honest one.
## 8. Defects of mine, logged first
1. The first harness run crashed on a missing fixture path — the C1 fixture was at the tree root, not in
`fixtures/` (`FileNotFoundError`, `run_negatives.py:203`). Logged, moved, re-run.
2. The first harness wrote the green logs as **empty files** (the stdout lived only in the JSON).
Re-measured with `run_green.py`, which writes the real stdout and stderr per run (§4).
3. In this tree the Q44 crash is **0 B of stdout** where the pipe measured **4 B** on the same fixture —
a tree-and-clock difference (their fixture path is longer), recorded, not reconciled. The crash site,
the rc and the leftover agree.
4. The n5 sandbox leak (§5) was found by this run's own census, on a class the item does not name, and
fixed here rather than left as somebody else's finding.
## 9. §4 consequence and Q7, measured
**The adoption pair is now collector `4e45021d…` + gate `ae69d5dd…`** (superseding Q45's `2bbca218…`).
Q40's finding is unchanged and is why the `cp` matters: the deployed gate `7ed1bca8…` reads
`HELM_COLLECTOR` **zero** times, so none of Q31–Q46's protection is in production until the pair lands.
Alexander's call; nothing in this run touched `/root/.helm/bin/`.
**Q7:** after this item the Helm lane's executable set is empty again — the adoption `cp` is Alexander's,
Q9 waits on the checksum handover, Q13/Q19 are Alexander's, Q42 is zephyrus's, Q37 is the pipe's.
Merlin's own §9 follow-on from Q45 (resolve every path the subject declares **at runtime**, rather than
rewriting the literals it declares statically) remains open and unclaimed beside the adoption. **No new
item opened** — the next Helm unit is a decision, not a task.