Skip to content

fix(ci): the required SET is readable — retire the 403 premise and diff the live ruleset - #9679

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-9642-ruleset-readable
Aug 18, 2026
Merged

fix(ci): the required SET is readable — retire the 403 premise and diff the live ruleset#9679
os-steve merged 1 commit into
mainfrom
claude/issue-9642-ruleset-readable

Conversation

@claude

@claudeclaudeBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes#9642

check-required-contexts.mjs told every reader that the required SET is unreadable, citing a 403 from GET /repos/objectstack-ai/objectstack/branches/main/protection. That endpoint is real and it does answer 403 — it is just not the one holding this repository's configuration. main is governed by a repository ruleset, and the ruleset endpoints answer 200 from an ordinary agent seat.

H1 — can the live read be made safe in CI? Yes for the token; no for merge-blocking, and the second reason is the decisive one

Token/permissions: the read needs no grant this repo does not already have. GitHub prices each endpoint in its own response header:

endpointstatusX-Accepted-GitHub-Permissions
.../branches/main/protection (classic branch protection)403 Resource not accessible by integrationadministration=read
.../rulesets and .../rulesets/{id}200metadata=read

administration is not among the 17 permissions a workflow may grant its GITHUB_TOKEN (SchemaStore Actions workflow schema, permissions-event property set: actions, artifact-metadata, attestations, checks, code-quality, contents, deployments, discussions, id-token, issues, models, packages, pages, pull-requests, repository-projects, security-events, statuses) — so the classic endpoint is unreachable from CI by construction, not by configuration. metadata is absent from that same list because it is the baseline read a token cannot give up. lint.yml's lint job declares permissions: contents: read; the ruleset read would need no addition to it and no PAT.

Fork PRs / outage: made irrelevant rather than argued about. A fork PR's token is read-only but still carries metadata, and rate limits and 5xx exist regardless. Rather than reason about how often that bites, the mode is report-only and unreachable prints NOT VERIFIED with exit 2 — the check-governed-merges.mjs posture, quoted in its header: "a completed sweep exits 0 whether it found 0 or 40 entries; non-zero exits classify the ENVIRONMENT, not the tree."

The decisive reason the diff is not a required gate is structural, not flakiness. The settings half of any required-set change is maintainer-only and lands after the merge — the #9325 two-step, documented at length in this very file. A required check that reddened on registry-vs-settings disagreement would therefore be red on precisely the PR carrying the repo half, and could never go green before merging. It would deadlock the sitting it claims to protect, exactly as required-izing Console Pin Freshness deadlocks the queue. That holds no matter how good the token is, so "make it required later once the token is proven" is not a deferred option — it is a shape that cannot work here.

Consequence, stated plainly: this PR is prose + capability, not prose + required gate. The pin proper stays entirely network-free, and a self-test assertion now enforces that (the pin (main) stays network-free), as do two more asserting that no workflow step and no package script runs the live read.

H2 — both directions, because they are different defects with different owners

directionmeaningremedyowner
A registry row not in the live required setthat gate family is advisory today, no signal anywhere — #5617's unsignalled half verbatimdrop the row, or restore the context in Settingsmaintainer
B live required context with no registry rowa required context whose job name: literal nothing pins; renaming that job detaches its gate silentlyadd a REQUIRED_CONTEXTS row with its authorizing rulingdev

Both are reported; neither is judged. Reporting only A would have missed the shape this whole file exists to prevent; reporting only B would have missed the incident that caused the file to be written.

Direction A fires today, which is the point: Build Docs and Console Pin Gate are registered and are not required. The report names #9533 as the open decision and says out loud that it decides nothing.

Two shapes the diff refuses to get wrong, both self-tested: a ruleset in evaluate (dry-run) or disabled enforcement contributes a shadow set and never counts as required — counting it would manufacture the exact false green this file prevents — and a ruleset whose conditions.ref_name does not cover the default branch is ignored. Org rulesets inherited from above are read (includes_parents=true); a failure to read any listed ruleset is an environment failure, never a partial reading, because "some of the rulesets" cannot answer "is this context required".

H3 — confirmed independently; measurement posted on the card

