Files
troupe-standup/recoveries/helm-read-path-guard.md
Merlin 8cb941fc26 Q47 CLOSED (Merlin): the read path — a subject the interpreter can run neither crashes the gate nor is falsely refused
The subject's source is now read the way CPython reads it: bytes through
tokenize.detect_encoding (BOM stripped, PEP 263 cookie honoured), with a
dated exit-2 refusal for a file that cannot be read that way, at Q46's
guard point — after Q35's precondition, before the admission probe,
before any leg, before any sandbox. All four subject-source reads route
through the one reader; one stderr NOTE when the encoding is not plain
utf-8; the Q46 parse guard, the Q45 redirect, every check() and the 169
count untouched.

Deliverable recoveries/helm-read-path-guard.md (12,821 B), scratch tree
/root/crucible/q47/, evidence out/Q47-NEGATIVES.json (21 rows),
out/Q47-VERDICT.json, out/Q47-GREEN.json, out/DIFF-vs-q46.txt.

Measured, not asserted: latin-1 cookie subject rc 0 / 169 passed / ALL
GATES PASS under the shipped gate (Q39-Q45 crash with UnicodeDecodeError);
BOM'd collector rc 0 / 169 passed (Q43/Q45 SyntaxError at the sandbox
copy, Q44 AttributeError, sandboxes left); genuinely undecodable subject
rc 2 with the dated Q47 REFUSAL and no sandbox. Q39 runs the BOM subject
green with 164 passed — the read-path class is the text-compiling gates
(Q43-Q46), not subject handling generally. Green pair 5x rc 0, 169/0,
13,312 B stdout / 425 B stderr, live and held delta 0 on all 26
invocations. Adoption pair moves to collector 4e45021d + gate 76f2292b.

Nothing deployed, no cp into /root/.helm/bin/, decisions/ and inbox/
untouched, no another-party file edited, no secret. Run 13 in the
check-in; queue block appended.
2026-10-13 03:53:48 +00:00

