@codex-reviewer-andresmgsl's three findings (#4743), all real.
1. forge_label_create is now an UPSERT, matching gh label create --force.
bootstrap_labels creates every declared label on EVERY workflow_dispatch,
so a plain POST onto an existing name aborted the bootstrap under set -e
from the second dispatch onward. Resolves name -> id and PATCHes when it
exists.
2. forge_pr_view carries createdAt/completedAt. checks_state groups repeated
contexts and selects the newest by [.startedAt, .createdAt, .completedAt];
mapping only {context,state} left the winner to incidental array order, so
a stale re-run could outrank the live verdict. The combined status carries
created_at and updated_at — measured.
3. forge_issue_edit refuses unknown flags and missing values. The github
backend hands them to gh, which fails; dropping them here turned a
mis-typed port site into a mutation that silently did not happen — this
issue's own failure class, inside the fix for it.
Also settles @grok-reviewer-andresmgsl's note 3 (#4741): Forgejo Actions DO
land as commit statuses on this instance, so the rollup is not empty.
rig main carries four — "ci / check (push)" and siblings, state success,
each with created_at. statusCheckRollup therefore populates, and NONE is not
silently substituted for SUCCESS.
Each fix mutation-verified: dropping the timestamps, forcing POST-always, and
restoring the silent flag skip each red exactly their own cases. The
newest-verdict case drives the real checks_state, not a copy.
Refs #188
@codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734):
"...and the refusal names both totals" searched only for "4", so it stayed
green if the later total vanished from the message. A case named "names
BOTH" must fail when one goes. Now asserts "4 then 9".
Auditing this file's siblings for the same shape found a second, older
instance: "the refusal names the client" searched for "gh", which also
occurs in the explanatory prose ("gh speaks GitHub's /api/v3..."), so it
would have passed even if the client name never reached the message. Now
asserts "the 'gh' client cannot speak it".
Both verified by mutation: removing the second total, and removing the
interpolated client name, each red exactly their own case.
Refs #188
@codex-reviewer-andresmgsl's three findings (#4712), each a route by which
an unprovable read could still be reported as a whole one — the guard
leaking the failure class it exists to stop.
1. x-total-count was never validated. `X-Total-Count: not-a-number` returned
rc=0 with that string as the bound the walk compared against, reproduced
on ab23a3b. Now required to be a canonical non-negative integer.
2. The total was read once. A collection changing size under the walk was
invisible: page 1 declaring 4 and page 2 declaring 9 stopped at 4
believing itself whole. Now re-read per page; a moving total means the
read was not atomic and is refused.
3. A 200 whose body is not an array counted as zero items, so an error
object or scalar arriving where a list belongs read as a complete EMPTY
collection whenever the declared total was 0. Now refused, quoting the
body. A genuinely empty array is still fine — covered.
Each guard is mutation-verified: removing it reds exactly its own cases and
no others.
Refs #188
SC2016 on the deliberate single-quoted bash -c (the expansion belongs to
the isolated process, as the sibling case in issueflow-reconcile.test.sh
already documents), and SC2317 on the curl stub, which shellcheck cannot
see is invoked indirectly by forge_api.
Found only after committing, because .github/scripts/shellcheck-all.sh
derives its lint set from `git ls-files` — an UNTRACKED file is not linted
at all. "Gates clean" measured before `git add` was measuring a set that
excluded the file just written. Verified from a clean clone at the pushed
SHA, which is what caught it.
Refs #188
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
@codex-reviewer-andresmgsl's draft-stage finding: the closed/merged half of
the term-3 replacement had an executable-path case, the open half did not.
The 27 closes_references cases test the parser, not the
`.body | @base64` -> base64 -d -> closes_references wiring around it.
Both directions in one sweep so neither assertion passes vacuously: #50 is
closed by an open PR and keeps its claim, #51 is closed by nothing and is
reclaimed. `Closes #50` sits on the third line of the body, so the newline
protection is non-vacuous — an @tsv-shaped regression that keeps only the
first line reclaims #50 and reds the case.
Verified by mutation: replacing the decode with `base64 -d | head -1` fails
exactly "a claim closed by an open PR survives the base64 round trip" and
nothing else; reverting restores 148/148.
The clock is injected. INOW is a fixed 2033 epoch, so without ISSUEFLOW_NOW
the subprocess reads its own wall clock, dates both claims in the future and
keeps them on a negative age — green, and proving nothing. Caught while
writing this case.
Also renames the sibling assertion that still said "through GraphQL"; that
gather has been REST since 5797b41.
Refs #188
Term 3 of #188. Forgejo has no GraphQL API, so these two gathers could not
be translated — there is no endpoint to translate them to. A real
forgejo-runner job says so from the other side: GITHUB_GRAPHQL_URL arrives
set to the empty string (probe task 278).
MERGED_REF_PR_RECORDS was already a body parse; GraphQL was buying
pagination, nothing semantic. OPEN_PR_ISSUES used GitHub's own parse of the
closing keywords, so it becomes lib/closes_references.sh — a sibling of
refs_references, sharing its LOCAL/CROSS classifier so rig#112 can still
never be read as local #112 (#61).
Both gathers now read /pulls, which /api/v3 and /api/v1 return in the same
shape (measured on both). merged_at replaces GraphQL's states: MERGED.
Bodies travel base64: jq's @tsv escapes a newline to a literal backslash-n,
which a line parser reads as one line and loses every declaration after the
first.
The accepted delta, written down rather than rediscovered: GitHub also
records closing links attached through the PR development sidebar, which
live in no body. This family declares links in the body, so the delta is
zero here.
Refs #188
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
Round fixes on #200.
codex-1 (blocking): the issues narrowing dropped `edited`/`reopened`, but both
carry a queue-state change an event uniquely carries — `edited` a body rewrite
of the `Blocked by #N` declaration the sweep parses
(issueflow-reconcile.sh:179), `reopened` a closed issue re-entering the queue.
Dropping them tripped #199's must-fail. Narrow to
`[opened, closed, edited, reopened]`, dropping only the churn/validation
actions labeled/unlabeled/assigned/unassigned. Trigger tests now pin
edited/reopened present and the four dropped; labels.test.sh exact-list updated.
kimi (blocking): the "supersedes unreleased #144" prose was false — #144's
edited/reopened shipped in 0.3.0. Dissolved: we now keep them. CONSUMERS prose
rewritten to the real version history (0.2.0 #32 / 0.3.0 #144 / #199 narrows),
and the #137 review-request line corrected from "unreleased" to shipped-in-0.3.0.
kimi (non-blocking): reusable labels.yml comment no longer cites */15.
codex-2 (AC1 after-measurement / closing) escalated to triage on #199 — held,
not guessed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add test/labels-triggers.test.sh: reconcile stays cancel-in-progress:
false (the must-fail — true kills a sweep mid-board), the cron is hourly
not */15, each churn action is gone from the issues surface, and the PR
labeled handoff wake survives the issues narrowing. Update the #137/#144
parity block in labels.test.sh to the narrowed [opened, closed] contract
and replace its fragile inline pull_request_target scan (the #199 prose
comments name the trigger keys) with the anchored event_types reader.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Binding to the first marker occurrence dropped every later sentence of a
repeated declaration and let earlier prose hijack the parse — the false
ready promotion on rig#154. Each occurrence now contributes its own
clause, terminated at its own first ./; (unterminated -> end of input),
and the union feeds the unchanged classification and decision table.
Closes#184
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Command substitution strips every trailing newline, so 'grouped\n\n'
reached the case as a clean word and passed — the round's shared blocker
(codex, grok). A line count taken from the file itself now refuses any
physically multi-line sentinel before the word check runs, with the
existing diagnosis naming the file. Red rows: grouped/flat with a
trailing blank line in the unit suite, grouped in the armed suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Must-pass and must-fail rows from #182's test plan: the flip shape green
under the sentinel, drift and malformed sentinels red with file named, the
D3 fragment-list assertion, and D5 sentinel survival through consumption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
During the ceremony#111 freeze, rig#126/#128 carried blocked beside
state:needs-human — the round had finished, but the hold said the merge
must not happen, and rig#126 was merged seven minutes after the
reconciler wrote the green label. decide_state() only joined the two
axes through blockers(), which emits branch facts; the hand-set blocked
label was never consulted.
blocked becomes the second exclusion on state:needs-human, exactly
parallel to needs-ruling: round says needs-human + has_label blocked ->
state:addressing. Deliberately not a blockers() emission — BLOCKERS is
machine-owned and the converge loop would strip the live hold on the
next tick, the same trap #51 names for needs-ruling.
Ruling record: discussion 122, armed default A fired 2026-07-25T09:00Z.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One definition in the fragment predicate; changelog-armed reds the PR
that writes the fragment and the assembler refuses at release, both by
inheritance. Doctrine names the number in BUILDER.md and CHANGELOG.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stub published [opened, labeled, unlabeled, assigned, unassigned,
closed] while ceremony's own caller listens on eight types — PR #32's
70db91f widened the caller by edited and reopened and the stub never
followed. Both are load-bearing: an edited body rewrites the Blocked-by
declaration the reconcile sweep parses, and a reopened issue re-enters
the queue wearing labels derived at close (#144).
The stub's list is now byte-identical to the caller's, a parity row in
test/labels.test.sh keeps it that way (red on a dropped type, a drift,
or a reorder in one file only), and one adoption note names the tag the
widened list rides in on. The caller does not narrow.
Closes#144
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reconciler's rule was right and blind: the caller never listened on
review_requested/review_request_removed, so the one event that falsifies
(or restores) blocker:unrequested could not clear it, and a quiet repo
wore the red flag until the advisory cron (#137's timeline: 93 seconds,
cleared only by an unrelated PR's push).
- self-labels.yml + the CONSUMERS.md stub gain both types; the scope job
skips them (no paths change; running labeler there widens #130's window)
- test/labels.test.sh: caller/stub parity row with mutation cases —
dropped type either side, one-sided reorder, all red
- CONSUMERS.md no longer claims trigger adoption is a bare pin bump; the
pending stub edit is named and rides the first tag carrying ceremony#137
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
checks_state discards a CANCELLED entry only when its context group holds
at least one non-cancelled sibling — before the sort, so the duplicate the
repo-global reconcile queue evicted (after it had already attached a check
to the head) cannot outvote the success that did its work (#136a17e497,
#1334002924). An all-cancelled context never reported at all and still
classifies FAILURE; {FAILURE older, CANCELLED newest} keeps its red.
The fixture that pinned the opposite rule imagined a cancelled run
replacing a success; it never saw one that replaced nothing. Rewritten
with its reason, plus the recorded a17e497 shape, the all-cancelled
groups, and the cancelled-over-FAILURE case.
Closes#139
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mapping header still described actions/labeler@v5 + sync-labels —
the exact mechanism this PR removed; it now describes labels-scope's
base-ref read and additive POST, keeping the #128 incident. The
scope:labels row gains actions/labels-scope/** and its test, and two
fixtures derive against the real mapping so the coverage is tested, not
just present. The reconcile job comment names labels-scope instead of
labeler (grok nit 3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
facts.test.sh: greenfield fixtures for all-zeros and empty event.before,
the bare root establishing labeled=no, and the D2 pin (an unresolvable
MERGE_SHA exits 128 and never reports base_ver=(none) — the test that
|| true would fail). release-chain.test.sh: chain() gains optional
repo/stub args; a -dev root commit is a green NOTICE ceremony=no, a bare
unlabeled root still refuses. Plus changelog.d/134.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bare X.Y.Z at the head where the base says otherwise, no release label,
not a draft: the sweep emits one :⚠️: per pass naming both
versions. A warning only — release is declared intent and the reconciler
never guesses intent (LABELS.md). Version read via the API, both
backends, jq not node; unreadable reads nag nobody. Plus the yq test
contract in CI and fixture tests for the guard matrix.
Part of #130.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stamp 2 is one assembler-produced edit; changelog-armed's rule is stated
mode-first (fragment, then legacy, #112 D7/D8/D9); changelog-assembled
gets its operator section (#116); monotonic records D10; the two rewritten
error strings and the retired re-arm recovery follow (#117).
The regression row grepped only the four current ceremony names, so a
future enumeration under new names stayed green. Widen the pattern to
scope:[a-z0-9] — any concrete label name, in any shape, re-reds the row,
while doctrine's bare `scope:` and wildcard scope:* stay allowed (#104).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mirror is byte-identical in every governed repo, so the four-row
enumeration was true at home and false in rig, box, cast and incubator —
14 of 16 vendored rows lied. The section keeps its doctrine and points at
the two places true wherever the reader stands; ceremony's own set is now
a pointer sentence in CONTRIBUTING, and a labels.test.sh row (red on main,
4 hits) keeps enumeration from returning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/changelog.test.sh drives changelog_fragments (order, marker, absent
dir), changelog_fragment_problem (every rule, file named each time), and
changelog_assemble (both shapes, canonical order, mixed-shape refusals).
test/changelog-assemble.test.sh drives the CLI against constructed trees:
exact-byte writes, provably read-only --check, every refusal from the
spec, the publisher/assembler round trip, and idempotence.
Closes#114.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.
Part of #101.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>