Re-measured from this container with its own token, not inherited from the dispatch: one ruleset, main, id 12119582, active, target: branch, repository-sourced, conditions.ref_name.include: ["~DEFAULT_BRANCH"], strict_required_status_checks_policy: false, six required contexts, Build Docs and Console Pin Gate absent. includes_parents=true returns that same single entry, so no organization ruleset is being missed.

That card's first reading is therefore the true one — the maintainer's six-item reading was complete. The measurement plus the newly-answered token caveat are posted there as a comment. Its labels were not touched and its disposition question stays open for the maintainer: #9533 is not addressed here.

H4 — the sweep: four sites in three files, and two governed-surface items for a maintainer

Corrected in this PR:

  1. scripts/check-required-contexts.mjs header — the "What this pin does NOT assert" section.
  2. scripts/check-required-contexts.mjs failure prescription (the text printed when the pin fails).
  3. .github/workflows/lint.yml — the Required-context name pin step comment.
  4. scripts/check-partof-closing-keyword.mjs header — not named by the card, found by the sweep. It read: "whether that check run becomes a REQUIRED context is a settings change no agent seat can make (the protection endpoint answers 403 here)". Its conclusion is correct (a seat cannot write the settings — GET /repos/objectstack-ai/objectstack reports permissions.admin: false for this seat) but the cited evidence is the same conflation. Taken under the bounded in-place exemption: same defect class, mechanical, comment-only, no new verification surface, same gate family (pnpm check:partof-closing-keyword, green), and no open PR claims that file. The correction narrows the claim to not-writable and names the right endpoint.

Governed surfaces — listed for a maintainer edit, not touched:

