ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe - #19259
Conversation
Two gates were registered in the root manifest and called by zero workflows: `scripts/check-issue-citations.mjs` (delivered by #18223) and `scripts/check-merged-result.mjs` (delivered by #18338). Both cards' file surfaces excluded `.github/workflows/**`, so both devs correctly stopped and filed the wiring instead of widening. Three entry points, two lanes, two deliberately opposite postures: - `lint.yml` / `Lint & Repo Gates`: the DIFF-SCOPED citation verdict, blocking. Two commands in one step -- the manifest alias (which is the checker's own `--self-test` and nothing else) and then the live diff run. Wiring the alias alone would run the self-test twice and scan nothing. - `lint.yml` / `Lint & Repo Gates`: `check:merged-result`, blocking. Offline, no credential, sub-second. - `half-state-patrol.yml`: `--census`, REPORT-ONLY, four times a day. The census verdict moves on a motionless tree -- three numbers in its own control set went 404 in four days with no change here -- so a tree-wide blocking verdict would red a repository nobody touched. The lint job gains `issues: read`: an explicit `permissions:` block sets every unnamed scope to `none`, and a blocking gate must not depend on repository visibility for its transport. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
…st go red Temporary, and reverted by the next commit on this branch. The card's acceptance asks for the red/green measurement to be taken ON THE CI SIDE, not only on the gate side (#18223 already did the gate-side pair with disk evidence). This commit is the red leg: one citation naming a number beyond this board's allocation frontier, inside a declared surface (`packages/**/src/**/*.ts`, comment-prose projection). Expected: the `Issue citations this change adds resolve on the board` step in `Lint & Repo Gates` exits 2 and the job goes red. The next commit removes the line and the same step must go green. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
… real runner
The census step landed on a runner in this PR's own patrol run and reported
3,628 unresolvable citation sites. A full-scope read of the same tree reports
2,168. The difference is 1,460 — EXACTLY the `resolves-as-pull-request` tally,
so every citation naming a PR number was classified as a number the board
never had.
Cause: `GET /repos/{owner}/{repo}/issues` omits pull requests unless the token
holds `pull-requests`, and an explicit `permissions:` block sets every unnamed
scope to `none`. The same call is how the gate reads the allocation frontier
(`?per_page=1&sort=created&direction=desc`), so an understated frontier turns
later numbers into `never-issued` as well.
Both lanes take the row, read-only:
- `half-state-patrol.yml`: a report-only reading wrong by 67% is still a
machine-readable surface telling a lie.
- `lint.yml`: on the BLOCKING step it is worse than a wrong number — it is a
false red on a correct citation.
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…must go green The reset half of the CI-side ablation the card asks for. `packages/types/src/ index.ts` returns to the blob it carried before leg 1 (`0235d4e7d2ebe529c7101b5e1e004597b30c4554`), marker count 1 back to 0, and this branch's delta against `main` is again the two workflow files alone. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
… queue ahead of it This step's first queue build ejected the PR that added it, and the reason was neither the tree nor a crash: `exit 2` is this gate's own FINDINGS code, and the findings were real citations written by somebody else. MEASURED on queue entry `a7109d1f08`. A queue entry is built on the GROUP's base, which carries the entries AHEAD of it in the queue and has not landed on `main` yet. `merge-base origin/main HEAD` therefore lands at the PUBLISHED tip: `231283a6e` at 14:30:14Z, while the group's base `8271c81425` reached `main` only at 14:47:59Z. Everything between the two read as "added by this change" -- 15 file(s) / 16 citations judged, 3 unresolvable, and all three written by the two entries ahead: `#6361` twice from `ada701220` (#19364), `#18003` from `8271c81425` (#19363). Against the group's own base the same tree judges 0 file(s). The Governed Surface Queue Guard, in the same build, read `merge_group.base_sha` and correctly saw 1 commit and 178 changed lines. Two halves, and the second is not cosmetic: 1. `lint.yml` declares the base -- `OS_GATE_MERGE_GROUP_BASE_SHA`, the name and the expression this file already uses for that fact. It renders empty on `pull_request` and `push`, where the ref guesses are CORRECT and are kept: a PR's merge ref already contains the main it was computed against. The step is not `if:`-skipped on `merge_group` -- this file asserts that every gate step here runs there. 2. The gate verifies the base resolves before handing it to `git diff`. It did not: an unresolvable `--base` threw `fatal: bad object` and exited 1, a failed read wearing a code that is neither the clean answer, the findings answer, nor the refusal. It now refuses with PREREQUISITE NOT MET (exit 3) and names every spelling tried and what to pass instead. Half 1 alone is inert -- the pre-change gate ignores the variable entirely -- and half 1 is what makes an unverified base reachable, so both are required. Firing controls, on the real queue tree with `origin/main` pinned to the published main of 14:30:14Z: ref guess -> exit 2 with the three findings, reproducing the ejection; declared base -> exit 0; declared base absent from the checkout -> exit 3; `--base` absent -> exit 3 (was: uncaught throw, exit 1). `--self-test` covers all of it: 66 -> 73 cases, 7 batteries, and the `diff-scope` battery floor moves 6 -> 13 so the new cases cannot stop running unnoticed. Also corrects the gate docblock sentence this wiring falsifies ("Neither is installed here"). Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…re-issue-citations-to-ci
…re-issue-citations-to-ci
…tations-to-ci' into claude/issue-18224-wire-issue-citations-to-ci
PR #19225 made the half-state patrol callable: the patrol's steps now live in the composite action `.github/actions/half-state-patrol`, and the workflow `uses:` it. This branch adds the report-only `--census` leg of the citation gate to the OLD shape, so the two sides overlapped in two places. Conflict 1 (`on.pull_request.paths` + the `permissions:` note) is additive in both directions: the branch's `scripts/check-issue-citations.mjs` trigger row and main's `.github/actions/half-state-patrol/**` glob name different files, and main's rewritten `issues: write` paragraph is kept with this branch's `pull-requests: read` paragraph appended under it. Conflict 2 is not textual. Main emptied that region -- every step in it moved into the composite action -- and the census step is the one thing there that main did not relocate. It stays a step of the CALLER, and the placement is the argument rather than an accident: - `scripts/check-issue-citations.mjs` is objectstack-only, so inside the action its "Locate the patrol sources" step would either have to name it and refuse to run in every sibling that adopted the action, or not name it and fail on a missing file there. The repo-name gate only works in the caller. - The action runs its scripts from `steps.sources.outputs.root` (the tree the action ships from) while the board's checkout is `github.workspace`. A census of the WORKSPACE tree run from inside the action would, in a sibling, census this repo's release pages and report the count under the sibling's name. - `pull-requests: read` is granted by this workflow's `permissions:` block, which a composite action cannot carry and the action's inputs do not name. One behaviour had to be spelled rather than inherited: the step's guard is now `${{ !cancelled() && github.repository == '...' }}`. In the old shape the census sat above the only step that failed the job, so it ran whatever the sweep returned; the patrol is now one `uses:` step that goes red itself, and a default `success()` would have skipped the census on exactly the runs where the patrol is down. This is the guard main gave the closed-card sweep one step up, for the reason its own comment states. Nothing else changed: the blocking diff-scoped step in `lint.yml` and the `OS_GATE_MERGE_GROUP_BASE_SHA` declaration are untouched, and the census command stays a bare literal path so the gate derivation keeps seeing it. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
|
os-dev-report { Generated by Claude Code |
Fixes #18224
Two gates were registered in the root manifest and invoked by zero workflows:
scripts/check-issue-citations.mjs(delivered by #18223 / card #17512) andscripts/check-merged-result.mjs(delivered by #18338 / card #16287). Both cards' declaredfile surfaces excluded
.github/workflows/**, so both devs correctly stopped and filed thewiring rather than widening. This PR is that wiring.
The three entry points, and their postures
lint.yml·Lint & Repo Gatescheck:merged-resultself-testlint.yml·Lint & Repo Gatescheck-issue-citations --censushalf-state-patrol.yml(scheduled)The census posture is a ruling, not a preference, and the card carries the measurement that
forces it: #16783, #16786 and #16787 were measured RESOLVING on 2026-09-10 and 404 on
2026-09-14, with no change to this tree. A tree-wide blocking verdict would therefore red a
motionless repository because a third party deleted an issue. The diff-scoped half is the part
an author owns, it is small, and it is the only thing that stops 2,785 unresolvable sites
becoming 3,000.
The manifest alias is NOT the verdict
package.jsonmapscheck:issue-citationstonode scripts/check-issue-citations.mjs --self-testand nothing else -- the shape every credential-needing gate in this manifest uses
(
check:pm-half-states,check:pm-closed-card-sweep), because a live mode needs a board and acredential. Wiring that alias alone would have run the self-test twice and scanned nothing. So
the lint step holds two commands, self-test first:
The second spelling is lint.yml's documented gate-invocation idiom (
dispatch-gates.mjs's ownheader names it), and the first is what
check-self-test-wiredrequires of every script CI runs.check:merged-resultneeded no such split: the manifest key already ISnode scripts/check-merged-result.mjs --self-test, which is the whole gate.package.jsonis deliberately untouchedNo new manifest key was added. Both keys already exist and both are now named by
lint.yml, sothe wiring needs no manifest edit, and leaving the file alone keeps this PR textually disjoint
from PR #18414, which adds a key two lines from where a new one would have gone.
The non-step changes:
issues: readANDpull-requests: readThe
Lint & Repo Gatesjob'spermissions:block gainsissues: read. An explicitpermissions:block sets every unnamed scope tonone; this board is public today, but ablocking gate whose transport depends on repository visibility is a gate that goes red on a
settings change no file in this repo can assert. Read-only, one scope wide: the gate never
writes an issue, a comment, a label or an assignee.
Acceptance 2 -- both directions measured, on the CI side
PR #18223 measured the red/green pair on the gate side. This card asks for the same pair on
the CI side. Two instruments, both here.
A. The commits on this branch ARE the CI-side ablation.
test(ci): ABLATION LEG 1 of 2adds one citation naming a number beyond this board's allocation frontier, in a declared surface
(
packages/**/src/**/*.ts, comment-prose projection).ABLATION LEG 2 of 2removes it andrestores the file to the byte. The run on the first head is the red reading; the run on the final
head is the green one. Both run identifiers are recorded in the dev report on the card.
B. The exact command the new step holds, ablated locally with disk evidence. Three legs, run
from a committed state, each restored with
git checkout HEAD -- pathand proven by hash:Leg 3 is the control on leg 2: a green produced by a live board read, not by a run that never
asked the board anything.
Acceptance 3 -- where the census reports, how often, who pays
Written into the step itself, and repeated here:
::warning::carrying thesite count. Deliberately not the anchor issue: that body is owned end to end by
check-half-states.mjs's generator, and a second writer is how half a generated body goes stale.37 1,7,13,19) --plus any
workflow_dispatch, plus thepull_requestruns the paths filter admits. A row forscripts/check-issue-citations.mjswas added to that filter for the reason the file alreadygives for its two siblings: a step whose script can change without the trigger firing is a step
whose PR-time proof is a coincidence.
secrets.GITHUB_TOKENcore quota --the same 5,000/hour the job already draws the live sweep from. Four runs a day is roughly 636
requests/day, under half a percent of a single hour's allowance. No PAT and no cross-repo
credential, per this file's own standing rule.
The step is gated on
github.repository == 'objectstack-ai/objectstack', exactly as theclosed-card sweep above it is, because
half-state-patrol.ymlis copied verbatim into siblingrepos that do not carry this script -- a copy must skip the step, not fail on a missing file. It
is placed after the anchor write, unlike the closed-card sweep: the anchor is this patrol's
product, the job has a 15-minute timeout, and a report-only reading must never be able to starve
it. It always exits 0.
Acceptance 4 -- the 422 wall
This diff touches
.github/workflows/**, which is outside the PM seat's arming channel: theseat's
auto_mergeanswers HTTP 422 for this PR. It merges by a human. That is the samewall as PR #18096 and #18341, it is not a tool fault, and it must not be retried. No seat should
undraft this PR or arm auto-merge on it.
Note that
.github/workflows/**is not on theGOVERNED_SURFACESregister inscripts/pm/check-governed-merges.mjs, so the governed-merge machinery is not what holds thisone -- the 422 is.
Placement, and the three in-flight PRs on these files
Read before editing: #18414 (
lint.yml+package.json, green, awaiting a human merge),#19024 (
lint.yml), #19225 (half-state-patrol.yml, draft and frozen). Only additions here; noexisting step was moved, renumbered or reformatted.
lint.ymlthe two steps go above the#15149step-name-quoting step, which keeps thatstep's own documented placement ("immediately above" the duration-unit-keys step) true and keeps
the duration-unit-keys step last among the gates. feat(scripts): refuse an undeclared mode-160000 gitlink in the index #18414 inserts at the control-byte guard
(line ~411) and revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024 edits the typecheck lanes (lines ~5173 and ~6104), so all three hunks are
disjoint.
half-state-patrol.ymlthe census step goes between the summary publish and the finalfail step. ci(pm): make the half-state patrol callable instead of copied #19225 rewrites that file wholesale into a composite action and is frozen behind a
pm:blockedcard; a textual conflict there is expected and was accepted at dispatch.Measurement this PR does not relay
The card's prose carries three disagreeing counts for the manifest census. Re-measured on this
branch's base
0f42d36ff, with a firing control and a dark control:Both unreached keys are the two this PR wires, so the reading after this lands is 0.
Acceptance notes
check-self-test-wiredadmits a script when a workflow names it directly or through a rootmanifest alias, repo-wide rather than per workflow, so the patrol's census step needs no second
self-test invocation: the lint step above already runs it.
files[].Generated by Claude Code
Generated by Claude Code