Commit graph

174 commits

Author SHA1 Message Date
clad2
396744618f Merge remote-tracking branch 'origin/main' into build/199-refs-not-closing-rest 2026-08-05 17:19:11 +00:00
clad2
4e28d437d6 fix(refs-not-closing): gather over REST, so the guard produces verdicts here
The action's entire gather was one GraphQL query asking GitHub for its own
parse of the closing keywords. Forgejo serves no GraphQL at all — /api/graphql
404s here and a forgejo-runner job arrives with GITHUB_GRAPHQL_URL empty — so
there was nothing to translate it to. It is re-expressed, as #188 re-expressed
its own two GraphQL sites, over two reads both backends serve plus this repo's
own parser.

The graph was called authoritative for including "closing keywords and sidebar
links". Those halves resolve differently here: Forgejo has no sidebar-link
concept, so nothing is lost there, but it DOES honour closing keywords in
commit messages. A body-only port would miss a PR that closes an issue from a
commit subject — exactly the contradiction this action exists to catch — so
the closing set unions the body and every commit message.

The hasNextPage refusal is relocated, not dropped: --paginate carries the
forgejo backend's x-total-count completeness proof, and a short gather refuses
rather than returning a partial verdict.

lib/issue_references.sh extracts the LOCAL/CROSS classifier from
issueflow-reconcile's executable. closes_references.sh's header recorded that
dependency in prose; a composite action cannot source a reconciler to borrow
one function, because sourcing a reconciler runs one.

refs-guard.yml's github-only gate is removed in the same change. A portable
action behind that gate is a guard that passes by never running.

The contract test drives the boundary on BOTH backends with stubs at the
transport. Mutations: body-only parse reds 4 cases, dropping --paginate reds
the partial-gather case, ignoring a failed read reds 9.