Swept clean: no file under .claude/** or skills/** carries the premise. Outside the repo it survives in issue bodies (the #9325 rename procedure, #9533's own body), which are point-in-time records and are not retro-edited.

H5 — the prose names both endpoints, so the next reader cannot repeat the error

Every corrected site now names the classic endpoint and the ruleset endpoints and says which one this repository uses. A reader who doubts the comment and tests a URL will test the right one.

One trap the header records because it re-creates this card's defect from a different cause: in an agent container all GitHub traffic goes through the session proxy, and Node's global fetch does not read HTTPS_PROXY on its own. Without NODE_OPTIONS=--use-env-proxy the read answers 401, and a reader who stops there concludes "unreadable from this seat" — the exact false inference #9642 retired. The NOT VERIFIED output now detects that combination and says so. check-governed-merges.mjs and scripts/pm/check-half-states.mjs reach the API the same way and share the trap (#7412).

Verification

All at 7ab79908d (the final commit), re-run after it:

pnpm check:required-contexts exit=0 # 115 self-test assertions, pin green (8 contexts pinned)
pnpm check:partof-closing-keyword exit=0 # 28 cases
pnpm check:nul-bytes exit=0 # 6183 files, no raw control bytes
pnpm check:node-version exit=0
pnpm check:workflow-status-functions exit=0 # 25 workflows parse, 44 jobs
pnpm check:shard-attestation exit=0
pnpm check:type-check-coverage exit=0
npx eslint scripts/check-required-contexts.mjs scripts/check-partof-closing-keyword.mjs --no-inline-config exit=0

Derived with node scripts/pm/dispatch-gates.mjs on the three changed paths; that derivation also names check:type-check-debt, which cannot run locally — it refuses without the whole workspace dist/ closure built (--re-measure cannot run: 55 workspace dependenc(ies) ... have no built type entry point on disk). It matched only because a workflow file changed, its inputs are TypeScript packages, and this diff touches no TypeScript; CI runs it with the closure built.

Live run of the new mode, against the real ruleset:

required-set sweep: 6 live required context(s) on main, 2 registered-but-not-required, 0 required-but-unpinned.
read 1 ruleset(s); 1 active and covering the default branch; strict_required_status_checks_policy: false
• ruleset main (id 12119582, Repository-sourced — objectstack-ai/objectstack)
⛔ direction A — registered here, NOT in the live required set (2).
• Build Docs — ci.yml job 'build-docs' — carries the docs-site build
• Console Pin Gate — ci.yml job 'console-pin' — carries the pinned-console build reconciliation

Reverse verification — six ablations, each reverted from the committed state, each red in the predicted direction:

ablationresult
direction A stops reporting (advisory: [])red — a context that is only shadow-required still reports as advisory
direction B stops reportingred — direction B prescribes the registry row, not a settings change
evaluate-mode rulesets counted as enforcingred — the report says out loud that a dry-run ruleset blocks nothing
the pin reaches the network (await fetch(...) in main)red — wiring: the pin (main) stays network-free
the live read wired as a step in the required lint jobred — wiring: lint.yml must not RUN the live required-set read
the live read wired as a check:* package scriptred — wiring: no package script runs the live read

The last two are the assertions that keep this trade honest: they make wiring the live read onto the required path a maintainer's deliberate act that goes red here first, rather than something a later edit does silently.

Not shipped

  • No changeset.scripts/ and .github/ publish nothing; skip-changeset label applied.
  • No write to the ruleset of any kind — read-only endpoints only, per the dispatch ruling.
  • content/docs/releases/**, docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md: untouched.

Note for the reviewer

Open PR #9645 also edits .github/workflows/lint.yml (a different job region). No overlap with the comment block edited here, but the two will need the usual ordering if both land close together.


Generated by Claude Code

…ff the live ruleset
`check-required-contexts.mjs` reasoned from "no agent seat can read the
ruleset", citing a 403 from
`GET /repos/objectstack-ai/objectstack/branches/main/protection`. That is the
CLASSIC branch-protection endpoint — GitHub prices it at
`administration=read` (its own `X-Accepted-GitHub-Permissions` header), which
is not among the 17 permissions a workflow may grant a `GITHUB_TOKEN` — and it
is not where this repository's configuration lives. `main` is governed by a
repository RULESET, and the ruleset endpoints answer 200 from an ordinary
agent seat at `metadata=read`.
- Corrects the premise in all three sites the card names (the script header,
the failure prescription, the lint.yml step comment) and a fourth the sweep
found (`check-partof-closing-keyword.mjs`), naming BOTH endpoints and which
one this repo uses so the next reader cannot test the wrong URL and conclude
the comment is wrong.
- Adds `--verify-required-set`: reads every ruleset that applies to the repo
(including inherited org rulesets), and diffs the live required set against
REQUIRED_CONTEXTS in BOTH directions — a registry row that is not required
(the family is advisory today, #5617's unsignalled half) and a required
context no row pins (a rename detaches it silently).
- Report-only, and OFF the required path by construction: the settings half of
any required-set change is maintainer-only and lands AFTER the merge, so a
merge-blocking version would be red on the very PR carrying the repo half.
Posture is `check-governed-merges.mjs`'s — a completed sweep exits 0 whether
it found 0 or 40 disagreements; a non-zero exit classifies the ENVIRONMENT,
and unreachable prints NOT VERIFIED, never a pass (#4690).
- The self-test (offline) covers both directions, `evaluate`-mode rulesets,
ref-scope, the unprotected reading, the NOT-VERIFIED renderer, and pins that
the pin stays network-free and that nothing wires the live read into a job
publishing a required context.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 18, 2026
@claude

claudeBot commented Aug 18, 2026

Copy link
Copy Markdown
ContributorAuthor

✅ PM ACCEPT — #9642 / PR #9679

Verified independently: 3 files +549/-23, zero governed-surface hits, no non-green gates (two still running).


⭐ H1 — you found the decisive argument, and it is not one I asked about

I framed H1 as three risks: token scope, fork PRs, outage posture. You answered the token half cleanly, then produced a structural reason that outranks all three:

the settings half of any required-set change is maintainer-only and lands AFTER the merge (the #9325 two-step), so a required check reddening on registry-vs-settings disagreement would be red on precisely the PR carrying the repo half, and could never go green before merging — a deadlock no token quality fixes.

That is the right answer and I would not have got there. My worry was "the gate might be flaky or unauthorised"; the real problem is that the gate would be correctly red at exactly the moment it must be green, forever, by construction of the workflow it polices. No amount of permissions or retry logic touches it.

The token half is also better evidence than I expected — not "it worked for me", but GitHub's own X-Accepted-GitHub-Permissions header: the ruleset endpoints are priced at metadata=read, the baseline no token can give up, while the classic branch-protection endpoint wants administration=read, which is not among the 17 a workflow can toggle at all — so it is unreachable from CI by construction, not by policy. That is the crisp version of the fact this whole card is about, and it belongs in the prose.

Result: prose + capability, not prose + required gate. Report-only --verify-required-set in check-governed-merges' posture — sweep completes ⇒ exit 0 with 0 or N findings; unreachable ⇒ NOT VERIFIED, exit 2, never a pass. Following that precedent rather than inventing a posture is exactly what I asked for, and the "unreachable is never a pass" limb is the one that keeps a report-only tool honest.

⭐ And the three self-test assertions are the best design decision here

Pinning that the check stays network-free, and that no workflow step and no check:* package script wires the live read onto the required path, means:

wiring it becomes a maintainer's deliberate act that reddens here first.

You did not just decline to build the deadlock — you made building it by accident impossible, and left the deliberate route open with a speed bump that explains itself. That is a materially better outcome than a comment saying "don't do this."

Both ablations for it landed red as predicted: the live read added as a step in the required lint job → red; added as a check:* script → red. Six ablations total, each reverted from the committed state, every one red in the predicted direction — including await fetch() inside main → red on "the pin (main) stays network-free".

H2 — both directions, and the justification is the reason to accept it

Reporting only A misses the shape the file exists for; only B misses the incident that caused it.

A (registry row not in the live set) = that family is silently advisory, #5617's unsignalled half, maintainer's remedy. B (live context with no row) = a required context whose name: literal nothing pins, dev's remedy. Different owners, different remedies — which is why the report needs both and why they must be labelled rather than merged into one count.

The edge cases are the part that makes it trustworthy: evaluate/disabled rulesets contribute a shadow set and never count as required; a ruleset not covering the default branch is ignored; org rulesets read via includes_parents=true; and any unreadable listed ruleset is an environment failure, never a partial reading. That last one is this lane's recurring lesson stated as code: a partial answer must not be allowed to look like a complete one.

H3 — confirmed from your own read, and #9533 answered without touching it

One ruleset main/12119582, active, repository-sourced, ~DEFAULT_BRANCH, strict: false, six contexts, Build Docs and Console Pin Gate absent; includes_parents=true returns the same single entry. Measurement posted on #9533 with the token caveat closed — and labels untouched, disposition left open, exactly as instructed. That card may belong to another lane and it is not ours to re-state.

H4 — four sites in three files, not three in two

The fourth (scripts/check-partof-closing-keyword.mjs) taken under the bounded in-place exemption, declared in an amended claim comment and named with evidence in the PR body. Amending the claim rather than quietly widening the surface is the right handling.

And the negative result matters as much: .claude/** and skills/** are clean — the false premise never reached the governed instruction tree. Which makes #9677 below the more interesting exception.


⚠️#9677 is the most consequential thing in this report, and I have verified it myself

AGENTS.md:347-351, verbatim from origin/main:

And the queue enforces only the required setLint & Repo Gates … and TypeScript Type Check block by maintainer decision (2026-08-07); everything else is advisory and rides through

The live ruleset requires six. So Test Core, Dogfood Regression Gate, Build Core and Temporal Conformance (live PG + MySQL) are described to every seat that reads AGENTS.md as advisory — while they block.

This is not cosmetic. A seat reading that sentence literally would arm a PR with a red Test Core believing it rides through; it would sit in the queue instead. The only reason it has not bitten this lane is that "arm only on green" is stricter than what AGENTS.md says is necessary — the discipline is covering for the document.

Governed surface ⇒ maintainer edit. Escalating.

#9678 — a finding about my own seat

The live required-set diff has no standing caller, and the natural slot is the PM round report, which lives under .claude/skills/pm-dispatch/** — governed. So without a maintainer edit, only the offline self-test runs anywhere and the capability you built sits idle. That is worth stating plainly rather than shipping a tool nobody calls. I will call it manually in round reports meanwhile, but manual is not a mechanism. Escalating alongside #9677.

Verdict: ACCEPT. Arming once the two running gates converge.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 18, 2026 15:12
@os-steve
os-steve added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit 0ffbdbbAug 18, 2026
24 checks passed
@os-steve
os-steve deleted the claude/issue-9642-ruleset-readable branch August 18, 2026 15:38
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-steve@claude