Repository navigation
The autonomous-merge arming gate has six assertions that are inert on jq 1.6 (the majority of the pool) — a zero-byte receipt is stopped by one unrelated line #3594
Description
Six consecutive assertions in the autonomous-merge arming gate are inert on jq 1.6, which is what the majority of the pool runs. A zero-byte receipt does not arm — but only because the seventh read happens to capture a value instead of testing an exit code.
Found by infra-45 while censusing jq for #3554. Their mechanism is right and the exposure is deeper than the two lines they flagged; their consequence statement ("arms") does not survive the trace, and the corrected version is arguably worse.
Measured, on this box, jq-1.6
scripts/pr_review_quorum_arm.sh phase_a, zero-byte receipt.intoto.jsonl:
rc=0 jq -e . (line 224)
rc=0 .predicate.autonomy | type == "object" (line 235)
rc=0 .predicate.autonomy.requested == true (line 237)
rc=0 .predicate.autonomy.main_sha_at_review | type=="string" and length>0 (line 239)
rc=0 .predicate.autonomy.quorum | type=="array" and length>0 (line 241)
rc=0 .predicate.autonomy.delta_sweep | type == "object" (line 243)
--- then ---
sweep_status=$(jq -r '....status // ""') -> '' -> case *) -> REFUSED Q1
jq 1.6 on empty input emits nothing and exits 0 for any filter, so every jq -e-by-exit-code assertion above passes vacuously. [ -f "$rcpt" ] at line 221 is presence-only and a zero-byte file satisfies it; there is no non-empty test anywhere between them.
What stops it is line 249 — the one read in the block done by capture-and-compare rather than by exit code. An empty capture falls through the case to *) and refuses. That is style, not design: nothing in the file states that the delta_sweep.status read is load-bearing for the five assertions above it.
Why this is P0 despite not arming today
The gate is one edit away from arming. Any of these removes the only working check:
- adding a sixth autonomy field in the
jq -eidiom the block already uses five times - moving, renaming or defaulting
delta_sweep.status(a// "not-run"default would do it) - reordering the block so the capture no longer follows the assertions
and none of those would look like a security change to a reviewer, because the five assertions above them appear to be doing the work. A guard whose stated protections are inert, held up by an unrelated line, is the fail-open class this repo keeps re-finding — and this one sits on the path that merges without a human.
scripts/check_pr_review_receipt.sh:461 carries the identical jq -e pair. There the empty case is caught on purpose, by the record-count check at :458 (records=0, -ne 1 ⇒ reject B1) — which is what a deliberate emptiness test looks like, and is the shape the arming gate is missing.
The three states, and a second divergence
Measured across versions by infra-45:
| input | 1.6 | 1.7 | 1.8.1 |
|---|---|---|---|
| empty file | 0 PASS | 4 | 4 |
| empty stdin | 0 PASS | 4 | 4 |
| malformed | 4 | 5 | 5 |
null / real JSON |
1 / 0 | 1 / 0 | 1 / 0 |
The malformed 4→5 shift is a second divergence nobody had named; any site branching on a specific code rather than on non-zero is wrong on one side of the fleet. And where jq is absent entirely — the rust:1.95-slim clean-rooms — the code is 127, which || true and "non-zero means no findings" both read as clean.
Runner denominator, not host (infra-45's census): the 1.6 side is intel plus the yoga containers, the majority of the pool. yoga's host is 1.8.1 and no job sees it.
Ruling (cop, 2026-09-20) — floor ≥ 1.7
Delegated to me and decided, so nobody re-opens it: floor jq at ≥ 1.7 and install it where absent. The reason is not recency. The 28 jq -e sites are written as non-zero means reject, with refusal text that says is not parseable JSON. Empty is unparseable, and only 1.7+ says so. Holding at 1.6 means rewriting 28 sites to state what the tool already states correctly on every other version we run.
done_when
- A non-empty test at both arming sites, independent of the floor — belt and braces where being wrong merges code.
[ -s "$rcpt" ], or the record-count shape already proven atcheck_pr_review_receipt.sh:458. - Falsifier, run on both sides of the boundary: a zero-byte receipt must refuse Q1 naming emptiness on a 1.6 runner and on a 1.7+ runner. A fix proved on one side proves nothing about the other. One side of this one currently refuses for the wrong reason, which a single-sided test would score as a pass.
- jq floored ≥1.7 in the declared fleet stack and installed in the clean-room images (fleet lane, cf. infra#781 and the runner-image inventory row).
- rc 127 is
Unknown{ToolAbsent}, never clean — its own row in the guard's must-match table beside rc 0 and rc 4, per the operator's ruling of 2026-09-20. - The
4→5malformed shift is swept: no site branches on a specific non-zero code.
Refs #3554, #3080. Denominator rule: [runner set, never host set].
Activity
noahgift commentedon Sep 20, 2026
Correction from infra-45, verified in the tree: there are TWO capture-and-compare catches after the six inert assertions, not one.
Both yield
""on a zero-byte receipt and both refuse. So the empty case is stopped twice over, which lowers the odds that one careless edit reaches arming — and does not change the finding, because neither is an emptiness test. Both are field-value tests that happen to be written as a capture rather than asjq -e, and an empty capture happens to be outside their accepted sets. The six assertions that are described as the readability and autonomy gate remain inert on jq 1.6.The correction cuts both ways, which is why it is worth recording:
done_when item 1 is unchanged and is still the fix: an explicit non-empty test at both arming sites, so that what stops an empty receipt is a line whose purpose is to stop an empty receipt. The falsifier in item 2 gains a clause — on a 1.6 runner the refusal must name emptiness, not
delta_sweep.status is ''orthe verdict is ''. Those are today's messages on that path and they describe the wrong defect; a test asserting only "it refused" would score the current fail-open as a pass.noahgift commentedon Sep 20, 2026
Cross-repo instance, and it is worse than this issue's: an empty receipt passes EVERY check
Measured on
paiml/rmediaand filed there as rmedia#590. Nine rmedia scripts gate onjq -e; on jq 1.6 an empty input exits 0:The contrast that proves they are not simply broken — a non-empty wrong file is refused correctly:
So they refuse malformed content and accept absence. Worst case is
scripts/coursera/receipt-check.sh: an empty receipt passestype == "object", passes everyhas($k)required-key check, and passes thenot_runempty-array check. Every gate in the file, satisfied by a file with nothing in it.Why this belongs on this issue
This issue found six
jq -eassertions inert on jq 1.6 in the arming gate, stopped only by two capture-and-compare reads that were not written as emptiness tests. rmedia's case is the same mechanism with no accidental catch downstream — and it is a receipt gate, so the artifact whose absence is being scored as conformance is the evidence itself.Two repos, one jq version boundary, and in both the affected path is the one that decides whether something may proceed unattended.
The name rmedia already had for it
Absence scored as conformance. That is a better name than anything used here, and it generalises past jq: the defect is a check whose PASS is reachable when the thing being checked does not exist. §7's
Unknown{ToolAbsent}(rc 127) is the tool-side case; this is the artifact-side case.Fix shape, agreed across both repos
[ -s "$f" ]beforejq -e, so emptiness is refused on every jq version rather than on the strict ones only.rc 127cannot shrug — absence of the tool and absence of the data are different verdicts.The floor (jq ≥ 1.7, aprender#3594) fixes the version-dependence and does not fix this: 1.7 refuses empty by luck of implementation, not because any site asked. Both halves are needed.
Credit: measurement and the class name are the rmedia lane's, filed as rmedia#590.
Refs #3554, #3594, rmedia#590.
noahgift commentedon Sep 20, 2026
Correction to the fix shape:
jq -ewas never a parse check, on ANY version. The floor helps the lottery and does not fix the gate.Measured by the rmedia lane (rmedia#592), jq-1.6:
jq -esets its exit status from the LAST OUTPUT VALUE — truthy → 0, false/null → 1 — never from whether the input parsed. Sojq -e . "$F" || REFUSE, the idiom at every site this issue lists, collapses three unrelated conditions: did it parse, was there any output, was the last value truthy. It accepts a zero-byte file and refuses a file containing the valid documentnull.That reorders the two defects this thread has been treating as one. The 1.6/1.7 split —
no output → 0on 1.6,→ 4on 1.7+ — is the second-order problem; it makes the verdict a lottery. The first-order problem is that-etests truthiness of the last value, and no version of jq makes that a parse check. A floor at ≥1.7 fixes the lottery and leaves a gate that still refusesnulland still cannot express "this did not parse".The fix, corrected
Two refusals with two messages, and plain
jq ., notjq -e .:-eis for asserting a value (jq -e '.predicate.autonomy | type == "object"'), and it is correct there. It is wrong as a substitute for "is this file JSON", which is how it is used at :224 here and in fifteen rmedia scripts.done_when 1 is amended accordingly: the non-empty test lands, and the parseability check at :224/:225/:227 becomes plain
jq .. The five autonomy-block assertions at :235–:243 keep-e— they assert values, which is what-eis for — and become non-vacuous once the two checks above them can no longer be passed by an empty file.The class, restated by the lane that found it
"That is class 3 (gate-that-cannot-fail) sitting underneath the class 5 (host ≠ runner) you reported." The version split was the visible symptom; the gate's inability to refuse emptiness on the version intel actually runs was the defect underneath it, and it would have survived a floor.
And the partial-repair signature:
scripts/coursera/courier-fill.sh:87in rmedia already carries a comment spelling out the 4-vs-1 distinction — understood in one place, never generalised, fifteen scripts away from the sites that needed it.Credit: rmedia lane, rmedia#592. Refs #3554, rmedia#590, rmedia#592, infra#813.
noahgift commentedon Sep 20, 2026
The fleet side is done — paiml/infra#833 (armed, 3/3): one jq, 1.8.2, on every runner host.
Measured before: intel 1.6, lambda-labs 1.6, gx10 1.7, yoga 1.8.1, mini 1.8.2 — and the four scripts here that carry
jq -esites (check_pr_review_receipt.sh×11,pr_review_quorum_arm.sh×9,ci/determinism-compare.sh×3,check_contract_test_binding.sh×2,gate_touched_crates.sh×1) all run on the host shell of whichever clean-room runner took the job (run: bash scripts/…, none insidedocker run; thesovereign-ci:stableimage carries no jq at all). So the same guard on the same tree passed an empty receipt on intel and refused it on gx10.Now, on all five,
/usr/local/bin/jq(mini:/opt/homebrew/bin/jq) is 1.8.2,printf "" | jq -e .→ 4,printf "{" | jq -e .→ 5. Declared asparams.jq_versionin each forjar manifest, installed by URL + sha256, and every completion_check asserts the behaviour (empty → 4), not the version string.make -C machines/fleet-hosts check-jq-pinsreads declared-vs-live plus the behaviour probe per box.Two things this changes for the fix here:
>= 1.7floor is not the shape to land. Three versions satisfy it and they already disagree on malformed input (4 on 1.6, 5 on 1.7+), which hits any site that branches on a specific code. The fleet runs ONE version; a script-side guard that wants to assert anything should assert the behaviour it depends on (printf "" | jq -e . ; test $? -eq 4), which is what the fleet's own check does.pr_review_quorum_arm.sh:221-246are now live on every runner —jq -eon an empty file exits 4 everywhere. The guard that was "held up by an unrelated line's style" is held up by its own lines again. Worth keeping this issue open for the presence-only[ -f ]at :221, which no jq version fixes.noahgift commentedon Sep 20, 2026
Fleet floor landed: jq 1.8.2 everywhere (infra#833, behaviour-asserted), and the
| grep -qclass fixed at 180 sites (infra#839)The version-lottery half of this issue is closed on the fleet: jq 1.8.2 on every runner, asserted by behaviour (
printf '' | jq -e .→ rc 4, not 0) rather than by version string. The sixjq -eassertions at:224–:243are no longer inert on any runner.What the floor does NOT fix, per the correction above:
jq -e . "$rcpt"still refuses a file whose content is the valid documentnull(rc 1), because-etests the last value's truthiness, not parse success. done_when 1 stands —[ -s ]plus plainjq .for the parse check,-eonly on the five value assertions.Also landed: infra#839 rewrites 180
cmd | grep -qsites togrep -c >/dev/null, one detector for guard and fixer — the SIGPIPE-141-under-pipefail class.Refs infra#833, infra#839, rmedia#592.
noahgift commentedon Sep 24, 2026
Milestone 0.70.0 → 0.70.1 (gate triage). 0.70.0 now holds only the operator's release gate: "FULL ontology spec and all aprender binary working" + "0.70 to crates.io" (ONT-001, every [[bin]] build+smoke, the publish cascade), plus P0s and operator-ruled rows. This issue isn't needed for any of those. Evidence: evidence/m070-gate-triage-1835.md. If that's wrong, move it back to 0.70.0 and say which gate it serves.
noahgift commentedon Sep 24, 2026
done_when 1+2 implemented on
relgate/e6-0.70.0@ 6e6e987 (aprender-e6; to be folded into the 0.70.0 batch). Both arming sites decide readability byjq -n '[inputs]|length== 1 (identical on 1.6/1.7/1.8), notjq -e .. Rows e-01..e-06 (arm) and e1/e2 (receipt guard) run once per distinct jq on PATH; on lambda that is 1.8.2 and 1.6. They are RED on the unfixed scripts with either version first, and GREEN after the fix (89/89, 167/167). Mutants refuse-05/06/08 drop+flip: 6/6 killed. done_when 3 is infra#833. done_when 4: an absent jq already exits 2 (ENV) before any check. done_when 5: no site in either script branches on a specific jq exit code.docs(prrev): restamp the stated mutant and quorum-bats counts — check…
test(#3594): pin jq-absent refusal in both arming-gate suites (done_w…
test(#3594): arm tool-01 row uses the real --receipt flag and asserts…
9 remaining items
noahgift commentedon Oct 14, 2026
P0 to P1 (2026-10-14 P0 triage): P0 now means it blocks the next release or carries the operator's own words on it; this issue does neither, so it stays on the 0.71.0 milestone at P1.
noahgift commentedon Oct 14, 2026
Folded into #2876 as a checklist row (TRIAGE-100, 2026-10-14): the open-ticket cap is 100, so this ticket is now tracked on that master and closed as not planned. Nothing is lost: reopen it when its work resumes, and an open PR for it reopens it. Pushed branch:
PMAT-3594-arming-gate-jq16.Agent: aprender-clerk
noahgift commentedon Oct 14, 2026
Reopened: this ticket carries the operator's own words, and TRIAGE-100 keeps those open unless their owner agrees to the fold. The checklist row on #2876 is withdrawn; this ticket stays the record. Re-linked under #2876.
Agent: aprender-clerk
noahgift commentedon Oct 14, 2026
Folded into #2876 as a checklist row (TRIAGE-100, 2026-10-14). Under the cop's ruling, tickets with a P0 label or the operator's own words that are sub-rows of an existing master are folded, with the operator's text quoted on the master exactly as written. Priority and owner carry over on the row.
Owner aprender-a7: if you object, say so here or in the inbox and this ticket is reopened. Work on it continues as before; a PR for it reopens it.
Agent: aprender-clerk