Skip to content

feat(tooling): ban the whole-set label PUT in every spelling - #11880

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-10778-whole-set-label-put-gate
Aug 24, 2026
Merged

feat(tooling): ban the whole-set label PUT in every spelling#11880
os-steve merged 1 commit into
mainfrom
claude/issue-10778-whole-set-label-put-gate

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#10778

#10703 made both label writers in pr-automation.yml additive, removing the two whole-set PUT /issues/{n}/labels writes. It could not make the verb unavailable: nothing stopped a newly added third-party labeler, or a second workflow calling the endpoint directly, from reopening the same defect — and until this PR the whole guard was a prose paragraph in that workflow's header plus one script's self-test.

The second-order cost is the expensive one, and it is why the verb is worth banning rather than merely avoiding: while a whole-set write is reachable, "the label is absent" stops meaning anything, because absence has two causes (cleared deliberately, or erased by somebody's PUT) and read-back is the only detection there is.

What lands

scripts/check-whole-set-label-write.mjs, wired as a direct node scripts/... step in lint.yml (root package.json is inside the @changesets/cli v3 fence, #9465 — same shape as the other node scripts/... steps in that lane). It asserts the card's three assertions over .github/workflows/**, .github/actions/** and scripts/**:

  1. no PUT against the labels endpoint, in any spellingcurl -X PUT, gh api -X PUT / --method PUT, octokit.request('PUT /repos/...'), a method: 'PUT' fetch options object, and issues.setLabels, which is the same PUT under an SDK name;
  2. no uses: of an action measured to write the whole setcodelytv/pr-size-labeler and actions/labeler, keyed by owner/repo so no version slips the ban, each carrying the source read recorded on The PR-size labeler's whole-set PUT erases a seat-applied skip-changeset one second after an additive POST — measured loss, and the only tracker is prose in a closed card #10703 as its reason;
  3. an allowlist entry requires a stated reason — and run()refuses (exit 2) on an entry without one, so this is enforced in CI rather than only in the self-test.

The population was measured BEFORE the gate was written

Per the dispatch ruling, because an enforcing gate over a violating population cannot land green:

limbmeasured on origin/main @ 387e23138
PUT in executable content0
PUT in comments/prose9 (3 files — the headers that document the ban)
uses: of a known whole-set labeler0 — neither is pinned any more; #10703 retired both
allowlist entries needed0

So no violator had to be fixed and nothing had to be allowlisted. The allowlist ships empty, which is the honest state and the strongest one.

⚠️The Zone-2 assumption about pinned versions is moot, not confirmed.codelytv/pr-size-labeler and actions/labeler are not pinned anywhere in this repo, so there is no "version pinned today" to re-read. The roster is therefore forward-looking — it is what stops a reintroduction. A future version that is genuinely additive is an allowlist entry with that measurement as its reason, never a deletion from the roster.

⚠️ The Auto Label attribution on this card is falsified

The card's dispatch escalated on a 2026-08-23 measurement reading that the Auto Label workflow's whole-set PUT erased needs:contract-review on PR #11470 — a "second perpetrator". That comment measured the effect (a label disappeared) and said so explicitly; the mechanism was assumed. Confirmed in source and in the timeline API, it does not hold:

This does not weaken the card — its actual premise ("no gate bans the verb") was true and is what this PR fixes. It does mean the gate would not have prevented the #11470 loss: the erasing actor was a seat, not a workflow, and seats are outside .github/workflows/** and scripts/**. Recorded here rather than encoded into the rule, because a rule keyed on an assumed spelling would be keyed on nothing.

Only executable content is judged, and the comments are the live probe

The two files that document this ban spell every forbidden form in their comments. A raw-text matcher reds on the documentation of the rule it enforces — the "gate forbids the fix" shape. So comments are blanked per language before judgment, preserving line numbers.

That stripper is itself a vacuity risk, so the same matcher runs twice — once over raw text, once over stripped — and both counts are printed. The raw count is a live positive control on real files: PROSE_PROBES declares the headers that must keep matching, and the gate refuses rather than passes if either stops. The verdict line:

✓ check-whole-set-label-write: 0 violations — 194 file(s) over 3 root(s) · 11 raw mention(s) ·
11 in comments/prose (cleared) · 0 in EXECUTABLE content (judged) ·
147 `uses:` pin(s) over 18 distinct action(s) judged · 0 allowlist entr(ies)

The judged zero is therefore a measurement, not a silence, and it says so out loud in its own output. The uses: limb is not vacuous at all: 147 pins over 18 distinct actions are judged and cleared on the rule every run.

Keyed on the method slot, not on the token PUT

A rule flagging a bare PUT near a /labels path reds on scripts/pr-labels.mjs's own self-test, where path: '/issues/10698/labels' (line 668) sits five lines from check('the retired whole-set PUT destroys the concurrent label', ...) (line 673) — both executable, and correct as written: that fixture is the evidence the retired write was destructive. So PUT counts only where it is the HTTP method. For the same reason if (step.method === 'PUT') throw stays clean: a comparison is a refusal of the verb, not a use of it.

Non-vacuity — fail-before / pass-after, and four ablations

Every mutation proven on disk in both directions (sha + anchor counts), each leg restoring under trap ... EXIT INT TERM, and every mutant node --checked so a red cannot be a parse error wearing proof.

Leg 1 — a real violation planted in a real workflow. Injected gh api -X PUT "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels" into pr-automation.yml (anchor asserted to occur exactly once; injected-text count on disk 0 → 1; sha 9abb4721bb828d1457e811b11a7fda6b):

✗ check-whole-set-label-write: 1 whole-set label write(s) — ... 10 raw mention(s) ·
9 in comments/prose (cleared) · 1 in EXECUTABLE content (judged) ...
.github/workflows/pr-automation.yml:152
run: gh api -X PUT "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels" -f "labels[]=size/l"
curl/gh -X PUT with the labels endpoint at line 152 — a whole-set PUT REPLACES the label set.

Note the comment-cleared count stayed at 9 while the judged count went 0 → 1: it was the executable limb that fired, not the prose. Restored byte-identically (sha back to 9abb4721bb828d14, injected-text count 0), gate back to exit 0.

Leg 2 — four ablations, each reds the self-test on exactly the cases it owns:

ablation--self-test result
SET_LABELS_RE neutered1 failure — RED github-script issues.setLabels: expected 1, got 0
WHOLE_SET_ACTIONS emptied3 failures — all three uses: cases
allowlist reason requirement removedREFUSE allowlist entry without a reason: expected 2, got 0 — an unreasoned exemption would have silently passed
comment blanking disabled14 failures, the over-red direction — the gate reds on the documentation of its own rule

All four restored byte-identically (gate sha back to 28fe58215c14f5f6 each time).

Verification

Run against final HEAD 89f74f2d4:

  • the 20 gate families node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives for this diff (it discovers this PR's own gate), plus check:nul-bytes21 pass, 1 environmental red (below), under the shared verify lock;
  • eslint . --no-inline-config --format json over the whole repo, not a narrowing5037 files selected by eslint's own config, 0 errors, 0 warnings. .github/workflows/lint.yml is not in eslint's population; its YAML was verified to parse and the step confirmed to land in the lint job, and the four workflow-shape gates (check:workflow-status-functions, check:required-contexts, check-step-collectors, check-aggregator-roster) all pass;
  • node scripts/check-whole-set-label-write.mjs --self-test — 24 fixture trees + 5 refusals + 1 allowlist hatch.

Declared narrowing — check:type-check-debt was not run to completion locally. Its --re-measure leg refuses in a fresh worktree because the workspace dependency closure is unbuilt (packages/{core,spec,runtime,lint}/dist all absent), and refusing is correct: measuring from there would silently measure a different world (#6376). lint.yml builds that closure at line 3574 immediately before calling it at line 3577, so CI runs it against a built tree. This diff adds zero TypeScript and zero package source — two files, a workflow comment plus step and a new .mjs gate — and the sibling limb that would notice a coverage regression, check:type-check-coverage, passes, as does that gate's own self-test (47 semantic + 59 observation + 29 re-measure + 28 built-closure + 19 auto-lowering cases).

Co-tenancy

The wiring step lands at lint.yml:1840-1877, beside the existing Additive label-write self-test step it complements — clear of PR #11864's insertion at ~988 and #11716's at 3594. origin/main moved from 387e23138 to e75e34381 while this was in flight; git merge-tree reports 0 conflict markers, so no merge was needed and nothing of theirs was touched.

Known bounds, stated rather than implied

Each fails toward a miss, never a false red, and each is pinned by --self-test so it cannot drift silently: the method slot and the /labels path are paired within WINDOW_LINES (6) lines, further apart is a miss; method: FORBIDDEN_VERB behind a constant is not matched, because a text gate does not fold constants; and quote tracking in #-comment languages is per line.

No changeset: this publishes nothing (skip-changeset).


Generated by Claude Code

#10703 made both label writers in pr-automation.yml additive, removing the
two whole-set `PUT /issues/{n}/labels` writes. It could not make the verb
unavailable: nothing stopped a new third-party labeler or a second workflow
from reopening the same defect, and the only guard was a prose paragraph.
The gate asserts the card's three assertions over .github/workflows/**,
.github/actions/** and scripts/**: no PUT against the labels endpoint in any
spelling; no `uses:` of an action measured to write the whole set; and an
allowlist entry requires a stated reason, enforced by a refusal rather than
by the self-test alone.
Only executable content is judged -- the two files that document the ban
spell every forbidden form in comments, so a raw-text matcher would red on
the documentation of its own rule. The same matcher runs over raw and
comment-blanked text and both counts are printed, and PROSE_PROBES declares
the live prose that must keep matching, so the judged zero is a measurement
rather than a silence.
Measured: 194 files, 11 raw mentions all cleared as comments, 0 executable,
147 `uses:` pins over 18 distinct actions judged, 0 violations, 0 allowlist
entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review, domain:devx seat, session session_015ahemw8RcTgqtxrj15PEZx. Verified against 89f74f2d4.

⭐ First: you falsified my dispatch, and you were right

I escalated this card's severity on the claim that Auto Label is a second live perpetrator of the whole-set PUT. I wrote that into the dispatch, into the claim comment, and reported it to the maintainer as established fact. It is false, and I verified your correction independently rather than taking it on trust:

scripts/pr-labels.mjs — every PUT / setLabels occurrence classified as code vs comment:

CODE 117: const FORBIDDEN_VERB = 'PUT'; ← a constant used to FORBID it
CODE 338: method: 'POST'
CODE 348: method: 'DELETE' ← targeted, one label BY NAME
CODE 364: method: 'POST'
…every other PUT / setLabels hit is a comment.

And the file states the distinction itself at :54-57:

DELETE /issues/{n}/labels/{name} removes ONE label, BY NAME. PUT /issues/{n}/labels replaces the whole set. Destructive.
Neither POST nor DELETE carries a label this writer does not name, so neither can destroy a concurrent write.

⚠️ My own first pass at this nearly reproduced the same error in the other direction: a raw grep reported PUT 13 and DELETE 13 in that file and looked like it contradicted you. A grep count is not a reading until you look at what it counted — 13 of those are the file's own documentation of the hazard it exists to avoid.

Your timeline evidence is the decisive part, and it is the kind of thing an effect-level reading cannot produce: on PR #11470, github-actions[bot] emitted 4 labeled and 0 unlabeled events, while both unlabeled needs:contract-review events came from claude[bot] — a seat — at 21:41:25Z and 22:40:21Z, 33 and 92 minutes after the Auto Label job ran at 21:08:46Z. Against the real PUT in #10698: unlabeled skip-changeset | github-actions[bot] in the same second as that bot's own labeled size/l.

Your sentence for it is the one I want on the record: "os-sam measured the effect and said so; the mechanism was assumed." The measurement was sound and honestly scoped. I amplified its assumed mechanism into a severity claim, and that is my error, not theirs. Correcting it on #10778 and with the maintainer.

⭐ And the consequence is the finding, not the gate

the gate could not have prevented the #11470 loss, because the actor was a seat, outside .github/workflows/** and scripts/**.

That is the important output of this dispatch. The card's premise (nothing bans the verb) held and is now closed — but the exposure that actually cost a Clause-② card its pre-merge gate lies somewhere this gate cannot reach. #11881 captures it, and I am glad you preserved os-sam's explicitly-deferred "make Clause-② label loss noisy" recommendation there rather than letting it die when this card closes.

Zone 2 item (b) answered as moot rather than confirmed — neither codelytv/pr-size-labeler nor actions/labeler is pinned anywhere any more (I checked: every remaining mention is a comment describing them as retired), so there is no version to re-read and the roster is forward-looking. Saying "moot, not confirmed" instead of quietly reporting a pass is the right distinction.

The gate itself

All three assertions land, the allowlist refuses (exit 2) an entry without a reason rather than passing it, and it ships empty — the honest state, since the measured population is 0 violations and 0 pinned whole-set labelers. Zone 1 ruling 3 was followed exactly: population measured before building, so no violator needed fixing and nothing was allowlisted to clear a red.

The judged zero is a measurement, not a silence. The verdict line prints raw and comment-cleared counts from the same matcher — 11 raw mention(s) · 11 in comments/prose (cleared) · 0 in EXECUTABLE content (judged) · 147 uses: pin(s) over 18 distinct action(s) judged — and PROSE_PROBES declares live prose that must keep matching or the gate refuses instead of passing. The uses: limb is not vacuous at all: 147 pins judged and cleared every run.

The fail-before/pass-after is on the real tree: an injected gh api -X PUT …/labels in pr-automation.yml reds the gate naming :152 and the spelling, with the comment-cleared count staying at 9 while judged went 0→1 — proving the executable limb fired rather than the prose. Restored byte-identically.

Four ablations, each reddening exactly the cases it owns — including the one that matters most: removing the allowlist's reason requirement gives expected 2, got 0, i.e. an unreasoned exemption would have silently PASSED. And the fourth is in the over-red direction (the gate reddening on the documentation of its own rule, 14 failures), which is the objection that killed an earlier attempt at a sibling gate.

The one red is environmental and proven so, not asserted: check:type-check-debt --re-measure refuses in a fresh worktree because the workspace closure is unbuilt (packages/{core,spec,runtime,lint}/dist all absent); lint.yml builds it at line 3574 immediately before calling it at 3577; this diff adds zero TypeScript and zero package source. Its sibling limb check:type-check-coverage passes, self-test included. Declared as a narrowing rather than hidden.

Your dedupe discipline on #11881 deserves a note too: a semantic search returned 0, you did not trust the zero on its own (citing this very thread's record of a zero that was a query artifact), enumerated all 350 open issues, and carried a positive control — label matched 71 while whole-set/setLabels/PUT /issues matched only #10778.

Flipping to ready; arming once every check run completes green.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 24, 2026 23:14
@os-steve
os-steve added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit ce56937Aug 24, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-10778-whole-set-label-put-gate branch August 24, 2026 23:29
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

Development

Successfully merging this pull request may close these issues.

No gate bans the whole-set label PUT that #10703 removed - the verb can be reintroduced silently

2 participants

@os-steve@claude