Five repairs to the Q38 gate (74aba348 -> 9b09f328, +309/-38): the probe injects from pathlib import Path so the rewritten line cannot NameError; EVERY SNAPSHOT_PATH assignment is rewritten; a probe that dies with a traceback is refused; an ast scan refuses an absolute path literal resolving inside /root/.helm/data/; and live bytes and mtime are read around every subprocess and around the subject's in-process import, a change dying the run and restoring the bytes it read. Negative controls (q39-negatives.py, Q39-NEGATIVES.json): five fixtures, each run under BOTH gates. n1 (the incident's shape) and n2 (a probe that dies with a traceback) are ADMITTED by the Q38 gate - n1's in-process import writes the fixture's destination inside the gate's own run - and refused by the Q39 gate. Green pair: 5 consecutive invocations, rc 0, 164 passed / 0 failed, 17 legs watched, live delta 0 on every run. Live snapshot aa842b0f delta 0 throughout; nothing deployed, decisions/ and inbox/ untouched.
12 KiB
Q39 — the destination guard's admission admits a writer, and its legs run the unrewritten subject
Merlin, 2026-10-12, this host. Scratch tree /root/crucible/q39/. Nothing deployed, nothing published, no
cp into /root/.helm/bin/, decisions/ and inbox/ untouched.
Q38 delivered a behavioural destination guard and the pipe falsified its protective claim the same day
(replies/2026-10-12-to-merlin-21.md, corrections C22/C23). This item is the repair. The fixed gate is
/root/crucible/q39/helm-selftest.py, 1,412 lines / 77,704 B / sha256
9b09f3283008e695bb0634bc2d5492f2c24067b9eeb9ee2100099b7cbfcd1429, a +309 / −38 unified diff against
Q38's gate (74aba348…, 1,141 lines / 64,454 B); DIFF.txt 408 lines / sha256 b8adc9126397f61a…. The
subject collector is byte-identical to the Q34/Q35/Q38 pair (4e45021d…, 720 lines / 29,954 B).
1 What was broken, restated from the falsification
| # | Defect | Mechanism | Consequence measured by the pipe |
|---|---|---|---|
| C22 | the probe injected SNAPSHOT_PATH = Path(<sentinel>) and Path lives in the gate's namespace, not the subject's |
a subject importing pathlib without the name raises NameError inside the probe → rc=1, nothing written, live delta 0 → admitted |
a writer reached the legs |
| C23 | only the first SNAPSHOT_PATH = line was rewritten, and the legs run the original subject |
the probe tested a destination the subject does not use; the legs then executed the subject against its live default | 2026-10-12T07:18Z: the live snapshot became {} (2 B / 44136fa3…) inside the gate's own run, while the gate printed live delta 0 |
2 The five repairs, with line numbers in the fixed gate
- The probe is import-independent (
:330rewrite loop,:333–338preamble). The probe file is now the subject's source with an injectedfrom pathlib import Pathabove the body, so the rewritten line cannotNameErroron a subject that never imported the name. This is the C22 mechanism removed rather than detected. - Every
SNAPSHOT_PATH =assignment is rewritten, not the first (:330loops over_q39_assign). A decoy first assignment can no longer hide the real destination from the probe. - A crash is not a refusal (
:356–358, condition at:380–381). A probe whose stderr carriesTraceback (most recent call last)is refused. Q38's own injection was the class that produced exactly that stderr, which is why this condition and repair 1 are both needed. - The destination-resolution scan (
:225–302, refusal at:304–320). EverySNAPSHOT_PATH =assignment is parsed with ast (not regex) and every absolute path literal in the subject's source is resolved — cumulatively onto three bases (the subject's directory, the live data root's parent, the cwd) — and refused if it lands inside the live data root. Bare string statements (docstrings) are excluded by a walk that does not descend intoExprnodes (:236–249), which is why the deployed collector is admitted: its live path is composed (HELM_HOME / "data" / "snapshot.json",helm-collect.py:29–30) and never written as one absolute literal. The report is printed on both the refusal and the green path. Measured on the gated collector, every green run:assign line 30: SNAPSHOT_PATH <- 'data' / 'snapshot.json' -> resolves inside the live data root: /root/.helm/data/snapshot.json - Every leg is watched (
:415–474, markers# Q39-LEG-GUARD-BEGIN/END; import watch at:1047–1049; summary at:1407). Live bytes and mtime are read around everysubprocess.runthe gate spawns and around the subject's in-process import; a change dies the run with exit 2, prints both digests, and restores the bytes it read (plus the mtime). Q38 read live once before the legs and once at the end and summarised the difference — C23 is that a mid-run write is invisible to that.
Two self-corrections made while building this, logged rather than hidden:
- the first
_q39_live_state()returned(bytes, mtime_ns, atime_ns)and every leg failed: reading the file updates its atime on this filesystem, so a three-tuple comparison flags a clean leg as a change. The state is(bytes, mtime_ns)(:431–438); the requirement is live bytes and mtime, and mtime is the stamp a writer moves. - the Q39 summary line was first written to stderr and landed concatenated onto the last
PASSline when both streams go to one file (stdout is block-buffered, stderr is not). It is now aprintaftersys.stdout.flush()(:1405–1411), and theQ39 OKline flushes stdout first (:405).
3 The negative-control suite — five refusals, each re-run under Q38 as the discriminator
python3 /root/crucible/q39/q39-negatives.py → RESULT: ALL NEGATIVE CONTROLS REFUSED AS EXPECTED,
Q39-NEGATIVES.json written at 2026-10-12T09:28:09Z, gate digest inside it matching the file above.
The suite resets the fixtures' destination files first, so "created" is measured per run, not inherited.
| fixture | Q39 exit | Q39 condition named | Q38 exit | Q38 admitted? | side-effect files created (Q39 / Q38) |
|---|---|---|---|---|---|
n1-incident-shape (the incident's shape: writes at import, imports pathlib not Path) |
2 | the probe did not refuse (rc=0); the probe wrote the sentinel (2 B) |
0 | yes | [] / ['n1-dest'] |
n2-traceback (raises at import) |
2 | the probe died with a traceback, which is a crash, not a refusal |
1 (crashed at the import) | yes | [] / [] |
n3-decoy (first assignment a decoy, real destination on the next line) |
2 | the probe did not refuse (rc=0); the probe wrote the sentinel (2 B) |
2 | no | [] / ['n3-real'] |
n4-comment-only (markers in the docstring, no rule) |
2 | the probe did not refuse (rc=0); the probe wrote the sentinel (2 B) |
2 | no | [] / [] |
n5-live-literal (refuses its default properly, names the live path as an absolute literal) |
2 | names the live destination as an absolute literal |
2 | yes | [] / [] |
Every Q39 run: stdout 0 B, live delta 0 (bytes and mtime), no helm-q39-* probe directory left
behind. Two of the five are the C22/C23 classes Q38 got wrong, and both are admitted by Q38 to this day —
n1 is admitted and its in-process import writes the fixture's destination inside the gate's own run,
n3's probe writes the decoy's real destination inside the probe, which is the containment failure
reply 21 measured. Under Q39 neither file is created.
4 The leg watch, measured both ways
python3 /root/crucible/q39/q39-legguard-test.py extracts the shipped guard by its own
# Q39-LEG-GUARD-BEGIN/END markers (lines 416–474, 2,207 B) and runs it against a scratch watched
file, because the live snapshot cannot be clobbered to prove a clobber is caught:
clean leg: rc=0 watch_calls 0 -> 1 bytes_unchanged=True
writing leg: exit_code=2 restored=True mtime_ns 1791797218872835164 (original 1791797218872835164)
refusal: Q39 REFUSAL: a leg's subprocess (… open('/tmp/q39-legguard-669_7xhk/snapshot.json',
'w').write('CLOBBERED')) changed the live snapshot - sha256 aef3121c6c5097a3… before,
sha256 8ddb948bf967125e… after; the before-bytes were restored and the run is refused.
RESULT: LEG WATCH MEASURED BOTH WAYS
5 The green pair — 5 consecutive invocations
bash /root/crucible/q39/run-green.sh 5, HELM_COLLECTOR=/root/crucible/q39/helm-collect.py:
run 1..5 rc=0 live_delta=0 pass_lines=164 legs_watched=17 q39_ok_lines=1
live=aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d
mtime=2026-10-08 17:12:59.475780303 +0000
Each log carries Q39 OK: … (probe rc=2, sentinel written=False, other probe files=0, no traceback, live delta 0), the destination-resolution report, helm-selftest: 164 passed, 0 failed, VERDICT: ALL GATES PASS, and Q39: 17 legs watched … delta 0 on each. Log digests:
f0bfaca19d5d2a56c42e8bd3b7c112c076544dfe90e9d874b395a29219604be5 green-run-1.log
a13a184de21a45a72ed4ec2409e5aa969dc18591752b82d5fac256a1cfad99a0 green-run-2.log
e305e9f7dfd5fdc9d8a34bc10ccbc4a3d3f0f0c1feb77863b376a66437771b70 green-run-3.log
0b2343b6a2fb64bd5078a7ecef5fcfbdfb5e9e0f9271cb4cdc11626f5a7e9d8a green-run-4.log
eaaf90e14ba8577749c718d5430ec2cd969a1bad07839bfc3826baded16efad9 green-run-5.log
17 legs is the count of subprocess.run calls the gate makes on the green path (10 in the Q38 gate's
own text plus G7's renderer runs), each watched individually — the number is printed so the watch is not
vacuous.
6 Live state and what was not touched
/root/.helm/data/snapshot.json14,039 B / sha256aa842b0fd88037f8…/ mtime_ns1791479579475780303before and after every run in this item — 5 green, 5 fixtures × 2 gates, the leg-watch test, and the cleanup. Delta 0 throughout./root/.helm/bin/unmoved:helm-collect.pyee0b36a8…(the deployed pre-Q35 collector — it carries no destination rule, which is why the gate's defaultHELM_COLLECTORis refused at Q35 and the gated pair is named explicitly),helm-selftest.py7ed1bca8…,helm-render.py5c67473d…,helm-validate.py12570786…,summarize.py73869998…. Nocpwas made; adoption stays Alexander's. The adoption pair is now collector4e45021d…+ gate9b09f328….- Public page
https://alexanderwhitestone.com/helm/200 / 9,865 B / sha256427b7b54c69b18d270288910fc810732463129f48ec4bdd7dbc75cdb22997ce4at 2026-10-12T09:29:03Z — the Q38 record's figure. /root/stackchain-legends-build/build_blocks.pystill97fedb5dd5e85d2b…, nothing under that tree modified since 2026-10-11 08:00Z (find … -newermt→ 0 files).decisions/2026-09-26.md40b7cc14a829…andinbox/seed-2026-09-26.jsone4429147bc1b…unchanged and not opened for content. No other party's file edited. No secret printed.
7 Limits, stated
- The static scan is literal-based. A subject that composes the live path from a variable the scan cannot see, or reads it from the environment, is not caught by repair 4; the behavioural probe and the leg watch are what cover it, and the leg watch is detection-with-restore, not prevention.
- Repair 4 refuses a legitimate shape. A subject that names the live snapshot as one absolute literal — even one that guards it properly — is refused. That is the conservative reading and it is a design decision, not a measurement: the gate cannot redirect a path that is not the module constant.
- The legs still run the original subject. Repair 5 makes a live write fatal to the run and restores it; it does not stop the subject from executing with its live default. Running the legs against a rewritten copy would be the stronger form and is not built.
- The probe substitution still touches one module constant. A subject that resolves its destination by another route is not covered by the rewrite (Q38's limit, unchanged); the live-delta assertion catches an actual write only after the fact.
- The timeout branch is coded and not exercised (Q38's limit, unchanged).
- The guard protects the gate's legs, not a direct CLI run of a pre-gate collector (Q35's limit, unchanged).
- 5 fixtures are the item's own four classes plus the decoy containment case; they are not a census of subject shapes. Byte counts and digests are dated readings from this host, 2026-10-12 ~08:5x–09:3xZ.
8 What is left for Alexander
The same gate as Q38: do not cp 9b09f328… into /root/.helm/bin/ without a decision, and note
that the shipped state is now measurably the weaker one — pre-Q39, a subject that carries no destination
rule is refused at Q35 but a subject that documents one is admitted and executed; with 9b09f328… the
four classes above die before any leg. The publish precondition the queue already carries stands:
adopt the fixed gate into the shared tree first, then publish — and it is a one-file change plus the
collector, not a build change.