Refs #199
2026-08-05 17:11:19 +00:00
clad2
262705d394 Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue 2026-08-05 16:29:10 +00:00
clad2
65cee3fdf9 Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue 2026-08-05 15:54:29 +00:00
clad2
1bf7091d39 Merge remote-tracking branch 'origin/main' into build/201-docs-sync-forge-source 2026-08-05 15:54:29 +00:00
5c924294bf Merge pull request 'docs/UPSTREAM-SYNC.md + .upstream-ref + the delta-inventory guard — the recurring sync, written from having just done one (#200)' (#208) from build/200-upstream-sync-doc into main
Reviewed-on: heavy-duty/ceremony#208
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 15:50:35 +00:00
0cb320b807 Merge pull request 'lib/forge-*.sh + labels-reconcile — forge_commit_at, because Forgejo serves a single commit at /git/commits/{sha} (#209)' (#212) from build/209-commit-at into main
Reviewed-on: heavy-duty/ceremony#212
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 15:49:58 +00:00
6874e76c04 docs(runner-probes): the checker binds the manifest to its target, and the snippets lint clean standalone (#202)
@codex-reviewer-andresmgsl, and he linted the published snippets DIRECTLY,
which my "parse, lint clean" claim had never meant.

1. THE CHECKER DID NOT CHECK THE TARGET. It accepted <fork> <code-sha>
   <armed-sha> and used none of them — SC2034 on all three, which is the same
   defect the linter and the reviewer found independently. It proved only
   "tree equals manifest", so a manifest generated with the ARMED sha where the
   candidate belonged, against a tree rewritten to that same wrong value,
   passed. Wrong-but-consistent is exactly what this gate exists to reject.

   Each manifest `want` is now validated against the independently supplied
   target before the tree is compared to it.

2. ONE ORDER, NOT TWO. Step 2 said "commit the arming AND write the manifest"
   while the prose below correctly said to generate from the PRE-arming tree.
   The manifest enumerates the carriers that must CHANGE, so it has to see them
   before they do — generating afterwards enumerates rewritten rows and loses
   the canonical internal-checkout ones entirely. The generator's first
   parameter is <candidate-checkout> now, and says so.

3. THE SNIPPETS LINT CLEAN STANDALONE. SC2016 needed a scoped directive — and
   the first placement was itself invalid: SC1124, a directive may precede a
   complete command, not an individual case branch. The checker's mktemp gets
   a trap.

Driven, the new controls:

  correct manifest + tree + target args        passes
  wrong fork, manifest AND tree consistent     refuses
  wrong candidate sha, consistent              refuses
  armed sha where the candidate belongs        refuses

plus every earlier class still red, and both snippets ShellCheck-clean when
extracted as an operator would copy them.

test/run.sh 28/28; repository shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 15:22:01 +00:00
20f4b287f7 docs(runner-probes): the generator survives a one-layer probe, callers carry full coordinates, one domain (#202)
@codex-reviewer-andresmgsl drove the published commands again and found three.

1. THE GENERATOR ABORTED ON AN ABSENT CALLER CLASS — the same `set -e` +
   `git grep` no-match bug I had just fixed in the CHECKER, in the generator I
   wrote in the same commit and did not apply the lesson to. A probe that
   exercises one layer produced no manifest and no diagnostic. `|| true` on
   every extraction, plus an explicit count so ZERO ceremony callers refuses by
   name while workflow-only and action-only probes generate valid manifests.

   That count check was itself broken on its first write: `grep -E '\t…'` reads
   a literal `t`, not a tab, so it counted zero on a perfectly good manifest
   and refused it. Found by running it.

2. CALLERS CARRY THE COMPLETE COORDINATE. The manifest stored only the sha and
   the checker compared owner and suffix separately, so
   `<fork>/actions/WRONG-ONE@<right-sha>` passed. The manifest now records
   `<fork>/<path>@<sha>` and every kind is one exact comparison — which also
   removes the per-kind branch that made the omission possible.

3. GENERATOR AND CHECKER SHARE ONE DOMAIN. `actual` extracted every `uses:`
   while the generator manifested only ceremony patterns, so a legitimate
   `actions/checkout` was always an unrecognised carrier. Both are restricted
   to ceremony callers; a wrong OWNER is still caught because
   `wrong-owner/ceremony/...` is still a ceremony caller.

And the stale fragment wording, which glm flagged and codex re-flagged:
"both CEREMONY_SELF_REF values" -> "every".

DRIVEN, all of it:

  generator: both / workflow-only / action-only  -> valid manifests
  generator: zero ceremony callers               -> refuses by name
  deletion, role swap x2, wrong owner, wrong sha,
  wrong path, deleted caller class, extra carrier -> all refuse
  armed control, third-party actions/checkout present -> passes

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 15:07:11 +00:00
a55fbaef15 fix(forge): forge_commit_at — Forgejo serves a single commit at /git/commits/{sha} (#209)
Found by the first post-merge sweep after the 0.6.0 merge — #198's own
acceptance probe — not by review. Three PRs in one run:

  labels: #208: could not read the head commit's date:
    forge_api: HTTP 404 from 'GET repos/heavy-duty/ceremony/commits/f3a1336…'
    — blocker:unrequested not judged this pass

Measured against this instance:

  forgejo  repos/{o}/{r}/commits/{sha}       -> 404
  forgejo  repos/{o}/{r}/git/commits/{sha}   -> 200, date under `.created`
  github   repos/{o}/{r}/commits/{sha}       -> 200, date nested

A fourth asymmetry, alongside the three lib/forge-forgejo.sh's header already
records. #198 ported this call site onto the shim with GitHub's path unchanged
— correct against GitHub, and the block it lives in (#236 D2) arrived WITH the
merge, so nothing here had ever executed it.

So it becomes a verb rather than a path at the call site: the caller wants one
timestamp and should not have to know either shape.

Cost while it stood was bounded and loud rather than silent — guarded_read
refused and the sweep said so — but blocker:unrequested could never be judged
on this forge.

The tests pin each backend's PATH and FIELD, because a stubbed forge_api cannot
catch a wrong path; that is exactly how this shipped and why it took a live
sweep to find. Swapping the paths reds the forgejo pair; swapping the fields
reds the github one.

test/run.sh 28/28 under jq 1.7 and jq 1.6; forge-backends 124/124; shellcheck
0.10.0 and actionlint clean.

Refs #209
2026-08-05 15:01:32 +00:00
745944ec8c docs(runner-probes): the arming gate is a manifest comparison, driven against all six failure classes (#202)
@codex-reviewer-andresmgsl's four holes and @glm-reviewer-andresmgsl's prose
staleness. Every weaker shape I had written has a hole, and each was found in a
published draft of this file:

  "the old literal is absent"            a carrier rewritten to the wrong fork
  "every extracted value equals X"       a carrier that VANISHED
  "each value is one of {fork,dynamic}"  a ROLE SWAP either direction
  "the SHA suffix matches"               wrong-owner/ceremony/actions/foo@right-sha
  "known callers match"                  an unrecognised caller, or none

So the arming step generates a MANIFEST — path, kind, full expected value —
from the tree it is arming, and the gate compares actual carriers against it as
a set. All six become one kind of failure: the sets differ. Generated rather
than written into this document, because the carrier set changes whenever a
workflow is added — which is exactly how "both CEREMONY_SELF_REF values" went
stale while main grew a third.

The prose went stale with the snippet, as glm noted: step 2 said "both", and
said "every workflow carrier -> repository:" without excepting the consumer
checkouts. Both corrected.

DRIVEN, not asserted. I built an armed/probe pair and ran every class:

  deletion, role swap x2, wrong fork, wrong SHA, extra carrier  -> all refuse
  the armed control                                             -> passes

Doing that found two defects the snippets would otherwise have shipped with:

  * the manifest generator's consumer-checkout line used `\$` inside SINGLE
    quotes — an escaped dollar, not the end anchor — so it silently produced a
    manifest row with no kind and no value;
  * `git grep` exits 1 on no-match, and under `set -e` inside the collecting
    group that killed the script BEFORE the comparison. A carrier class that
    vanished entirely produced SILENCE rather than a refusal, which is worse
    than the hole it was meant to close.

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 14:53:30 +00:00
bada4ffff5 test(issueflow): each of the three sites is caught by behaviour, not only by the pin (#210)
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.

1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
   it has to, because the #188 warning that explains why has("pull_request") is
   wrong contains the string. Without controls that was an untested property,
   and the pressure it creates is real: a raw grep would push a builder into
   deleting the very warning that prevents recurrence. Two fixtures now prove
   it: the explanatory comment is allowed, an executable jq filter is rejected.

2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
   pin proves a string is absent, not that each replacement means the intended
   thing:

     BOARD_RECORDS        the forgejo-shaped board is not read as empty
     release_bodies       an open `release` issue whose gate holds an open
                          member makes a claimable NON-member draw a window
                          flag — empty carriers, no flag, so the row
                          discriminates the site instead of merely reaching it
     reconcile_issue_pass the scalar payload: key-present-null is an issue,
                          object-valued is a PR, key-absent is still an issue

   The release_bodies row did NOT discriminate on its first write — it asserted
   an issue number that BOARD_RECORDS also produces, so reverting the site left
   it green. Caught by mutating each site separately rather than trusting the
   suite total.

Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.

test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.

Refs #210
2026-08-05 14:37:37 +00:00
877e09e015 fix(issueflow): the board discriminator is .pull_request == null, not has() (#210)
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:

  issueflow: no open issues.
  issueflow: reconciled.

over a board of nine.

Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.

This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.

Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.

Two guards, because a comment did not hold:

  * A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
    the key. The existing discriminator cases assert jq expressions in
    isolation and passed throughout this regression; they never ran the gather
    that uses them, which is precisely how it survived review.
  * A SOURCE PIN forbidding has("pull_request") on this surface, so a future
    sync cannot reintroduce it 40 lines below the comment forbidding it.

Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.

test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.

Refs #210
2026-08-05 14:27:40 +00:00
dc87051c69 docs(runner-probes): enumerate the real carriers, spare the consumer checkouts, and make the snippet run (#202)
@codex-reviewer-andresmgsl's three, all verified against current main before
fixing.

1. THERE ARE THREE SELF-REF CARRIERS, NOT TWO — labels-sweep.yml:52,
   labels.yml:51, release.yml:132. My `[ "$n" -eq 2 ]` came from the
   pre-upstream tree, so it would have REJECTED a correctly armed candidate and
   told the operator to rewrite two of three, leaving one workflow pinned to
   the tag. The gate enumerates from the tree now, with the derivation commands
   beside the table so the list is re-checked rather than trusted.

2. NOT EVERY `repository:` BELONGS TO THE FORK. Three are
   `${{ github.repository }}` — labels-sweep.yml:69, labels.yml:92,
   release-exercise.yml:72 — and they fetch the CALLER's repository. My loop
   required every one to equal the fork, which would have rewritten the
   consumer checkouts and quietly changed what the probe exercises. Internal
   self-checkouts (four) are asserted to be the fork; consumer checkouts are
   asserted to stay dynamic.

3. EACH CHECK IS BOUND TO THE TREE IT IS ABOUT — `git -C "$armed"` for the
   carriers, `git -C "$probe"` for the callers, instead of depending on the
   operator's current directory. And `mapfile` rather than `git grep | while …
   fail`: the loop ran in a pipeline subshell, so `fail` exited the subshell
   and the gate carried on. Collect first, validate after, under a declared
   `set -euo pipefail`.

And the snippet is now executable rather than illustrative: placeholders became
positional parameters, so it parses, is shellcheck-clean, and runs. Driven
against the unarmed tree it refuses with `CEREMONY_SELF_REF=0.6.0` — a tag
rather than the candidate SHA, which is exactly the case it exists to catch.
Publishing a gate that could not run would have been the same defect one level
up.

Branch updated from merged main (e236318). test/run.sh 28/28; shellcheck 0.10.0
and changelog-armed clean.

Refs #202
2026-08-05 14:13:40 +00:00
8c3c37d412 Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue 2026-08-05 14:11:05 +00:00
a48cc719a4 fix(upstream-delta): discovery is git's index, not the filesystem (#200)
@codex-reviewer-andresmgsl reproduced it again, with one file: scanned_paths()
said "tracked" and used `find`, which walks the working directory and knows
nothing about the index.

Not pedantry — ci.yml extracts shellcheck.tar.xz, actionlint.tar.gz and their
binaries INTO the checkout before the suite runs, and any developer cache sits
there too. Today none happens to carry a matching marker; that is luck, not a
property, and a false red on a downloaded tarball would be indistinguishable
from a real finding.

`git ls-files -z` makes "tracked" executable rather than prose.

The fixtures become tiny git repositories, because a fixture that is only a
directory is invisible to ls-files and every must-fail below it would have
passed vacuously — the same trap as the earlier teeth that never invoked the
guard. Plus the negative case he asked for: an untracked marker-bearing cache
file is ignored, and the moment it is TRACKED the guard sees it.

Reverting discovery to find reds three.

Branch updated from merged main (e236318, now carrying !206) before verifying:
upstream-delta 28/28, test/run.sh 29/29, shellcheck 0.10.0 clean.

Refs #200
2026-08-05 14:10:56 +00:00
f8f318c634 Merge remote-tracking branch 'origin/main' into build/200-upstream-sync-doc 2026-08-05 14:08:45 +00:00
e236318647 Merge pull request 'lib/forge-forgejo.sh + labels-reconcile — label removal is a full-set PUT, and a write that did not happen fails the sweep (#192)' (#206) from build/192-label-write into main
Reviewed-on: heavy-duty/ceremony#206
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 14:07:59 +00:00
7e02344672 docs(runner-probes): the arming gate asserts what each carrier IS, not that a literal is gone (#202)
@codex-reviewer-andresmgsl: absence of the canonical coordinate is not proof of
correct arming. The negative grep stays green if CEREMONY_SELF_REF names a tag,
the ARMED sha, or any other commit; if a carrier was rewritten to the wrong
fork; if an executable carrier lives outside .github; or if a carrier simply
disappeared rather than being rewritten.

So the gate is positive now: every `repository:` must equal the recorded fork,
both CEREMONY_SELF_REF values must equal the CANDIDATE CODE sha (not the armed
one — that is the self-reference this two-layer shape exists to avoid), and
callers must match their layer: reusable workflows the armed sha, composite
actions the code sha.

With a COUNT beside the comparison. `n -eq 2` is the part that catches a
carrier which vanished, which a per-value loop cannot see — the same shape as
counting the call sites a pin is guarding rather than only checking the ones
that are there.

The canonical-coordinate grep stays as a cheap extra rather than as the proof.

Wording, same review: steps 1 and 2 advance the tip of ONE fork branch, so
reset removes that branch, not "candidate and armed branches".

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 14:02:08 +00:00
f3a1336d42 fix(upstream-delta): discovery derives from the tree, not from a glob list (#200)
@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
2026-08-05 13:58:32 +00:00
e27acd8ab9 docs(runner-probes): arming is two layers, because a commit cannot contain its own SHA (#202)
@codex-reviewer-andresmgsl found that the procedure was not executable as
written, and the reason is structural rather than a wording slip.

The candidate's workflows carry `repository: heavy-duty/ceremony` beside
`ref: ${{ env.CEREMONY_SELF_REF }}`, so arming must rewrite them. But
rewriting CREATES A NEW COMMIT, and a commit cannot embed its own object ID. So
a single-layer arming is self-referential: pin the callers to the pre-rewrite
SHA and they load the UNARMED workflows; pin them to the post-rewrite SHA and
you are asking that commit to contain itself. My step 3 asked for exactly that.

Two layers, stated as a table because the distinction is the whole thing:

  candidate code SHA   the immutable tree under test — actions/, lib/
  armed workflow SHA   a child commit whose workflows point at the fork and
                       whose CEREMONY_SELF_REF is the candidate code SHA

And callers pin by layer, because they are not the same thing: composite
actions to the candidate code SHA, reusable workflows to the armed SHA, which
is the only revision whose inner checkout is rewritten.

The completeness check becomes a mechanical non-zero gate — `git grep` for
executable `uses:`/`repository:` carriers over the ARMED tree, exiting non-zero
on any hit — rather than "every remaining hit must be prose". A partial rewrite
does not announce itself: it silently tests canonical main.

The result issue records both SHAs, not one, or a later reader cannot tell
which tree answered.

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 13:54:44 +00:00
634e7a3528 fix(upstream-delta): the object is mandatory, the scan covers every governed surface, and the teeth drive the real guard (#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
2026-08-05 13:51:21 +00:00
f3f7538d15 docs(upstream-sync): stale in-flight branches, and auditing post-merge runs by executed steps (#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
2026-08-05 13:42:21 +00:00
e61bb91476 docs(runner-probes): its own document, an arming procedure, and the evidence boundary made consistent (#202)
@codex-reviewer-andresmgsl's four gaps.

1. BRANCH UPDATED TO CURRENT MAIN. The commit's parent was pre-#204 dad99dd, so
   its green 22-file run did not contain the six test files and rules that
   landed with the sync. Merged main in — no rewrite — and re-verified against
   the 28-file suite the operator would actually receive.

2. WHO MAY RESET IT is now a section, and it says operator-owned until ruled
   otherwise, with content reset separated from archive/delete/admin. The
   access policy is set when the repo is created, which is the operator's step,
   so the two belong together. Flagged for @andres rather than assumed.

3. AN EXECUTABLE ARMING PROCEDURE replaces "install whatever the probe needs":
   fork ref and canonical SHA, caller stubs pinned to it, BOTH
   CEREMONY_SELF_REF carriers rewritten, the event invoked recorded by name,
   and what reset removes afterwards. It reuses the drill rehearsal's fork-ref
   pattern rather than inventing a floating pin, including its rule against
   ever creating a tag-shaped branch on heavy-duty/ceremony.

4. THE EVIDENCE CONTRADICTION IS RESOLVED. "Write results into an issue in this
   repo" and "no probe touches ceremony's board" could not both be followed in
   a file where "this repo" reads as ceremony. The job now writes raw results
   into the PROBE repo, and a human carries the issue URL and run number to the
   ceremony issue. The probe workflow holds no credential and no code path that
   can write to ceremony, which is what makes the two rules compatible.

Placement: the operational contract moves to docs/RUNNER-PROBES.md, with a
short cross-link in drills/README.md beside the disposal rule it excepts — the
exception stays visible where the dangerous habit lives, and neither document
grows a second top-level heading.

test/run.sh 28/28 on the updated branch; shellcheck 0.10.0, actionlint,
self-ref, marker, vendored and changelog-armed clean.

Refs #202
2026-08-05 13:40:28 +00:00
2037275a9c Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue 2026-08-05 13:38:13 +00:00
e965b15cbf docs: the recurring upstream sync, its standing resolutions, and a guard on where the delta lives (#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
2026-08-05 13:36:17 +00:00
08714530b3 docs(drills): the standing runner-probe venue, and why it is not a drill (#202)
@andres ruled option A (#5631): one standing never-archived repo. This is the
runbook half.

The distinction the document exists to make: a drill is disposable by design
and ends with the builder archiving it. This venue is the opposite — it exists
so that runner-only facts can be measured on demand, and archiving it defeats
the purpose. That is not hypothetical: all three drill repos were archived
correctly, by the rule, and each then had to be un-archived or replaced. The
request came three times in two days across #192 and #198 and never became
anything.

What the runbook pins, all of it measured rather than asserted:

  * a probe MUST run as an Actions job under ${{ github.token }} — the same
    DELETE answers 500 there and 204 under a PAT, so a probe run any other way
    produces a confident wrong answer;
  * probe results are written into the forge, not left in a job log, because
    logs age out and #192's run 701 survived only because it wrote into an
    issue;
  * no probe touches ceremony's own board — the venue exists so the live board
    is not the fixture;
  * the three probes it already owes (#192's live label lift, #205's dispatch
    measurement, a 0.6.0 consumer exercise after #198).

STANDING THE REPO UP IS THE OPERATOR'S STEP, and this is the part I could not
do rather than the part I chose not to. Measured today with this identity:

  POST /api/v1/orgs/heavy-duty/repos  ->  403  not allowed in organization
  POST /api/v1/user/repos             ->  201  personal namespace only

Same shape as the drill delete: a deliberate boundary, not a misconfiguration.
The runbook says so, says not to retry it, and says not to work around it by
using a personal namespace where the org's runner and secrets do not reach.

test/run.sh 22/22, shellcheck 0.10.0, actionlint, self-ref all clean.

Refs #202
2026-08-05 13:16:20 +00:00
a35a77f752 fix(forgejo): a read failure names its verb too, and the tests assert the whole diagnostic (#192)
@codex-reviewer-andresmgsl caught a test that describes evidence it does not
collect — mine, and it is the class this PR is about.

Two cases were titled "naming the verb, path and status" and asserted only the
substring "500". The PUT boundary happened to satisfy the contract because
forgejo_write already passes "PUT $endpoint" to forgejo_http_ok. The GET
boundary did not: the diagnostic was `HTTP 500 from 'repos/o/r/issues/5'`, with
no verb at all — so a caller could not tell a failed READ from a failed WRITE
of the same path, and #192's acceptance criterion asks for exactly that
distinction.

Reads now pass "GET $endpoint" on both non-paginated and paginated paths, and
the two tests assert the complete expected diagnostic as one substring rather
than a status code that any failure would contain. Reverting the verb reds the
GET case.

Also, per the same review: the failed GET is asserted to write nothing, and the
failed PUT to have attempted exactly one write.

forge-backends 117/117 (was 115), test/run.sh 22/22 under jq 1.7 and jq 1.6,
shellcheck 0.10.0 and actionlint clean.

Refs #192
2026-08-05 13:11:33 +00:00
062e016a42 fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.

1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
   and then re-resolved every preserved label through the repository-wide list
   — so preservation depended on a paginated read with nothing to do with this
   issue, and an incomplete one would drop a bystander. It now keeps
   name<TAB>id from the issue payload, subtracts removals by name, and resolves
   ONLY added names. Fixture: a bystander on the issue with id 14 that is
   absent from the repo-list fixture entirely must still survive the PUT.

2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
   the write proved the sweep reached the forge. The GET already proves that,
   and replacing a set with itself opens ceremony#128's window for no state
   change — most calls here are exactly this case, since the reconcilers call
   --remove-label unconditionally. Short-circuits when the wanted set equals
   the current one. This was the policy-shaped choice flagged for @andres; the
   reviewer's reasoning is better than mine was.

3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
   state edit, so clearing `merge-next` and both `stale` edits could fail into
   the generic per-PR branch and still finish `reconciled.` and exit 0. All
   four sites go through one `label_write` helper, so a future call site cannot
   reopen it by forgetting to mark itself. Probe: a failed NON-primary write
   (unstale on a blocked PR) must fail the sweep.

4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
   "reconciled.", which a log-tail consumer greps for. The line is now
   "sweep incomplete", and the test asserts the whole output is free of the
   token rather than only of the success prefix.

Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.

Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.

forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.

Refs #192
2026-08-05 13:03:25 +00:00
018489ac4d chore(changelog): the 192 fragment's citation is terminal (#192)
Found by merging this branch onto !204 and running the suite there — not by
anything visible on this base. The terminal-citation rule (#262) ARRIVES with
the 0.6.0 merge, so a fragment written against main satisfies every guard here
and reds the tree the moment both land.

'(ceremony#128) (#192)' is two groups; exactly one must end the entry. The
reference moves into prose.

Refs #192
2026-08-05 12:54:29 +00:00
0f20f4b6ef fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.

THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.

Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.

So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.

THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.

A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`

The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.

Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.

test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.

Refs #192
2026-08-05 12:48:09 +00:00
adf907c963 fix(198): the action fails closed, the caller decides scheduling, the guard decides the forge (#198)
@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
2026-08-05 12:30:28 +00:00
728102a3ba fix(issueflow): issue_payload_valid refuses an empty payload on jq 1.6 too (#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
2026-08-05 12:22:20 +00:00
06f05aebec fix(198): the workflow declares and refuses instead of being exempted by name (#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
2026-08-05 12:16:53 +00:00
97e63acef0 fix(refs-not-closing): report and skip on a forge it cannot speak, rather than reddening every PR (#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
2026-08-05 12:09:05 +00:00
e035130f65 merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#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
2026-08-05 11:56:23 +00:00
e0cd0cb7a3 fix(docs-sync): the mirror is fetched from the forge in play, never a built-in one (#201)
heavy-duty/ceremony exists on two forges and the same ref names a different
tree on each — this forge's 0.4.1 carries lib/forge*.sh, GitHub's carries none
of it. The fetch URL was hard-coded to github.com, so a consumer's `.ceremony/`
mirror was verified against a tree it never pinned, and the fetch returned
HTTP 200 while doing it: --check reported drift the consumer could not fix,
and --fix would have rewritten a correct mirror into the wrong one.

The host now comes from GITHUB_SERVER_URL, which Actions injects on both
forges and which lib/forge.sh already selects the whole backend on. Unset,
with no --source, is a refusal naming the variable rather than a guess —
the same rule the pin itself has always followed.

The fetch path had no test coverage at all: every existing row passes
--source, which overrides the fetch entirely. It is now driven against a
PATH-stubbed curl that records the URL and serves a tarball, so the real tar
pipeline still runs and which forge a pin resolves against is asserted.

Refs #201
2026-08-05 11:16:23 +00:00
cndgrr
fb8f8282a9 release: cut 0.6.0
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>
2026-08-05 00:01:03 +00:00
cndgrr
8080618a70 changelog.d: the README is rewritten whole from the current tree 2026-08-04 20:17:30 +00:00
cndgrr
3af132edf7 changelog.d: the claimed-branch ruling pre-read is pinned
Grouped shape, terminal cite, 287 characters.

Refs #307
2026-08-04 19:44:42 +00:00
c74f31829d fix(labels): panel and triage name identities that exist on this forge (#195)
`.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>
2026-08-04 19:09:16 +00:00
cndgrr
20e35b15da the release note says unblocked, not ready
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.
2026-08-04 19:03:24 +00:00
cndgrr
bdcc211a0b the sweep flags what the window and collision rules forbid
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.
2026-08-04 18:15:00 +00:00
Daniel Marin
2807d34c74
Merge pull request #308 from andriujoseba/build/292-window-graph
docs: make standing release windows explicit graphs
2026-08-04 18:52:48 +01:00
dan-claude-bot
f5bac7c7ee fix: the labeler map learns lib/attention.sh and the surfaces it never knew (#302)
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>
2026-08-04 17:28:37 +00:00
Andriujose
ad5d988563 Merge remote-tracking branch 'origin/main' into build/292-window-graph 2026-08-04 17:28:26 +00:00
Andriujose
830a643a4b docs: make standing release windows explicit graphs
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.
2026-08-04 17:12:48 +00:00
cndgrr
66a0eb5d65 test(labels-reconcile): fixture roster replaces the live panel by slot
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
2026-08-04 17:04:09 +00:00
Daniel Marin
97cc00e268
Merge pull request #303 from dan-claude-bot/build/284-ruling-clock-comments-only
fix: the issue-side ruling clock reads comments only — an assignment is the claim's fact
2026-08-04 17:49:57 +01:00
Daniel Marin
7e52e35596
Merge pull request #301 from andriujoseba/build/282-triage-slim
docs: slim TRIAGE.md incident narratives
2026-08-04 17:37:33 +01:00