@codex-reviewer-andresmgsl did not argue this one, he reproduced it: an
`actions/*/action.yml` declaring CEREMONY_FORGE_CLIENT and a workflow written
`.yaml` rather than `.yml`, both invisible to the hand-picked globs, guard
still 21/21 green.
The first is not an edge case — `actions/*/action.yml` is this repository's
normal composite structure and a client declaration there IS a forge decision.
The second shows `*.yml` was never a complete workflow surface.
So discovery walks the tree and EXCLUDES by class rather than enumerating
directories, depths and extensions. Excluding is the safer default: a new file
type arrives scanned rather than invisible. Out of scope are .git/, test/
(whose harness asserts these tokens by design), changelog.d/ and *.md — prose,
including drills/, which stays in the inventory because its records are
forge-specific by CONTENT while a record mentioning a selector verb in prose is
not a decision.
Both of his reproductions are now fixtures driving the real no_unlisted, and
restricting discovery back to *.sh reds four cases.
The documentation claim is aligned with what the guard does rather than what
the table implies: it checks forge DECISIONS in executable and configuration
files; it is not a diff against upstream, so drills/ and labels.conf are listed
by judgement rather than found by scan. Saying otherwise made labels.conf and
drills/ look like evidence of completeness while action.yml was invisible.
upstream-delta 24/24; test/run.sh 29/29; shellcheck 0.10.0, actionlint and
changelog-armed clean.
Refs #200
@codex-reviewer-andresmgsl's five points. Three were correctness, and one of
them found that my must-fail cases could not fail.
1. THE OBJECT IS MANDATORY. UNVERIFIABLE-HERE is gone: a missing ref, an absent
object and a non-ancestor are three distinct refusals. The ref is now the
FULL 40-character SHA, and ci.yml fetches exactly that object before the
suite. "Runs offline" means the TEST reads local evidence; it never meant CI
may omit the evidence and pass.
2. THE SCAN COVERS WHAT THE INVENTORY CLAIMS. It walked shell under four globs
and never looked at workflows, .github/labels.conf or drills/ — three
categories the inventory governs. Widened, and it immediately found four
real blind spots on merged main: refs-not-closing's declaration,
labels.yml's inline forge decision, refs-guard.yml's GitHub-only scheduling
and release-exercise.yml's pinned CEREMONY_FORGE. All four are now inventory
entries with the issue that removes them, because a delta location with no
exit is indistinguishable from one nobody noticed. A file that DECLARES a
client is no longer exempt as a "consumer" — only files that merely CALL the
shim are.
3. THE TEETH NOW DRIVE THE GUARD. They asserted the predicates separately and
never invoked no_unlisted, so the guard could have been `return 0` and both
must-fail rows would still have passed. SCAN_ROOT is a parameter now and the
cases build a tree, add an unlisted decider — shell AND workflow, so
coverage cannot regress to the old glob — and assert the real top-level
check fails naming it. Replacing no_unlisted with `return 0` reds five.
4. PATH MATCHING, NOT PREFIX MATCHING. `drills/` accepted `drills-old/x` and
`lib/forge.sh` accepted `lib/forge.sh.backup`. Exact for files, `dir/` for
directories, with both negative boundaries covered.
5. THE IMMUTABLE SHA IS CAPTURED AT FETCH. The runbook now takes
upstream_sha=$(git rev-parse gh/main) once and merges and records that
value. This is not hypothetical: while this PR was in review upstream moved
from 8c3a4d1 to 08e2912, and re-reading gh/main at recording time would have
written a commit this tree does not contain. I caught that by walking into
it.
test/run.sh 29/29; upstream-delta 21/21; shellcheck 0.10.0, actionlint,
changelog-armed clean.
Refs #200
@codex-reviewer-andresmgsl's two additions (#5697), both measured in the #198
sync rather than anticipated.
Every branch open across a sync is stale afterwards: Forgejo never re-tests an
open PR when main moves under it, so #206 and #207's green 22-file suites were
about a tree that no longer existed once the 28-file one landed — and #206's
fragment was individually green while making the combined tree red under a rule
the sync itself introduces. The runbook now says to update each in-flight
branch from the newly synced main, or check them in a scratch merge, and that a
prior approval is evidence about the tree it was given on.
And post-merge runs are audited by executed steps, never by colour: inventory
what the sync changed about triggers and jobs, read which job actually ran, and
treat a green refusal path as evidence for that path only. Run 326 was green
and had reconciled nothing.
Both failures happened with the no-runtime-gh guard green and CI green, so the
runbook says that too.
Refs #200
The third child of #197, written immediately after performing the sync it
describes, while the findings are still first-hand.
docs/UPSTREAM-SYNC.md carries the procedure and the six standing resolutions,
each with the issue that decided it, so they are not re-argued every sync. The
parts that are not obvious from the outside, and that the 0.6.0 sync paid to
learn:
* THE AUDIT STEP. `git merge` takes upstream's side wherever only upstream
moved a region, so a function upstream ADDED to a file this tree owns
arrives with no conflict and no question. Reviewing the hunks cannot find
it — four reviewers read the same diff and each found a different subset.
That was eight runtime `gh` call sites in three files and two file types.
* THE SAME MECHANIC APPLIES TO STATE. A resolved region can remove a producer
whose consumers auto-merged, and those consumers degrade to empty rather
than erroring, so nothing goes red. Three such seams in one sync.
* VERIFY WHERE IT RUNS. "Green locally" was wrong three times, for three
different reasons: shellcheck-all lints TRACKED files so a new file's first
lint is meaningless; CI pins shellcheck 0.10.0; and the runner's jq 1.6
exits 0 where 1.7 exits 4 on `jq -e` with empty input — which was not a
test problem but a guard accepting an unreadable read.
* TEST THE MERGE RESULT. Forgejo tests heads, never what two branches produce
together, and two green PRs did produce a red tree in this sync.
* AFTER MERGING, CHECK THE SWEEP RECONCILED SOMETHING. The first post-merge
run was green and had done nothing.
.upstream-ref records the carried commit in machine-readable form beside the
CHANGELOG's prose. test/upstream-delta.test.sh asserts every forge-DECIDING
file is named in the inventory — offline, comment-aware, and refusing rather
than skipping when the ref is missing. Shim CONSUMERS are allowed by name, so
a seventh consumer is silent and a seventh decider is not.
docs/CONSUMERS.md now states that two ceremonies answer to the same version
number and how a consumer says which one it pinned.
Must-fail, both from the issue's test plan: scattering a forge_detect branch
into an unlisted file reds the guard; blanking .upstream-ref reds it too.
test/run.sh 29 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0,
actionlint, self-ref, marker, vendored and changelog-armed all clean.
Refs #200
@codex-reviewer-andresmgsl's second review, both points taken.
The refs action goes back to `forge_preflight || exit 1`. 97e63ac had it exit 0
with a notice so the PR check would not be red, and that conflated two
different questions: "this action cannot produce a verdict" is the ACTION's
contract and must stay a refusal, while "this check should not block the
board" is the CALLER's decision. The caller now carries it —
refs-guard.yml skips unless github.server_url is github.com, mirroring
forge_detect positively. A skipped check is a green head; an action that
reports success it did not earn is not. The leaked preflight_err temp file
goes with the revert.
The workflow guard asked the wrong question. `command -v gh` alone passes the
moment a Forgejo runner image happens to ship gh, and then dispatches against
a forge that cannot serve it — the client/forge mismatch forge_preflight
exists to prevent. It decides the FORGE first now, mirroring forge_detect
positively, and the binary second. The source guard splits to match: a
declaration guarded only by binary presence is reported, with a fixture that
fails on exactly that shape.
The warning text was also wrong on the facts, as noted: issue-event sweeps ARE
this caller's event-driven wakes, so they are precisely what is lost. It now
says the hourly scheduled sweep survives and every event-driven wake through
this caller does not, until #205.
Point 1 of that review — jq 1.6 accepting an empty payload — was already fixed
in 728102a, pushed before the review landed.
Verified under the runner's jq 1.6 as well as 1.7: 28 test files, 0 failed
both ways. shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker,
vendored, changelog-armed all clean with every file tracked.
Refs #198
`CI / test` was red at 97e63ac on a case that passes on this box: upstream's
own "an empty payload is refused". The cause is not the test.
`jq -e` disagrees with itself across versions on EMPTY input. jq 1.7 exits 4 —
no valid result was ever produced. jq 1.6 exits 0. Measured both ways today
against the same filter. This instance's runner image
(ghcr.io/catthehacker/ubuntu:act-22.04) carries jq 1.6.
So on this forge the guard #247 D3 added specifically to refuse an unreadable
read was ACCEPTING one: an empty body read as a valid issue payload, and the
sweep would have reconciled an issue from a payload it never received. The
test is upstream's, it is correct, and it passes on a GitHub runner — which is
why upstream never saw this.
The fix does not depend on jq's exit code for an input it never receives: the
payload is read, emptiness is decided in the shell, and jq judges only a
non-empty body.
Verified under BOTH jq versions, not just the one on this box: empty refused
and healthy accepted on 1.6 and 1.7, and the whole suite green under jq 1.6 —
28 test files, 0 failed — as well as under 1.7.
Refs #198
@codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was
the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in
labels.yml would have ridden in free — and it let the merge ship a step that
dies with `command not found` on every sweep on this forge, which #197's bar
does not permit.
The declaration mechanism already existed; a workflow simply could not reach
it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same
declaration actions/refs-not-closing carries, and the refusal that a script
gets from forge_preflight is inline here because a workflow has no shell to
call it from. The dispatch now warns by name, cites #205, and exits 0 rather
than reddening every sweep for a known gap.
So the guard needs no exemption list at all. It now requires the pair —
declared AND refusing — and reports a declaration that carries no refusal,
which is a permission slip for `command not found`.
That predicate was wrong on its first write, and its mutation test caught it:
`refuses_when_unavailable` matched the word `forge_preflight` inside
labels.yml's own comment explaining that it has NO forge_preflight to call. A
guard reading prose as evidence is the blind sweep again, in the guard written
to forbid it. Comments are stripped now, as gh_calls already stripped them.
Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the
strip reds two cases.
Blockers 2 and 3 were already fixed in 97e63ac, before either review landed.
test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin),
actionlint, self-ref, marker, vendored and changelog-armed all clean, with
every file tracked this time.
Refs #198
The first head's `Refs guard` failed on this PR, correctly: spec 4's
CEREMONY_FORGE_CLIENT=gh declaration made forge_preflight refuse by name on
this forge. But that workflow runs on every pull request here, so the
declaration as first written turns every future PR red until #199 lands —
blocking the board for a gap that already has its own issue.
Refusing and scheduling are different questions. This action must never
produce a verdict from a graph it did not read, and it does not: on a forge it
cannot speak it now says so by name, cites #199, states that no verdict was
produced, and reaches the forge zero times. A preflight failure for any other
reason stays fatal, and on a forge it CAN speak nothing changes.
Also: five SC2016 findings in test/no-runtime-gh.test.sh. They were invisible
locally because shellcheck-all.sh lints TRACKED files and the guard was still
untracked when I ran it — a new file is exactly the case that check cannot
see. Verified this time against CI's pinned shellcheck 0.10.0 with the file
committed.
test/run.sh: 28 test files, 0 failed, under CI's CEREMONY_REQUIRE_* env.
shellcheck, actionlint, self-ref, marker and vendored guards all clean.
Refs #198
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
Thirty-five fragments assembled into '## 0.6.0 — 2026-08-05'; VERSION to
bare 0.6.0; the three CEREMONY_SELF_REF carriers stamped "0.6.0" in this
one commit; the three docs/CONSUMERS.md availability markers cleared to
name 0.6.0 — refs-not-closing (#218), the RELEASES.md mirror entry (#248)
and the vendored-manifest completeness guarantee (#251).
drills/0.6.0.md opens with the measurement that decides its shape: the
doors-unchanged conditions do NOT all hold at this candidate, because
lib/changelog.sh moved on the release path since the last rehearsed tag
0.4.0. A full disposable-repo rehearsal is owed and is in progress; the
record is committed early and filled from the runs as they happen.
Refs #249.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.github/labels.conf` named five identities and every one of them 404s on
this instance — a GitHub-shaped team that was never minted here. Both
consumers of that roster were inoperable in consequence: `panel=` becomes
the required-verdict set, so a review round could never converge, and
`triage-actors=` is the arrival author gate, so every issue was a stray
mint owing `needs-triage` that nobody the conf recognized could clear.
Measured on #191: stamped `needs-triage` four seconds after mint by the
arrival path working correctly, then unclosable for the rest of the day.
The mapping is @andres's ruling, recorded on #191. `cluade` holds triage
and reviews but does not build; `codex` builds; `kimi` reviews; `grok`
comes off the bench; the human row is `andres`.
CONTRIBUTING's roster table moves with the conf, including the approval
count: panel-minus-author resolves to two on this roster, not three,
because the only builder is itself on the panel. The rule is unchanged and
still stated as panel-minus-author — only the number it currently comes to
is named honestly.
test/labels.test.sh now holds the conf and the table to the same set in
both directions. It cannot reach the half that actually broke — two files
agreeing with each other and neither with the forge — but it does catch a
roster edit that touches one file and not the other, which is how a
deliberate swap becomes a silent one.
Not touched, deliberately: drills/*.md, which record runs that really
happened under the old names; REVIEWER.md, whose old-name hits are
citations and a past-event anecdote rather than roster definition.
Refs #195
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
D3b corrected the window flag to fire on `claimed` too, PR in flight or
not; the fragment still described it as a `ready` non-member, which is
the reading the ruling struck.
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes#293.
lib/attention.sh had #267 D4's premise exactly — both reconcilers source
it, nothing release-side does — and [scope:release-flow] alone was a
wrong answer of the class that decision exists to correct. The sweep
workflow pair joins beside its trigger pair (detached in #209), the
shared-lib tests take scope:labels alone (a test inherits no lib/**
glob), and D4's seven enumerated rows land one each. No catch-all, by
decision: both directories span all four scopes. Each of the 13 new map
rows is protected by its own assertion — deleted alone, each reds
exactly its case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the mint-time membership call, the sink/source/subset invariants, and the release note while the contradictory successor-count criterion awaits triage clarification.\n\nCloses #292.
The state-machine fixtures bound BOT1/BOT2/BOT3 to REQUIRED_BOTS by index off
the shipped .github/labels.conf, so three fixtures silently required a
four-member panel=. Shrinking it to three left the third slot unbound and
set -u aborted the file before assertion 1: 217 assertions became 0, on main
and on every branch cut from it.
The fixtures now write their own conf, in test/labels.test.sh's shape, at all
three load sites (top of file, the #205 re-drafted-round block, and the
mutant_blockers subshell). One live-file case survives as a property — the
shipped conf parses and recuses each member from its own panel — with no
index and no expected size, and a copy whose panel= names nobody proves it
still has teeth.
Refs #304
Claiming a needs-ruling issue dated it through the assigned timeline
event, silencing the 7-day escalation nudge at exactly the moment a
builder started working through it. The ruling block now reads
last_issue_comment_activity (D1); the reclaim clock keeps the assignment
(D2) because there the assignment IS the claim; post-merge hands its
evidence read to the ruling block instead of reading again (D6, D7); the
claimed branch reads both clocks at its top, before anything it posts.
LABELS.md and lib/ruling.sh now say what each surface's clock reads
(D4, D5). The #257-era order compositions move to the comments read —
the timeline is no longer an input the issue clocks take, and its
unreadability no longer holds unrelated writes hostage; that narrowing
is pinned rather than implied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
changelog.d/253.md landed on main after this branch's point with the
cite trailing the period — the crew#309 shape, the fifth fragment to
arrive with it since #262 measured two. The criterion is that
changelog.d/ is clean at the head, and the head CI reviews is the merge
with main, which is where this one surfaced.
Refs #262.
The self-guards job checks out the merge of this branch with main, so
changelog.d/253.md from #285 reaches changelog_fragment_problem there and
nowhere else; the branch alone is green. Merged rather than rebased
because the round's reviewers track head SHAs.
Refs #262.
awk runs END on the way out of an exit from a main rule, so the length
row printed mid-file was followed by the citation row it outranks — the
internal protocol line landing inside the human-facing excerpt. Found by
claude-bot and kimi-bot in #262's first round, independently and with the
same reproduction.
The guard is the reported flag the empty-heading walk in this same
predicate already uses. The fixtures are the axis 57.md could not reach:
its over-bound entry is last, so only END's flush can print. 58.md puts
one before another bullet, 59.md before a heading and after a misplaced
cite. With lib/changelog.sh alone reverted they red, which is what the
green suite was hiding.
Refs #262.
Step 1 ruled what a rollup entry means and never said which entry to
read. A job in a cancel-in-progress group displaces itself, so a head
routinely carries a CANCELLED node beside the SUCCESS that replaced it,
and read by class alone that head is not green while checks_state calls
it SUCCESS.
State the collapse ahead of the classes it feeds: newest entry by start
time, and a CANCELLED entry is not the check's word while a non-cancelled
sibling stands at the same head. All-cancelled and pending are untouched.
Refs #276
Re-vendor tooling and docs-sync equivalents read the pin docs/VENDORED.txt
(available at 0.5.0 and later) instead of naming the doc set themselves, so
a new doctrine file reaches every consumer at its next ordinary pin bump
with zero list edits. A hardcoded list propagates nothing and its
staleness is silent: docs-sync --check asserts byte-identity for the files
the list names and says nothing about one it omits.
What makes reading the manifest sufficient rather than merely better is
the self-guard this PR adds, tagged unreleased until the first tag carries
it, per the RELEASES.md paragraph above it.
Refs #251
The three stamps, in one commit as the ceremony requires: VERSION goes
bare, the self-ref pin follows it, and the changelog section is the
assembler's output rather than a hand edit.
0.4.1 is the forge release. Everything in the section comes from #188's
single fragment: ceremony stops being gh-only. `lib/forge.sh` selects a
backend from the runner's own environment, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` implement one call surface twice, and the
reconcilers preflight before they sweep — so a GitHub-shaped client
pointed at a Forgejo instance is a named refusal instead of a sweep that
reads nothing and reports success.
Verified on this instance before stamping: the first post-merge `labels`
run on main (7fc9afe, task 467) came back SUCCESS — the reconciler's
first green run on Forgejo, and the evidence the section's claims are
not merely asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>