162 lines
13 KiB
Markdown
Raw Permalink 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.
# Q47 — the read path: a subject the interpreter can run must neither crash the gate nor be falsely refused
Merlin, 2026-10-13. Helm lane. Scratch tree `/root/crucible/q47/`; nothing deployed, no `cp` into
`/root/.helm/bin/`, `decisions/` and `inbox/` untouched, no another-party file edited, no secret.
Item opened by the pipe on reply 30's C1 (2026-10-13 01:58Z), measured there on the shipped Q46 gate.
**The defect.** Two directions, one cause. (a) A subject with `# -*- coding: latin-1 -*-` and one
latin-1 byte — bytes-compile OK, `python3 <fixture>` rc 0, so the **interpreter runs it** — crashed
the gate at `COLLECTOR.read_text()`: UTF-8 decode raises `UnicodeDecodeError`, rc 1, a traceback, no
dated refusal. (b) The real collector with a UTF-8 BOM — bytes-compile OK, because CPython strips a
BOM when it reads a source file — was **falsely refused** by the Q46 guard: the decoded text still
carries U+FEFF and `compile()` calls it an invalid non-printable character. The cause in both cases:
the gate reads a decoded `str`, the interpreter compiles the file's **bytes**.
## 1. The pair
Collector **byte-identical to Q46's** (`4e45021d80395d3f…`, 720 lines / 29,954 B) — measured by
sha256, not asserted. Every change is in the gate.
| | Q46 | Q47 |
|---|---|---|
| sha256 | `ae69d5dd735b6f3cfe82b3682b02acf4557b2939d104b9f15b81cffaeef016d4` | `76f2292b9a6cad08505956787cf05e65e3edaf7e0f23f73f4aed2e59a62866c1` |
| bytes | 110,509 | 115,182 |
| lines | 2,032 | 2,124 |
`out/DIFF-vs-q46.txt` (fresh `diff -u`): **130 lines = 2 file headers + 4 hunk headers + 96 added +
4 removed + 24 context**, and 2,032 + 96 − 4 = **2,124** exactly. The decomposition convention is
the lane rule: added = `^+` lines minus the `+++` header, removed = `^-` minus `---`.
## 2. What changed — one reader, four read sites, one refusal
1. **`_q47_subject_text(path)`** reads the subject the way CPython reads a source file: the file's
**bytes** through `tokenize.detect_encoding` (BOM first, then the PEP 263 cookie, then utf-8),
decoding with `utf-8-sig` when a BOM is present so the BOM is stripped exactly as the
interpreter strips it. A non-file path returns `("", "utf-8")`, the shape Q35's marker check
expects. An unreadable cookie or bytes that do not decode raise `_Q47ReadError`.
2. The **dated exit-2 refusal** sits at the same point as Q46's guard — after Q35's source
precondition, before the admission probe, before any leg, before any sandbox is created — with
the live snapshot read before and after and the delta printed. A file that cannot be read the
CPython way cannot be run by the interpreter either, so refusal is the honest shape.
3. **All four subject-source reads are routed through the one reader**: the Q35 precondition's
`_q35_src` (line 210), the admission's `_q39_src_text` (was line 361), the G8 source read (was
1704), and the G8 source-guard check (was 1971). No `read_text()` on the subject survives.
4. One **`Q47 NOTE` on stderr, never stdout, and only when the subject's encoding is not plain
utf-8** — so a green pair's stdout shape is unchanged and the positive side of a read-path fix
is visible rather than inferred (the Q38 precedent).
5. The **Q46 parse guard, the Q45 redirect, every `check()` and the suite count are untouched**: the
guard compiles the same `_q35_src` it always did — it now compiles what the interpreter would
compile. Green stays **169 passed / 0 failed**; the guard registers no check.
## 3. The fixtures, and the interpreter's own verdict (measured, not assumed)
`make_fixtures.py` builds the three from the **real collector** (digest asserted against
`4e45021d…`), each construction asserting its own counts:
| fixture | construction | `python3 <fixture> --skip-tests` (the ground truth) |
|---|---|---|
| `collector-latin1.py` (30,028 B / `6ce8bf90292d83f1…`) | cookie on line 1, one `0xe9` in a trailing comment | **rc 2** — the destination rule refuses; the file **runs** |
| `collector-bom.py` (29,957 B / `d14906c868e24997…`) | UTF-8 BOM prepended, no cookie | **rc 2** — the destination rule refuses; the file **runs** |
| `collector-undecodable.py` (30,006 B / `47fc3580fdfe8308…`) | one `0xe9` in a trailing comment, **no** cookie | **rc 1** — `SyntaxError: Non-UTF-8 code starting with '\xe9'` |
## 4. The discriminator — fifteen invocations, five gates
`run_negatives_q47.py` → `out/Q47-NEGATIVES.json` (57,247 B, 21 rows, `generated_at
2026-10-13T03:46:55.002706+00:00`); verdict in `out/Q47-VERDICT.json` (recomputed from the rows by
`recompute_verdict.py` — see §7 defect 1). Legacy gates digest-verified against the Q46 record
(`9b09f328…`, `c22601ec…`, `18580c10…`, `2bbca218…`).
| fixture | Q39 | Q43 | Q44 | Q45 | **Q47 (shipped)** |
|---|---|---|---|---|---|
| `collector-latin1.py` | rc 1 — `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 30026` (0.16 s, no sandbox) | rc 1 — same (0.13 s) | rc 1 — same (0.15 s) | rc 1 — same (0.14 s) | **rc 0, 169 passed / 0 failed, `VERDICT: ALL GATES PASS`, 13,312 B stdout, 12.44 s**, `Q47 NOTE … (iso-8859-1)`, no leftover |
| `collector-bom.py` | **rc 0, 164 passed, ALL GATES PASS, 11,398 B, 10.12 s** — Q39 runs the BOM subject **green** (see §6) | rc 1 — `SyntaxError: invalid non-printable character U+FEFF` at the sandbox copy's import (`\ufeff"""The Helm — P0 collector.`), 575 B, **`legacy/q43/helm-legs-_sja2yko` left** (0.61 s) | rc 1 — `AttributeError: 'NoneType' object has no attribute 'body'` (`_q39_tree.body` on a BOM'd parse), 0 B, **`legacy/q44/helm-legs-sl1v6yx5` left** (0.22 s) | rc 1 — `SyntaxError: … U+FEFF` at the sandbox copy, 575 B, **`legacy/q45/helm-legs-8qmmr11u` left** (0.66 s) | **rc 0, 169 passed / 0 failed, ALL GATES PASS, 13,312 B, 12.22 s**, `Q47 NOTE … (utf-8-sig)`, no leftover |
| `collector-undecodable.py` | rc 1 — `UnicodeDecodeError … position 30004` (0.14 s) | rc 1 — same (0.14 s) | rc 1 — same (0.19 s) | rc 1 — same (0.14 s) | **rc 2, stdout 0 B (`e3b0c44298fc1c149…`), dated `Q47 REFUSAL: … cannot be read the way CPython reads a source file - 'utf-8' codec can't decode byte 0xe9 in position 30004: invalid continuation byte - refused at 2026-10-13T03:47:32.685519+00:00, before the admission probe, before any leg, and before any sandbox is created.`** — no traceback, no leftover (0.13 s) |
The item's four done-criteria, as measured: the latin-1 subject **proceeds past the guard and runs
the whole suite green**; the BOM subject is **not refused** and runs green; a genuinely undecodable
subject is a **dated exit-2 refusal**; the four pre-Q46 gates' rcs are measured on both fixtures
above (rc 1 crashes — frozen controls, not patched); **green still 169 passed / 0 failed**; **no
sandbox left**. `live_delta` = 0 and `live_held_delta` = 0 on **all 21 rows**.
## 5. Regression controls, all still refused
- `q47-q46reg-noparse`: the Q46 class re-measured under the Q47 gate — **rc 2**, the dated
`Q46 REFUSAL` unchanged (`invalid syntax (noparse-with-markers.py, line 13) (line 13, offset 12)`).
The read-path fix did not loosen the parse guard.
- `q47-neg-n1…n5`: all five Q39 negative fixtures **rc 2**, live delta 0 — the admission machinery
is untouched. (n5's refusal line is the Q39 NOTE plus the leg's own
`refusing to write the live snapshot … without --deploy`, rc 2, 575 B.)
## 6. The green pair, and the stdout identity
`run_green_q47.py` → `out/Q47-GREEN.json` (2,725 B), logs `out/q47-green-{1..5}.{out,err}`: five
consecutive **rc 0**, **169 passed / 0 failed**, `VERDICT: ALL GATES PASS`, **13,312 B stdout /
425 B stderr** each, 12.03–12.58 s, live delta 0, held delta 0, no leftover. Green stdout digests:
`21920260cade855a…` / `580021754ed55d3d…` / `49025d65cc3082e5…` / `c8155f076d214d24…` /
`1d2c7d69a8531d39…`.
`out/q47-green-1.out` vs `out/../q46/out/q46-green-1.out`: **184 lines each, exactly 4 differ**, and
all four are the randomized names — line 1 (`/tmp/tmpkhag_ezo/` vs `/tmp/tmpl46pgy2a/`), line 10
(the `q31-g8-deploy` temp dir) and lines 183–184 (the two sandbox-path lines, which name the tree).
So the Q47 gate's green output is **byte-identical to Q46's but for the random names** — the read
path changed nothing the suite prints.
**The Q39-bom row is a finding, not noise.** The oldest gate runs the BOM subject **green with 164
passed** while Q43/Q44/Q45 crash on it. The mechanism is visible in its own report:
`parse line -: the subject does not parse: invalid non-printable character U+FEFF` — Q39 records the
parse failure and continues, because **Q39 never compiles the subject's text**; it only imports the
file (where CPython strips the BOM) and runs the probe copy as a subprocess (whose SyntaxError the
Q46-era crash detector cannot see). The read-path class is therefore a property of the gates that
compile the subject's **text** — Q43/Q45's sandbox-copy import, Q44's `ast` — not of subject
handling generally. The BOM subject is runnable by the interpreter and green on the oldest gate; it
is Q43–Q46's text compilation that made it dangerous.
## 7. Limits and defects — mine first, logged not deleted
1. **Harness verdict bug (mine).** `row_for()` was passed `READPATH_FIXTURES` (which carry `.py`)
while the discriminator loop names rows with the suffix stripped, so the verdict's generator
raised `StopIteration` → `RuntimeError` **after** the rows JSON was written. The rows are all on
disk; the verdict was recomputed from them by `recompute_verdict.py`, and `run_negatives_q47.py`
is patched (the strip) so the file on disk is right. No gate invocation was re-run.
2. **Vacuous fixture-dest census (mine).** The negative fixtures' destination literals still name
the **q46** tree — `cp -a` inherited fixtures already retargeted by q46's `setup_fixtures.py` —
so this run's `fixture_dest_census()` (which watches `q47/fixtures`) was empty on every row.
Evidence no cross-tree write happened: `ls /root/crucible/q46/fixtures` shows **no**
`n1-dest…n5-dest`/`dep-dest` directories, and each negative row's stderr shows the leg refusing
its own destination. The right fix was to re-run `setup_fixtures.py` retargeted into
`q47/fixtures`.
3. **The copies are UTF-8 by construction (recorded limit).** The probe copy and the legs' sandbox
copy prepend their own header lines, which push the subject's cookie past PEP 263's line-2
limit, so a cookie subject's non-ASCII bytes in **executable strings** would be re-encoded as
UTF-8 in the copies. The fixture therefore carries its single latin-1 byte in a comment only. A
copy faithful in the subject's own encoding would need the cookie hoisted — not built.
4. **The guard is static** (carried from Q46): runtime-generated code, or a source produced by an
import hook, is out of every static guard's reach.
5. `tokenize.detect_encoding` reads the first two lines only — **faithful**, not a limit: CPython
does not honour a cookie past line 2 either.
6. Byte counts, hashes and timings are dated readings (2026-10-13 ~03:30–04:00Z).
## 8. Live state, and the §4 consequence
Censused on (bytes, sha256, mtime_ns, ctime_ns) before and after **all 26 invocations** (21 harness
rows + 5 green runs), delta 0 on every field: snapshot `/root/.helm/data/snapshot.json` **14,039 B /
`aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d`** / mtime_ns
`1791479579475780303` / ctime_ns `1791789490146606356`; held pin
`/root/.helm/data/held/stackchain-home.html.sha256` **120 B / `5b6d70716153c3f4…`** / mtime_ns
`1791478687099553071` / ctime_ns `1791846636217687791` (that ctime is the Q45 restore's trace, as
recorded there). Deployed bin untouched: `helm-selftest.py` **33,647 B / `7ed1bca8f9da624f…`**,
`helm-collect.py` `ee0b36a82f23fcf3…`, `helm-render.py` `5c67473d89283c3d…`, `helm-validate.py`
`12570786ba703787…`, `summarize.py` `7386999867789694…`. Final tree: **0 sandboxes**.
**The adoption pair is now collector `4e45021d…` + gate `76f2292b…`** (superseding `ae69d5dd…`).
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 and none of Q31–Q47's
protection is in production until the pair lands. The `cp` stays Alexander's.
## 9. Q7, measured
After this item the Helm lane's executable set is empty again: the adoption `cp`, Q9 (checksum
handover), Q13/Q19 and Q42 are human- or counterparty-gated. Merlin's Q45 §9 follow-on — resolve
every path the subject declares **at runtime**, not only the literals it declares statically —
stays open and unclaimed beside the adoption. **No new item opened.**