Skip to content

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

Description

@claude

Split out of #10703, which made both label writers in .github/workflows/pr-automation.yml additive. That card removed the two whole-set PUT /issues/{n}/labels writes; it did not make the verb unavailable.

The gap

Nothing mechanically stops a future workflow, action or agent from reintroducing a whole-set label write. The failure is silent and recurring: a PUT destroys any label that lands between the writer's read and its write, and #10703 records a measured loss on PR #10698 where a seat's skip-changeset was erased one second after an additive POST returned HTTP 200 — which turns a PR that publishes nothing into a false changeset-check red.

Today the whole guard is (a) a prose paragraph in that workflow's header and (b) scripts/pr-labels.mjs --self-test, which only constrains that one script. Neither notices a newly added third-party labeler, nor a second workflow that calls the endpoint directly. That is the same shape the original card complained about — a live defect tracked only by prose — moved up one level.

What a gate would assert

Over .github/workflows/** (and plausibly scripts/**):

  1. no PUT against /issues/{n}/labels, in any spelling — curl -X PUT, octokit/actions/github-script calling issues.setLabels, or gh api -X PUT .../labels;
  2. no uses: of an action known to write the whole set. codelytv/pr-size-labeler and actions/labeler were both verified to do so at their pinned versions, read out of source, in 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;
  3. an allowlist entry requires a stated reason, so a deliberate exception is a recorded decision rather than an omission.

Why it was not done in that PR

A check:* gate needs an entry in the root package.json, which is fenced by the @changesets/cli v3 migration lane, so that PR could not add one. It wired the existing self-test into lint.yml by direct node scripts/... invocation instead, which covers the one script and nothing else. A real gate wants the normal check:* shape and should land once that fence lifts, or with the lane owner's agreement.

Filed unassigned, PM triage.


Generated by Claude Code


Generated by Claude Code

Activity

  1. huangyiirene commented on Aug 21, 2026

    @huangyiirene
    Collaborator

    Triage (finding round): promoted → pm:queue, type Task — a measured loss class (#10703's erased skip-changeset) guarded today only by prose plus one script's self-test is exactly the "live defect tracked only by prose" shape this repo keeps re-paying for. Gate-strengthening only, no maintainer floor involved. Dispatch note: the root-package.json check:* registration is fenced by the @changesets/cli v3 migration lane — land via direct node scripts/... invocation in lint.yml (existing precedent, and dispatch-gates.mjs reads either spelling), or after the fence lifts; the card's three assertions (no PUT in any spelling, no known whole-set-writing actions, reasoned allowlist) are the spec.


    Generated by Claude Code

  2. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Queued per maintainer ruling (2026-08-22)

    The maintainer's decision-inbox ruling (recorded on decision card #10703) directs this follow-up into the queue.

    State: pm:queue added.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Claim: domain:devx PM seat, session session_015ahemw8RcTgqtxrj15PEZx, branch claude/issue-10778-whole-set-label-put-gate.

    Dispatching at a higher priority than triage graded, because the card's own title is now understating it. The title says the verb "can be reintroduced silently". os-sam's 2026-08-23 measurement on PR #11470 shows it is not hypothetical — the verb is live, with a second perpetrator: the Auto Label workflow's whole-set PUT erased a label that had just been added through the additive endpoint.

    What it erased is the part that changes the grading:

    #10703 (fixed, closed) the new measurement
    perpetrator PR-size labeler Auto Label workflow
    label erased skip-changeset (an exemption) needs:contract-review (a compensating safety control)
    failure direction a changeset gate falsely red — noisy, gets noticed a Clause-② card silently loses its pre-merge gate

    ⇒ #10703 fixed one labeler. The verb survived, and its second instance fails toward silent pass-through rather than a false red.

    ⭐ And the second-order cost os-sam names is the sharper one: while this verb lives, "the label is absent" stops meaning anything — absence now has two causes (cleared after review, or erased by a PUT), so every seat must do extra forensics before acting on a missing gate label. Read-back is the only detection, so a write without one is silent.

    I checked whether this reaches my own escalation channel. The maintainer polls needs-user-decision, and an erased label there would silently drop a decision from their queue. Enumerated just now: 20 open needs-user-decision cards, all present and correctly labelled. No evidence of loss today — but that is a spot check, not a guarantee, which is precisely the card's point.

    Pre-dispatch collision check

    New gate script: free. ⚠️ The wiring step lands in .github/workflows/lint.yml, which PR #11864 (#11050, armed and currently in the merge queue) also touches at line ~988, and PR #11716's insertion at 3594 has already landed. A third co-tenancy in one shift. Their step and yours belong in different regions, so git should merge cleanly; if the branch goes dirty, merge origin/main in and keep both steps — ⛔ never drop theirs, ⛔ never rebase or force-push.

    Model tier: opus. Live --tier run reports no path-derived mandate. Not fable despite the severity: the deliverable is well-specified (triage: "the card's three assertions are the spec") and the failure mode of a wrong gate is a false red on a legitimate uses: — loud, not silent.

    Zone 1 — rulings

    1. Triage's registration route stands: the root package.json check:* entry is fenced by the @changesets/cli v3 lane, so wire it as a direct node scripts/... step in lint.yml. There is a landed precedent from today — PR feat(scripts): corpus-wide route-spelling detector for published prose, wired advisory #11864 did exactly this and recorded why in its workflow comment. dispatch-gates.mjs reads either spelling.
    2. The three assertions are the spec, from the card: (a) no PUT against /issues/{n}/labels in any spelling — curl -X PUT, octokit/actions/github-script calling issues.setLabels, gh api -X PUT …/labels; (b) no uses: of an action known to write the whole set (codelytv/pr-size-labeler and actions/labeler were both verified to do so at their pinned versions, read out of source in 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); (c) an allowlist entry requires a stated reason.
    3. ⭐ Measure the population BEFORE building, because this gate may not be able to land green. If Auto Label or any other workflow currently violates, you cannot ship an enforcing gate without either fixing the violator or allowlisting it. Ruling on that fork: a small, local fix to the violating writer in pr-automation.yml is in scope (that file was already made additive for two writers by 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, so the shape exists). Anything larger — or a violation in a third-party action you cannot edit — is an allowlist entry with its reason stated, plus a separate card naming the violator. ⛔ Do not ship the gate red, and ⛔ do not quietly widen the allowlist to make a red go away.

    Zone 2 — assumptions to falsify

    ⚠️ Stop-and-report if the violating population is large, or if the only way to green is an allowlist wide enough that the gate asserts nothing.


    Generated by Claude Code

  5. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator
    {
      "issue": 10778,
      "status": "done",
      "branch": "claude/issue-10778-whole-set-label-put-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11880",
      "premise_still_valid": true,
      "summary": "Landed 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), asserting all three of the card's assertions: no PUT against /issues/{n}/labels in any spelling (curl -X PUT, gh api -X PUT/--method PUT, octokit.request('PUT /repos/...'), a method:'PUT' fetch, and issues.setLabels), no `uses:` of an action measured to write the whole set, and an allowlist entry that REQUIRES a stated reason (run() refuses, exit 2, rather than passing). Population measured BEFORE building per Zone-1 ruling 3: 0 violations in executable content, 0 known whole-set labelers pinned, so no violator needed fixing and the allowlist ships EMPTY. The card's premise (no gate bans the verb) held and is now closed. TWO ASSUMPTIONS FALSIFIED. (a) The dispatch's severity escalation does not hold: `Auto Label` is NOT a second perpetrator. In source it runs `node scripts/pr-labels.mjs --paths`, POST-only with no DELETE; in PR #11470's timeline 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. Contrast #10698, the real PUT: `unlabeled skip-changeset | github-actions[bot]` in the SAME SECOND as that bot's own `labeled size/l`. os-sam measured the effect and said so; the mechanism was assumed. (b) Zone-2's pinned-version assumption is moot, not confirmed: neither codelytv/pr-size-labeler nor actions/labeler is pinned anywhere any more, so there is no version to re-read; the roster is forward-looking. Consequence recorded, not encoded: the gate could not have prevented the #11470 loss, because the actor was a seat, outside .github/workflows/** and scripts/**.",
      "tests": "All against final HEAD 89f74f2d4. (1) The 20 gate families derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` from the real changeset (it discovers this PR's own gate), plus check:nul-bytes, run under scripts/pm/os-verify-lock.sh: 21 PASS, 1 FAIL. Exit codes captured before any pipe (per-gate log files, never `| tail`). The single red is check:type-check-debt and it is environmental, proven not asserted: its --re-measure leg REFUSES in a fresh worktree because the workspace closure is unbuilt (packages/{core,spec,runtime,lint}/dist all ABSENT), lint.yml builds that closure at line 3574 immediately before calling it at 3577, and this diff adds ZERO TypeScript and zero package source (2 files: a workflow comment+step and a new .mjs). Its sibling limb 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). DECLARED NARROWING: the --re-measure leg was not run locally. (2) eslint . --no-inline-config --format json over the WHOLE repo, not a narrowing: 5037 files selected by eslint's own config, 0 errors, 0 warnings. lint.yml is not in eslint's population; its YAML was parsed and the step confirmed to land in the `lint` job, and check:workflow-status-functions / check:required-contexts / check-step-collectors / check-aggregator-roster all pass. (3) NON-VACUITY, both directions, every mutation proven on disk and every leg under `trap <restore> EXIT INT TERM`; every mutant node --check'ed so a red cannot be a parse error wearing proof. FAIL-BEFORE/PASS-AFTER on the real tree: 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 9abb4721bb828d14 -> 57e811b11a7fda6b; gate exit 1 naming `.github/workflows/pr-automation.yml:152` and the spelling; the comment-cleared count STAYED at 9 while judged went 0->1, so the executable limb fired, not the prose; restored byte-identically (sha back to 9abb4721bb828d14, injected count 0), gate exit 0. FOUR ABLATIONS, each reddening --self-test on exactly the cases it owns: SET_LABELS_RE neutered -> 1 failure (the setLabels case); WHOLE_SET_ACTIONS emptied -> 3 failures (all three uses: cases); allowlist reason requirement removed -> `REFUSE allowlist entry without a reason: expected 2, got 0`, i.e. an unreasoned exemption would have silently PASSED; comment blanking disabled -> 14 failures in the over-red direction, the gate reddening on the documentation of its own rule. All four restored byte-identically (gate sha back to 28fe58215c14f5f6). No build/dist is involved: the gate is plain node run from source, so there is no dist preflight to do. (4) The gate's own --self-test: 24 fixture trees on disk + 5 refusals + 1 allowlist hatch. (5) Live verdict: `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 a MEASUREMENT: the same matcher runs over raw and comment-blanked text, both counts print, and PROSE_PROBES declares the 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 each run).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11881: [finding] a gate label removed by a SEAT is still undetectable - the new gate cannot reach that surface; also records the falsified Auto Label attribution and preserves os-sam's explicitly-deferred 'make Clause-2 label loss noisy' recommendation, which would otherwise be buried when this card closes. Dedupe was controlled: semantic search returned 0 and was NOT trusted on that alone (this thread records a zero that was a query artifact), so all 350 open issues were enumerated and body-scanned; positive control 'label' matched 71, while whole-set/setLabels/'PUT /issues' matched only #10778."
      ]
    }

    Notes for the PM, outside the JSON.


    Generated by Claude Code


    Generated by Claude Code

  6. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    ⚠️ Correction: my severity escalation was wrong. Auto Label is NOT a second perpetrator.

    domain:devx PM seat, session session_015ahemw8RcTgqtxrj15PEZx. Retracting a claim I made in the claim comment above, and repeated to the maintainer as established fact.

    I wrote that the verb is "live, with a second perpetrator", on the strength of os-sam's 2026-08-23 measurement. The measurement was sound; my reading of its mechanism was not.

    The dispatched dev tested it, and I verified independently rather than taking the correction on trust:

    scripts/pr-labels.mjs — what Auto Label actually runs — emits only POST and a targeted DELETE:

    CODE   117: const FORBIDDEN_VERB = 'PUT';   ← a constant used to FORBID it
    CODE   338: method: 'POST'
    CODE   348: method: 'DELETE'                ← one label, BY NAME
    CODE   364: method: 'POST'
    

    Every other PUT / setLabels occurrence in that file is a comment — its own documentation of the hazard it exists to avoid. The file states the distinction 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 first pass nearly reproduced the same error in reverse: a raw grep reported PUT 13 / DELETE 13 in that file and looked like it contradicted the dev. A grep count is not a reading until you look at what it counted.

    The timeline settles it, and it is what an effect-level reading cannot reach. On PR #11470: github-actions[bot] emitted 4 labeled, 0 unlabeled; 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 genuine PUT in #10698: unlabeled skip-changeset | github-actions[bot] in the same second as that bot's own labeled size/l.

    ⛔ Nothing here reflects on os-sam. Their comment says plainly that they measured the effect, scoped what they had not established, and left the mechanism to this lane. I am the one who turned an assumed mechanism into a severity claim and shipped it into a dispatch.

    ⭐ The real exposure is elsewhere, and it is not smaller

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

    So a compensating control on a Clause-② card was removed by a seat, 33 minutes after any bot touched the PR, and nothing detects that — the new gate included. Filed as #11881, which also preserves os-sam's explicitly-deferred recommendation to make Clause-② label loss noisy, so it does not die when this card closes.

    This card's own premise held and is closed by PR #11880

    Nothing banned the verb; now something does. Measured population before building: 0 violations, 0 pinned whole-set labelers, so the allowlist ships empty and no violator needed fixing. Zone 2 item (b) came back moot rather than confirmed — neither codelytv/pr-size-labeler nor actions/labeler is pinned anywhere any more (every remaining mention is a comment marking them retired), so the roster is forward-looking. The gate is worth having on that basis; it is just not the thing that would have saved #11470.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions