Skip to content

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

Closed

Description

@claude

Measured tonight on PR #10698 (the #10490 aggregator-roster card). Unlike the near-miss in #10686, this one actually dropped the label — there is an unlabeled event, and the seat's write was the correct additive POST, not a whole-set write.

The specimen

Every label event on PR #10698, from the timeline API:

time event label actor
09:05:29Z labeled skip-changeset claude[bot] (seat, additive POST, HTTP 200)
09:05:30Z unlabeled skip-changeset github-actions[bot]
09:05:30Z labeled size/l github-actions[bot]
09:05:42Z labeled ci/cd github-actions[bot]
09:06:03Z labeled skip-changeset claude[bot] (re-applied after read-back)

The seat did everything the brief asks. The additive POST /issues/10698/labels returned HTTP 200 and the read-back in the same call showed ['skip-changeset']. One second later the size labeler's whole-set PUT erased it, having snapshotted the label set before the seat's POST landed.

Caught only because the brief also says to read the labels back after the bots settle — the read-back at ~40s showed ['ci/cd', 'size/l'], with skip-changeset gone.

Why there is no open card for this

.github/workflows/pr-automation.yml documents the mechanism exactly, at its head, and states plainly that this half was left open:

NOT closed by either change, and deliberately recorded rather than implied: a writer OUTSIDE this workflow — an agent or a human labelling the PR seconds after gh pr create, i.e. exactly while these jobs run — can still land inside a PUT window and be erased. […] Closing that half needs the writes themselves to become additive, not merely better ordered; it is the open half of #5649 and no configuration here can stand in for it.

But #5649 is closed, and #5533 is closed. So the only tracker for a live, recurring defect is a prose paragraph inside a workflow file — findable only by someone who already knows to look there. That is the gap this card exists to fill, and it is why the recurrence went unnoticed until a read-back happened to catch it.

#10686 is a different direction and does not cover this: it is about the permission classifier pushing agents toward the unsafe write, and its own timeline records "No unlabeled event — nothing was dropped."

Why it matters beyond one label

skip-changeset is the exemption for a PR that publishes nothing. When the labeler erases it, changeset-check enforces a changeset against a PR that legitimately has none. The workflow does mitigate the read side — it re-reads labels live and has a "Settle the skip-changeset window" step — so the erasure is usually survivable if the seat notices and re-applies. A seat that applies the label, sees HTTP 200, and moves on (which is what the brief's happy path looks like) leaves a PR whose exemption silently does not exist.

The window is wide: bots wrote at +1s, +1s and +13s after the PR opened here.

Directions, not a prescription

  1. Make the writes additive. codelytv/pr-size-labeler (src/github.sh:68-91) and actions/labeler@v7 (src/api/set-labels.ts) both PUT the whole set and expose no additive input; sync-labels is not that input. Replacing them with a small additive step is the only fix that closes the window rather than narrowing it — this is the remedy the workflow header already names.
  2. Make the loss loud. A step that re-reads the labels after both writers finish and re-applies a seat-applied exemption it finds missing would turn a silent drop into a self-healing one.
  3. At minimum, re-open the tracking. The defect currently lives only as prose in a closed card's workflow comment.

Filed unassigned, no labels — PM triage. Found while working #10490; not fixed there, out of scope for a gate-tooling PR.


Generated by Claude Code


Generated by Claude Code

Activity

  1. added theissue type on Aug 21, 2026
  2. os-zhuang commented on Aug 21, 2026

    @os-zhuang
    Contributor

    Triage (finding concentration round): promoted to pm:queue · type Bug. Measured, recurring, currently tracked only by prose in a closed card's workflow header — and the damage radius is every seat's label writes (a silently-erased skip-changeset turns into a false changeset-check red on a PR that publishes nothing). Dispatch note: direction 1 is the fix the workflow header itself names (replace the whole-set PUT labelers with a small additive step); direction 2 (post-settle re-apply) is acceptable as a rider, not as the fix. This is gate-strengthening in .github/workflows/pr-automation.yml, no contract face. M, mode:subagent, opus; Clause-②: no. Sibling: #10686 keeps the permission-classifier half (routed to the skills lane).


    Generated by Claude Code

  3. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor

    Claimed by the domain:devx PM seat.

    • session c970724d-303c-5614-9d20-a3f92205cfad
    • branch claude/issue-10703-additive-label-writes

    Dispatching to a developer agent now. If you hold an earlier claim on this card, say so and I will stand down — all agents share one GitHub identity, so the assignee field alone cannot tell us apart.


    Generated by Claude Code

  4. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor
    {
      "issue": 10703,
      "status": "done",
      "branch": "claude/issue-10703-additive-label-writes",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10777",
      "premise_still_valid": true,
      "summary": "The issue premise holds: I re-fetched the #10698 timeline independently and it matches the reported table event-for-event (09:05:29Z labeled skip-changeset by claude[bot], 09:05:30Z unlabeled by github-actions[bot], 09:05:30Z labeled size/l). ONE HALF OF THE DISPATCH BRIEF WAS WRONG: line :16 is not 'a step of ours doing curl -X PUT' -- it is a comment line inside the header block quoting codelytv/pr-size-labeler's own src/github.sh:68-91. A grep for label writes across .github/workflows/** returns only two 'uses:' lines, so there was no step of ours at all and BOTH PUTs were third-party. Both were replaced by scripts/pr-labels.mjs, which issues POST plus targeted DELETE only; neither verb carries a label the writer does not name, so neither can destroy a concurrent writer's label at any interleaving -- correctness no longer depends on timing, which is what separates this from every prior ordering change. I replaced actions/labeler too (not just the measured size labeler) because although v7.0.0 partly mitigates via a re-read at labeler.ts:111-133, the [re-read..PUT] window stays open and #5533 lost skip-changeset to exactly that writer. Chose additive writes over reconcile-after: the label to be repaired arrives DURING the window, so it is in neither the before- nor the after-snapshot, and separating 'erased by us' from 'deliberately removed by a human' needs a timeline heuristic that is wrong in both directions. Rewrote the workflow header (it asserted this half was open and pointed at a closed card). Dropped two retired inputs: fail_if_xl (do-nothing branch) and message_if_xl, which is DEAD as configured -- labeler.sh calls add_label_to_pr and only then tests '! has_label' for the label it just wrote, so the guard is always false and the comment never posts. Reverse race noted, not changed: the size family IS owned by this writer so a hand-applied size/xl is still retired, but by a targeted DELETE naming that label, not by an erasure. Fenced surfaces untouched (Check Changeset job and its steps, root package.json); the self-test is wired into lint.yml by direct 'node scripts/...' invocation for that reason, and it needs a required-context home anyway since Check PR Size is deliberately excluded from the required set.",
      "tests": "GATES -- 'node scripts/pm/dispatch-gates.mjs' re-derived from the merge base at final commit ce525a05d3 (no paths passed; editing lint.yml pulled in two families the dispatch list did not name). Exit codes captured before any pipe ('cmd > file 2>&1; ec=$?'), and each verdict line quoted is the gate's own: check:cross-package-test-inputs EXIT=0 'OK: 13 package(s) read outside themselves, all declared'; check:node-version EXIT=0 'OK (32 setup-node step(s) across 26 workflow(s), all on Node 22)'; check:required-contexts EXIT=0 (pin table, no problems); check:shard-attestation EXIT=0 '2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s)'; check:workflow-status-functions EXIT=0 'OK (scanned 26 workflow file(s), 49 job(s), 24 job-level if: expression(s))'; check:type-check-coverage EXIT=0 'OK -- 64/77 workspace packages type-checked'; check:nul-bytes EXIT=0 'OK (scanned 6238 text file(s) ... no raw ASCII control bytes)'; check-aggregator-roster.mjs EXIT=0 'roster == needs: in both directions'. DECLARED NARROWING: check:type-check-debt is '--self-test && --re-measure'; the self-test half passed and --re-measure REFUSED to run without a built workspace closure ('55 workspace dependenc(ies) ... have no built type entry point on disk'), leaving the ledger untouched (git status empty). It re-measures tsc counts for 13 packages and this diff contains zero TypeScript, so I did not build the full closure to satisfy it; CI runs that half with the closure built. WORKFLOW SYNTAX -- actionlint is not installed in this container; both edited workflows validated by a real PyYAML safe_load parse plus the four repo gates that parse workflows for real. ABLATIONS (no build/dist involved -- this script runs from source; each mutation confirmed ON DISK by grepping the injected AND the removed text, never by an editor's exit code): (1) \"method: 'POST'\" -> \"'PUT'\" in planSizeWrites, on-disk proof PUT 0->1 and POST 3->2 plus the git diff line -- self-test EXIT=1, 4 failures including the interleaving replay returning [\"size/l\"] where [\"skip-changeset\",\"size/l\"] was expected, i.e. the defect itself reproduced; (2) the '**' zero-segment case broken ('k = j' -> 'k = j + 1'), on-disk proof removed-text 1->0 and injected-text 0->1 -- self-test EXIT=1, 3 failures, exactly the assertions where '**' must match zero segments, while 'matches through the parsed config' correctly stayed GREEN because that input has enough segments that the mutation cannot change its answer (predicted direction, observed direction). Both restored and PROVED restored: grep counts back to 1/0 and 0/3, 'git diff' empty, self-test EXIT=0. LIVE END-TO-END -- pr-automation.yml is plain 'pull_request' (no pull_request_target, no merge-triggered job) and pull_request workflows run the head branch's version, so the replaced steps really executed on this PR. Driving the real exported plan builders over PR #10698's real file list reproduced the retired actions' output exactly: total modifications = 724 -> size/l (the bot applied size/l) and path labels = [ci/cd] (the bot applied ci/cd). Then the card's own defect, on this PR: applied skip-changeset with an additive POST at 11:18:56Z (HTTP 200), deliberately into the window. Timeline on PR #10777: 11:18:57Z labeled skip-changeset claude[bot]; 11:19:03Z labeled size/xl github-actions[bot]; 11:19:20Z labeled ci/cd github-actions[bot]. NO 'unlabeled' EVENT -- the label was written 6s before the size labeler and 23s before the path labeler, squarely inside the window that erased it on #10698, and it survived both; read back at 11:20:36Z and again at 11:22:02Z: 'ci/cd, size/xl, skip-changeset'. Run 32476631817 steps by name: 'Self-test the additive label writer' success, 'Add size label (additive POST, then a targeted DELETE)' success, 'Label based on changed files (additive POST)' success; Check Changeset success with the exemption honoured (its Require-a-changeset step skipped). Real CI logs quote the verbs actually issued: 'VERDICT: pr-labels self-test PASSED' / 'total modifications ...: 1013 -> size/xl' / 'labels on PR #10777 right now: skip-changeset' / 'POST /issues/10777/labels -- add the computed size label size/xl', and for the path half 'labels on PR #10777 right now: size/xl, skip-changeset' / 'POST /issues/10777/labels -- add path label(s) ci/cd' -- the writer READ skip-changeset and did not carry it into any write. CI STATUS AT REPORT TIME: PR Automation success, Check Links success, Duplicate Fix Guard success, Single-Claim Path Guard success, Part-of Closing-Keyword Guard success; Lint & Type Check and CI still in_progress -- not waited on, per the report-at-draft-PR contract.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10778 (domain:devx + finding, unassigned, no pm:queue): no repo gate bans the whole-set label PUT this PR removed, so the verb can be reintroduced silently by a future workflow, third-party action or agent; a check:* gate needs a root package.json entry, which is fenced by the @changesets/cli v3 lane. Searched the 105 open issues by keyword (label/PUT/setLabels/additive/whole-set/labeler/skip-changeset) before filing; no duplicate.",
        "NOT filed, recorded here only: codelytv/pr-size-labeler v1.10.4 message_if_xl is dead code upstream -- labeler.sh calls add_label_to_pr and only then tests '! github::has_label' for the label it just wrote. It is an upstream bug in a dependency this PR removes, so nothing is left in this repo to track."
      ]
    }

    Generated by Claude Code

  5. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor

    Updated report (supersedes the earlier one on this issue -- head is now e0accbffc7).

    {
      "issue": 10703,
      "status": "done",
      "branch": "claude/issue-10703-additive-label-writes",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10777",
      "premise_still_valid": true,
      "summary": "SUPERSEDES my earlier report; head is now e0accbffc7 (was ce525a05d3). Two commits. (1) ce525a05d3 -- the fix: both label-writing jobs in pr-automation.yml reached PUT /issues/{n}/labels through a third-party action, and both were replaced by scripts/pr-labels.mjs, which issues POST plus targeted DELETE only, so neither verb carries a label the writer does not name and neither can destroy a concurrent writer's label at any interleaving. NOTE the dispatch brief was wrong on one half: line :16 is not 'a step of ours doing curl -X PUT', it is a comment line quoting codelytv/pr-size-labeler's own src/github.sh:68-91; a grep for label writes across .github/workflows/** returns only two 'uses:' lines, so BOTH PUTs were third-party and both needed the same remedy. (2) e0accbffc7 -- the entry-guard fix the coordinator flagged: check:entry-guard correctly rejected the hand-typed process.argv[1] comparison ce525a05d3 shipped. My spelling dodged the percent-encoding trap but still fails the symlink direction, because node resolves symlinks for the module graph and leaves process.argv[1] as the caller typed it. Now 'import { isEntrypoint } from ./invoked-as.mjs', with the newly unused pathToFileURL import dropped. The file stays OFF KNOWN_IMPORT_UNSAFE. Other decisions unchanged and recorded in the PR body: additive writes over reconcile-after (the label to be repaired arrives DURING the window, so it is in neither snapshot); both writers replaced rather than only the measured one (actions/labeler v7's re-read narrows but does not close its window, and #5533 lost skip-changeset to exactly that writer); header prose rewritten; fail_if_xl and message_if_xl not carried over (message_if_xl is DEAD upstream -- labeler.sh tests '! has_label' for the label it just wrote); reverse race noted, not changed. Fenced surfaces untouched (Check Changeset job and its steps, root package.json).",
      "tests": "ENTRY-GUARD FIX (the resumption item), exit codes captured before any pipe: 'pnpm check:entry-guard' EXIT=0 -- both legs, self-test '47 cases pass -- all 11 measured spellings rejected, canonical form and masked prose/payloads accepted' then the run '127 scripts/ file(s) -- every entry guard goes through invoked-as.mjs; 85 export bindings, 75 of them inert on import (10 known-unsafe, SHRINK-ONLY)'. 'node scripts/pr-labels.mjs --self-test' EXIT=0 'VERDICT: pr-labels self-test PASSED'. OFF KNOWN_IMPORT_UNSAFE, verified not inferred: the file is absent from the allowlist (grep), the gate still counts 10 known-unsafe (the original length, so nothing was added) with this file among the 75 inert exporters, and importing it directly with '--size' in argv printed 'IMPORTED; nothing ran. planSizeWrites callable: true' with all eight exports available. MEASURED, not recited -- the two guard spellings side by side, each reached directly and through a real symlink: old-guard exit=0 output=RAN / old-link exit=0 output=(nothing) / new-guard exit=0 output=RAN / new-link exit=0 output=RAN. The old spelling was silently inert with exit 0 through a symlink, which is the silent-success direction; reaching the real scripts/pr-labels.mjs through a symlink now prints 'VERDICT: pr-labels self-test PASSED'. WHY IT WAS NOT IN MY GATE LIST -- dispatch-gates did NOT name it, and STILL does not: re-derived at e0accbffc7 with the file committed and tracked, 'grep -c entry-guard' over the output is 0. It is absent from matched, absent from Unreachable; '--residue' places it in Silent, because the derivation reads the gate's declared population off KNOWN_IMPORT_UNSAFE (check-entry-guard.mjs:437-448) -- an enumeration of the ten files that ALREADY violate the rule, which a newly added script can never be in. Its sibling check:parse-guard covers the same directory and fails the opposite way ('Unreachable -- dead: scripts -- the covering rule refuses the literal as too generic'). Both scripts/** gates are invisible to the derivation by opposite routes; filed as #10784, and I ran check:parse-guard by hand for the same reason (EXIT=0). GATE UNION re-run at e0accbffc7: check:entry-guard 0, check:parse-guard 0, check:cross-package-test-inputs 0 'OK: 13 package(s) read outside themselves, all declared', check:node-version 0 'all on Node 22', check:required-contexts 0, check:shard-attestation 0 '2 aggregate gate(s) count 3 declared leg(s)', check:workflow-status-functions 0 'OK (scanned 26 workflow file(s), 49 job(s))', check:type-check-coverage 0 'OK -- 64/77 workspace packages type-checked', check:nul-bytes 0 'no raw ASCII control bytes', check-aggregator-roster.mjs 0 'roster == needs: in both directions'. DECLARED NARROWING (unchanged): check:type-check-debt's --re-measure leg refused to run without a built workspace closure and left the ledger untouched; this diff has zero TypeScript, and CI runs that half with the closure built. ABLATIONS from the first round still stand (each mutation confirmed ON DISK by grepping the injected AND removed text, both restored and proved restored): PUT injected into planSizeWrites -> self-test EXIT=1 with the interleaving replay returning [size/l] where [skip-changeset, size/l] was expected; '**' zero-segment case broken -> self-test EXIT=1 on exactly the zero-segment assertions, with 'matches through the parsed config' correctly staying GREEN because that input cannot change answer. LIVE END-TO-END, now TWO runs and still ZERO 'unlabeled' events. Run 1 (32476631817): skip-changeset applied by additive POST at 11:18:56Z deliberately into the window; timeline 11:18:57Z labeled skip-changeset, 11:19:03Z labeled size/xl, 11:19:20Z labeled ci/cd, no unlabeled -- the label was written 6s before the size labeler and 23s before the path labeler and survived both, where #10698 lost it in one second. CI logs: 'labels on PR #10777 right now: skip-changeset' then 'POST /issues/10777/labels -- add the computed size label size/xl' -- the writer READ skip-changeset and did not carry it into any write. Run 2 at the new head (32477473695) exercised the IDEMPOTENT path: 'labels on PR #10777 right now: ci/cd, size/xl, skip-changeset' then 'pr-labels: nothing to write.' from BOTH writers, zero API calls -- exactly the re-run the retired header called 'one more chance to erase a concurrent writer in exchange for no new information'. Labels read back at 11:20:36Z, 11:22:02Z and after the second push: 'ci/cd, size/xl, skip-changeset'. CI AT REPORT TIME (head e0accbffc7): PR Automation success, Check Links success, Duplicate Fix Guard success, Part-of Closing-Keyword Guard success; Lint & Type Check, CI and Single-Claim Path Guard still in_progress -- not waited on, per the report-at-draft-PR contract. PR BODY carries the live evidence, the entry-guard section and the idempotent-path specimen; verified after each PATCH that the session-URL footer survived (the platform's bare-form footer is the one the sanitizer strips).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10778 (domain:devx + finding, unassigned, no pm:queue): no repo gate bans the whole-set label PUT this PR removed, so the verb can be reintroduced silently by a future workflow, third-party action or agent; a check:* gate needs a root package.json entry, which is fenced by the @changesets/cli v3 lane.",
        "filed as #10784 (domain:devx + finding, unassigned, no pm:queue): dispatch-gates never names check:entry-guard for a NEW scripts/ file, because it reads the gate's declared population off KNOWN_IMPORT_UNSAFE -- an enumeration of current members. Reproduced after the file was tracked. Its sibling check:parse-guard is invisible the opposite way (population too generic). This is the derivation gap that let the red reach CI; the gate itself worked correctly.",
        "NOT filed, recorded here only: codelytv/pr-size-labeler v1.10.4 message_if_xl is dead code upstream -- labeler.sh calls add_label_to_pr and only then tests '! github::has_label' for the label it just wrote. It is an upstream bug in a dependency this PR removes, so nothing is left in this repo to track."
      ]
    }

    Generated by Claude Code

  6. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Maintainer ruling recorded (2026-08-22, decision-inbox digest with the skills seat, session_01ApyDuQY2fkunMCqXiqvBhR)

    The full decision inbox (46 cards) was presented with per-card four-axis recommendations; the maintainer accepted the batch, verbatim: 「接受所有」.

    Ruled: The decision blocker on this card is lifted. The additive-label-writes fix is approved as delivered: the already-open landing PR #10777 (both third-party whole-set PUT labelers replaced by the additive scripts/pr-labels.mjs writer, POST plus targeted DELETE only) proceeds to landing under the existing pm:dispatched claim — no re-dispatch. The out-of-scope follow-up filed from this work, #10778 (a gate banning reintroduction of the whole-set label PUT), goes to the queue per the same ruling.

    State: needs-user-decision removed; the card stays pm:dispatched — landing continues under the existing dispatch.


    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