Skip to content

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

Closed as not planned

Description

@noahgift

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 -e idiom 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

  1. 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 at check_pr_review_receipt.sh:458.
  2. 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.
  3. 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).
  4. 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.
  5. The 4→5 malformed shift is swept: no site branches on a specific non-zero code.

Refs #3554, #3080. Denominator rule: [runner set, never host set].

Activity

  1. added this to the 0.70.0 milestone on Sep 20, 2026
  2. added
    bugSomething isn't working
    P0Critical priority
    on Sep 20, 2026
  3. noahgift commented on Sep 20, 2026

    @noahgift
    ContributorAuthor

    Correction from infra-45, verified in the tree: there are TWO capture-and-compare catches after the six inert assertions, not one.

    :246  sweep_status=$(jq -r '.predicate.autonomy.delta_sweep.status // ""' "$rcpt")
    :247  case "$sweep_status" in clean|dirty|not-run) ;; *) refuse Q1 ...
    :258  verdict=$(jq -r '.predicate.verdict // ""' "$rcpt")
    :259  [ "$verdict" = "PASS" ] || refuse Q6 ...
    

    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 as jq -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:

    • Against the severity: two independent catches, in different classes (Q1 and Q6), is more robust than one. An edit would have to defeat both.
    • For the severity: the block now has eight checks against an empty receipt, of which six do nothing and the two that work were not written to do this job. A reviewer counting protections sees eight. The ratio is the defect.

    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 '' or the 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.

  4. noahgift commented on Sep 20, 2026

    @noahgift
    ContributorAuthor

    Cross-repo instance, and it is worse than this issue's: an empty receipt passes EVERY check

    Measured on paiml/rmedia and filed there as rmedia#590. Nine rmedia scripts gate on jq -e; on jq 1.6 an empty input exits 0:

    $ : > empty.json
    $ jq -e 'type == "object"' empty.json ; echo $?    # receipt-check.sh:47
    0
    $ jq -e . empty.json ; echo $?                     # edx-olx-build.sh:274
    0
    $ jq -e 'has("x")' empty.json ; echo $?            # receipt-check.sh:54
    0
    

    The contrast that proves they are not simply broken — a non-empty wrong file is refused correctly:

    $ echo '[]' > arr.json ; jq -e 'type == "object"' arr.json ; echo $?
    1
    

    So they refuse malformed content and accept absence. Worst case is scripts/coursera/receipt-check.sh: an empty receipt passes type == "object", passes every has($k) required-key check, and passes the not_run empty-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 -e assertions 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

    1. [ -s "$f" ] before jq -e, so emptiness is refused on every jq version rather than on the strict ones only.
    2. The gate names the jq it measured with, so rc 127 cannot 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.

  5. noahgift commented on Sep 20, 2026

    @noahgift
    ContributorAuthor

    Correction to the fix shape: jq -e was 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:

    empty.jsonl   jq -e .  → rc=0    an EMPTY artifact PASSES the validity gate
    nulls.jsonl   jq -e .  → rc=1    `null` is VALID JSON and is REFUSED
    good.jsonl    jq -e .  → rc=0
    

    jq -e sets its exit status from the LAST OUTPUT VALUE — truthy → 0, false/null → 1 — never from whether the input parsed. So jq -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 document null.

    That reorders the two defects this thread has been treating as one. The 1.6/1.7 split — no output → 0 on 1.6, → 4 on 1.7+ — is the second-order problem; it makes the verdict a lottery. The first-order problem is that -e tests 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 refuses null and still cannot express "this did not parse".

    The fix, corrected

    Two refusals with two messages, and plain jq ., not jq -e .:

    [ -s "$F" ]   || refuse "receipt is empty"           # emptiness, on every jq
    jq . "$F" >/dev/null 2>&1 || refuse "receipt is not JSON"   # parse — plain jq's status DOES reflect parsing

    -e is 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 -e is 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:87 in 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.

  6. noahgift commented on Sep 20, 2026

    @noahgift
    ContributorAuthor

    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 -e sites (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 inside docker run; the sovereign-ci:stable image 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 as params.jq_version in 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-pins reads declared-vs-live plus the behaviour probe per box.

    Two things this changes for the fix here:

    1. A >= 1.7 floor 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.
    2. The six inert assertions at pr_review_quorum_arm.sh:221-246 are now live on every runner — jq -e on 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.
  7. noahgift commented on Sep 20, 2026

    @noahgift
    ContributorAuthor

    Fleet floor landed: jq 1.8.2 everywhere (infra#833, behaviour-asserted), and the | grep -q class 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 six jq -e assertions at :224–:243 are 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 document null (rc 1), because -e tests the last value's truthiness, not parse success. done_when 1 stands — [ -s ] plus plain jq . for the parse check, -e only on the five value assertions.

    Also landed: infra#839 rewrites 180 cmd | grep -q sites to grep -c >/dev/null, one detector for guard and fixer — the SIGPIPE-141-under-pipefail class.

    Refs infra#833, infra#839, rmedia#592.

  8. modified the milestones: 0.70.0, 0.70.1 on Sep 24, 2026
  9. noahgift commented on Sep 24, 2026

    @noahgift
    ContributorAuthor

    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.

  10. noahgift commented on Sep 24, 2026

    @noahgift
    ContributorAuthor

    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 by jq -n '[inputs]|length == 1 (identical on 1.6/1.7/1.8), not jq -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.

  11. added 3 commits that reference this issue on Sep 24, 2026
  12. 9 remaining items

  13. added
    P1High priority
    and removed
    P0Critical priority
    on Oct 14, 2026
  14. noahgift commented on Oct 14, 2026

    @noahgift
    ContributorAuthor

    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.

  15. noahgift commented on Oct 14, 2026

    @noahgift
    ContributorAuthor

    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

  16. noahgift commented on Oct 14, 2026

    @noahgift
    ContributorAuthor

    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

  17. modified the milestones: 0.71.0, 0.73.0 on Oct 14, 2026
  18. noahgift commented on Oct 14, 2026

    @noahgift
    ContributorAuthor

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High prioritybugSomething isn't workingowner:aprender-a7owner sessionsize:MK̂ 3-4 h

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions