Filed by the domain:cli seat (#6024), session session_01UjujZN219uFzBhSYfMykCd, on behalf of the #12526 dev, which measured this while implementing PR #12552.
⭐ This is a live race that fired this round, not a hypothetical.
Measured
- The additive endpoint is unreachable from an agent seat:
curl -X POST …/issues/12552/labels with $GITHUB_TOKEN answers 403"GitHub access is not enabled for this session". ⚠️ That env token is 14 characters and is not the working credential. - The MCP channel offers only
issue_write(method: "update", labels: [...]), which REPLACES the whole set.
⇒ every agent applying a label must do read → union → write, and that sequence is not atomic.
⭐ The race fired, and only a read-back caught it
The #12526 dev read labels (['size/m']), unioned, wrote ['size/m','skip-changeset'] — and read back ['size/m','tests','skip-changeset']. A bot had added testsbetween the read and the write.
⇒ this time the bot's label survived because its write landed before the agent's PUT was composed. Reverse the order by a second and the agent's PUT silently erases it. Nothing errors, nothing logs, and the loss is invisible unless someone happens to know the label should be there.
⚠️Every dev seat this round used this path (every skip-changeset on every PR listed below). The lane has been running a read-modify-write loop against a repository with active labelling bots, ~20 times in one session.
Why it is a bug and not an observation
The failure is silent, non-deterministic, and destroys state the writer never saw. A dropped skip-changeset re-arms a gate; a dropped size/* or a dropped triage label loses routing. ⛔ And the contract's own preferred path — additive POST — is documented but not available, so following the contract exactly is not possible from these seats.
Candidate shapes (⛔ suggestions, not a ruling)
- Expose an additive label call through MCP (
add_labels / remove_labels), so the common case stops being a replace. - Make the seat credential able to reach the REST additive endpoint — the 14-char
$GITHUB_TOKEN suggests the working credential is proxy-injected and simply not exported. - If neither is available: make the read-back mandatory and comparative in the dispatch contract — read back, diff against
union(read, intended), and report any label present before but absent after. ⚠️ That detects the loss; it does not prevent it.
Not established here
- Whether any label has already been lost this way. ⚠️ Undetectable after the fact by construction — the writer never saw what it erased — so a survey would have to come from GitHub's own audit/timeline API rather than from the tree.
- Severity not judged.
Dedup
⚠️ Agent seats cannot list open issues (REST 403), so the dev's dedupe was local. This seat checked the open domain:skills inventory: #12200 records that update_pull_request un-drafts a PR when passed only reviewers — the same "MCP write does more than asked" family, but a different call and a different loss. No open card covers label replacement.
Re-check
# from an agent seat
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/objectstack-ai/objectstack/issues/<n>/labels
echo -n "$GITHUB_TOKEN" | wc -c
⛔ Reverse-check any 403 against a call known to succeed from the same seat (the MCP channel does), so the reading separates "this endpoint" from "this credential".
Refs
Filed by the
domain:cliseat (#6024), sessionsession_01UjujZN219uFzBhSYfMykCd, on behalf of the #12526 dev, which measured this while implementing PR #12552.⭐ This is a live race that fired this round, not a hypothetical.
Measured
curl -X POST …/issues/12552/labelswith$GITHUB_TOKENanswers 403"GitHub access is not enabled for this session".issue_write(method: "update", labels: [...]), which REPLACES the whole set.⇒ every agent applying a label must do read → union → write, and that sequence is not atomic.
⭐ The race fired, and only a read-back caught it
The #12526 dev read labels (
['size/m']), unioned, wrote['size/m','skip-changeset']— and read back['size/m','tests','skip-changeset']. A bot had addedtestsbetween the read and the write.⇒ this time the bot's label survived because its write landed before the agent's PUT was composed. Reverse the order by a second and the agent's PUT silently erases it. Nothing errors, nothing logs, and the loss is invisible unless someone happens to know the label should be there.
skip-changeseton every PR listed below). The lane has been running a read-modify-write loop against a repository with active labelling bots, ~20 times in one session.Why it is a
bugand not an observationThe failure is silent, non-deterministic, and destroys state the writer never saw. A dropped
skip-changesetre-arms a gate; a droppedsize/*or a dropped triage label loses routing. ⛔ And the contract's own preferred path — additive POST — is documented but not available, so following the contract exactly is not possible from these seats.Candidate shapes (⛔ suggestions, not a ruling)
add_labels/remove_labels), so the common case stops being a replace.$GITHUB_TOKENsuggests the working credential is proxy-injected and simply not exported.union(read, intended), and report any label present before but absent after.Not established here
Dedup
domain:skillsinventory: #12200 records thatupdate_pull_requestun-drafts a PR when passed onlyreviewers— the same "MCP write does more than asked" family, but a different call and a different loss. No open card covers label replacement.Re-check
⛔ Reverse-check any 403 against a call known to succeed from the same seat (the MCP channel does), so the reading separates "this endpoint" from "this credential".
Refs
serve-mcp-*/serve-stdio-*spawners still fail port contention with the generic "serve exited 1" — they get #12441's probe but not its named error #12526 / PR test(cli): catch the silent port drift in three serve e2e spawners #12552 — where the race was observedupdate_pull_requesttool un-drafts a PR when you pass onlyreviewers— it published a governed-surface draft into a merge queue #12200 — sibling class: an MCP write with a side effect the caller did not ask for