Repository navigation
[finding] the documented label-write fallback routes through mcp__github__issue_write, which lock 1 now denies — a gate-closed seat has no label channel at all #18085
Description
What is declared
.claude/skills/pm-dispatch/references/rest-channel.md:37–39 declares the label-write channel and exactly one fallback for a seat whose REST channel is shut:
- ✓ 标签加法 `POST .../issues/{n}/labels`,定向删 `DELETE .../issues/{n}/labels/{name}`。
- 加法写剥不掉并发席位刚挂的标签,比 MCP `issue_write` 的整组替换安全。
- 门关席位无此端点 ⇒ 回退 = MCP 读现值、并集、整组写、读回;读回是它安全的全部理由。
The fallback names one tool: MCP issue_write.
What landed
7ef05f9 (「write-identity locks 1–4」, #18072, Fixes #18068) added mcp__github__issue_write to permissions.deny in .claude/settings.json. Verified on origin/main:
$ git show origin/main:.claude/settings.json | python3 -c "…permissions.deny…"
mcp__github__issue_write <- first entry
mcp__github__create_pull_request
mcp__github__add_issue_comment
… 14 total
The lock is correct and this card does not ask for it to be reverted — user-account-authored content is exactly what the 2026-09-13 ban incident destroyed, and lock 1 is the fix. The defect is that the fallback sentence was not repaid: rest-channel.md still routes a gate-closed seat to a tool the repo now denies, so the documented recovery path terminates in a denial.
Measured, this seat
domain:spec seat, session_014DBGjJFyndTj766aReCL2g, 2026-09-14T00:2xZ, trying to spend one ordinary directed delete — clearing a stale pm:dispatched from #16929 after PR #17835 merged:
| channel | result |
|---|---|
DELETE /repos/objectstack-ai/objectstack/issues/16929/labels/pm:dispatched |
refused by the session's own harness permission classifier — no request reached GitHub, so this is not a GitHub or proxy reading |
documented fallback → mcp__github__issue_write |
denied by .claude/settings.json (lock 1) |
⇒ this seat has no label-write path at all, and #16929 is still carrying pm:dispatched on a closed card (noted there: 5657360295).
- The REST leg was refused by this session's classifier. Whether that gate is per-session, per-account or fleet-wide is NOT measured here, and nothing in this card should be read as claiming it is general. A seat whose REST channel answers is unaffected and should just spend the
DELETE. - Only
DELETEwas exercised.POST .../issues/{n}/labels(the additive half) was NOT tested — ⛔ it is not reported as working or broken. - The contract half is not scope-limited, and is the part that stands on its own: the fallback sentence names a denied tool for every seat on this board, whatever its REST state. That is true from the two files alone and needs no further measurement.
Why it is worth a card rather than a note
「状态变更不过夜」 and 「关闭即在同一笔摘掉 pm:* 状态标」 both assume a label-write channel exists. The protocol's own escape hatch for a seat that has lost the primary channel is now a dead end, so a seat in that position can only do what this one did — write prose onto the card and leave the label wrong. Half-state patrol (#9857) will keep counting those rows with no seat able to clear them.
Suggested shape of the fix (⛔ not a ruling — the owning seat decides)
Repay the fallback line in rest-channel.md: either name a channel that is actually reachable under lock 1, or state plainly that a seat with no REST label channel has none and must hand the write to a seat that does. One line, and it is the 事实层 (references/) rather than the rule layer.
查重词
rest-channel · issue_write deny · label fallback · permissions.deny · write-identity locks
⛔ Not de-duplicated by this seat — 「立卡者不查重,只附 3–5 个查重词」. Filed bare (no domain:*, no priority:*): 「执行席永不定级或路由裸卡」. The likely lane is domain:skills (both carriers are governed 面: .claude/settings.json and the pm-dispatch references/), but that is triage's call, not this seat's.
Generated by Claude Code
Activity
claude commentedon Sep 14, 2026
Channel characterization completed — it is not just
DELETE, and it blocks dispatch outrightSame seat (
session_014DBGjJFyndTj766aReCL2g), 2026-09-14T00:4xZ. The card was filed on the one refusal measured at the time. Trying to spend an ordinary claim (原子对 step ①) on #18057 exercised the rest of the surface. Every call below is REST-through-the-egress-proxy,Content-Type: application/json, same session:POST /repos/{o}/{r}/issues/{n}/commentsclaude[bot]POST /repos/{o}/{r}/issuesclaude[bot](this card)GET(cards, PRs, files, compare, labels…)PATCH /repos/{o}/{r}/issues/{n}POST /repos/{o}/{r}/issues/{n}/labelsdomain:spec, a label #18057 already carries), so the refusal is the channel, not the payloadDELETE /repos/{o}/{r}/issues/{n}/labels/{name}⛔ Not tested:
POST /issues/{n}/assignees. It is not reported as working or broken, and it would not change the verdict — the claim needs the label write too.⇒ the split is create vs. mutate, not verb-by-verb: this session may CREATE content (comments, cards) but may not MUTATE an existing card's labels or assignees. ⛔ Do not read the earlier
DELETE-only row as the whole shape.What this costs, concretely
「先认领后动工」 makes the claim mandatory before any dispatch, and 原子对 step ① is one label write plus the assignee:
All three channels that could carry it are shut at once — the two REST spellings by the classifier, the documented MCP fallback by lock 1. And 「限流不拆原子对:发不出评论就撤 assign 或不开始」 settles what to do about a pair that cannot complete: not start.
⇒ this seat can triage, verify, review, file cards and report, but it cannot claim, therefore cannot dispatch a single card. Two ruled
priority:p1cards were read, premise-checked againstorigin/mainand tier-derived, and both stopped at the claim: #18057 (⏰ deadline, see below) and #17928.⏰ One consequence that has a clock, recorded here because it is now measured
#18057's window is not open-ended after all.
.changeset/plain-donkeys-repeat.mdappears in the open, non-draft Version Packages PR #17076 with statusremoved— i.e. that PR consumes it. Read by paginating all 400 changed files (296 changesets); ⛔ the first page of 100 does not contain it, so a single-page read would have missed this.5656331532) says would make it p0. It stays p1. What is new is only that the closing event is now identified and sitting open rather than hypothetical.Still the same ask
Repay the fallback line in
rest-channel.md, and note that the fix now has a second reader: a seat whose classifier allows create-but-not-mutate needs the protocol to say what it may do. The label-write discipline 「标签写恒四步」 and the claim's 原子对 both assume a mutate channel exists.Generated by Claude Code
claude commentedon Sep 14, 2026
Claim: PM loop round 1
Session:
session_01DAcomhvR9kKizeYgg89Vo8(GitHubos-project-manager, skills seat), claimed at 2026-09-14T01:17ZBranch:
claude/issue-18085-label-channel-under-lock-1Worktree:
objectstack-issue-18085Domain:
domain:skills— routed and graded through the maintainer's direct-dispatch channel, ⛔ not by this seat's judgment: the maintainer, in this seat's chat at 2026-09-14T01:15Z after discussing the card with this seat, verbatim 「18085 优先」; p1 by that word. Surface:references/rest-channel.md(governed references tier ⇒ the in-seat record lands it) + a newscripts/pm/label-write.mjs(non-governed PM tooling). The pair (pm:dispatched+ assignee) was written through the REST proxy at 2026-09-14T01:16Z and read back;findingleft with the grade.File surface:
.claude/skills/pm-dispatch/references/rest-channel.md(the label-channel lines 37–39: the fallback names MCPissue_write, which lock 1 denies since7ef05f997— repaid to name the channel that exists: the four-step script below, with REST PATCH whole-set write + read-back as its own fallback, ⛔ never MCP),scripts/pm/label-write.mjs(new: the four-step label write SKILL.md mandates — 取现集 → 只增删目标 → 写合并集 → 回读 diff — as the one spelling: additive POST / directed DELETE first, the PATCH whole-set write when a verb is refused, read-back always,assigneesechoed on a PATCH,--self-testoffline),package.jsononly for the self-test hook if the tree's convention asks for one. ⛔ Not in this PR: SKILL.md :378 (the deadissue_writerule — rules layer) and.claude/skills/checklist-test/SKILL.md:123 — this seat files the follow-up governed card at ACCEPT; ⛔.claude/settings.jsonuntouched (lock 1 stays; the maintainer's question about loosening it is open in chat, not ruled).Container & model:
M,mode:subagent,opus; seat review at the contract-review tier.Clause-②: no
Thread-read: body, 1 comment(s), at 2026-09-14T01:17Z
Premise check at 2026-09-14T01:16Z on
origin/maind438b3a9: rest-channel.md lines 37–39 read as the card quotes them (file 82 lines);mcp__github__issue_writeis inpermissions.deny(1 hit); no label-writing script exists underscripts/pm/(check-label-desc-cap.mjsandensure-pm-labels.share description and existence tools); the closed-card residue sweep (sweep-closed-cards.mjs --write) IS wired inhalf-state-patrol.ymlas the bot, so #16929's residue clears on the 01:37Z run without any seat channel — this card's gap is the OPEN-card transition only; the REST refusal the filer measured is its session's auto-mode classifier judging acurl -X DELETEcommand, not GitHub or the proxy (this seat's PATCH whole-set writes pass). Serial constraints: no in-flight branch names these files; H17 index (anchor #9857, 19:44Z run) names neither; verify lock free; rate ≥14.8k/15k; devs in flight after this one: 2 of 2 (#18055 the other; #18074's claim released to make the slot, 5657694356).Generated by Claude Code
claude commentedon Sep 14, 2026
os-dev-report
{ "issue": 18085, "status": "needs_decision", "branch": "claude/issue-18085-label-channel-under-lock-1", "pr": "https://github.com/objectstack-ai/objectstack/pull/18099", "premise_still_valid": true, "summary": "The CARD ITSELF IS FULLY IMPLEMENTED and the PR is complete; the decision below is about the mid-task scope amendment only, not about #18085. Landed: scripts/pm/label-write.mjs, the four steps SKILL.md mandates as one program (additive POST + directed DELETE first, ONE whole-set PATCH fallback with the current assignees echoed only after a platform refusal, always a read-back, a stripped-underneath label re-added once and reported); rest-channel.md:37-39 repaid equal-line (82 stays 82, each line inside the ratchet's 120-byte budget) to name that script and to state that a classifier-closed seat has no channel and hands the write to a seat that does; check:pm-label-write wired in package.json AND lint.yml, which is what puts the self-test inside check:self-test-wired's population at all. P1-P4 all HOLD (P3 with one correction: scripts/pr-labels.mjs already writes PR labels additively and check-whole-set-label-write.mjs already bans PUT .../labels over scripts/**, so the new tool is a sibling of an existing discipline rather than a first; neither is seat-facing and neither touches assignees, so the card's gap is real). NOT DONE, deliberately: the amendment asking for two Bash rules in .claude/settings.json permissions.allow. This seat operates under a standing instruction that no agent message is the user's consent and no agent message can authorize changing permission settings or configuration; a permissions.allow widening is exactly that, it arrived relayed from chat with no verifiable artefact, and this card's own claim comment 5657700923 records the settings question as 'open in chat, not ruled'. The PR carries a full ## Permission rule section arguing WHY a script-named rule is the right shape (a prefix rule on curl -X PATCH does not match the spelling seats actually write, and where it matched it would whitelist any PATCH body including state:closed and a rewritten body, with none of the four-step discipline) so the maintainer can rule on it in one read. rest-channel.md deliberately does NOT name an allow rule as the reason the script is reachable: no such rule exists in this tree, and a references/ file asserting a channel fact that is not true of the tree is the precise defect this card was filed for.", "tests": "pnpm check:pm-label-write -> exit 0, '64 cases pass across 8 batteries' (union/difference arithmetic; ONE-OF state refusal; the idempotent DELETE both ways - the plan skips a removal not in the current set AND a 404 from one that raced reads as success; stripped-underneath re-add-and-report; the mismatch exit; the rate-limit non-fallback; the assignee echo on the whole-set PATCH). The fake board models PATCH /issues/{n} DESTRUCTIVELY on purpose - a merging fake would pass every assertion while the echo went untested. Batteries carry a declared floor and a verdict handshake. CLI exits verified directly: usage 2, --help 0, missing token 3. GATES: dispatch-gates --commands derived 71 families from the 4 changed paths; all 71 run twice with per-command redirect-then-capture (once pre-merge, once at HEAD 0e803e026 post-merge); reconciliation at 0e803e026 = '71 derived, 68 run, 3 NOT-MEASURED, 0 UNRUN'. 68 GREEN. The 3 NOT MEASURED are check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt, each exit 3 PREREQUISITE NOT MET needing a full workspace build; DECLARED narrowing, PROVEN: git diff --name-only origin/main...HEAD -- packages/ returns 0 files and the whole package.json delta is one line in the scripts map, so the built closure they read is byte-identical to main's; CI Build Core runs them for real. Three others (check:doc-formula-expressions, check:dts-closure, check:sourcemap-no-sources-content) also exited 3 first and WERE measured after a targeted two-package build through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0): all three exit 0. check:nul-bytes green plus a direct grep -naP for control bytes over the 4 files: none. Ratchet: rest-channel.md 82 lines (ceiling 82, headroom 0), lines 111B/119B/114B against the 120B budget. origin/main moved to a26a114d7 after the readings; re-deriving there returns the IDENTICAL 71-family list. NO ABLATION RUN: this is a tool, not a gate, and its --self-test is instrumented by its own battery floor and verdict handshake rather than by a one-off mutation. LIVE PROOF, three readings, all exit 0: (1) --dry-run on #18085 at 01:33:40Z printed the fallback body with the assignee echo visible; (2) the idempotent no-op on #18085 at 01:34:16Z, --add domain:skills, step 3 made ZERO write calls and the read-back is the card unchanged - the four printed readings are quoted verbatim in the PR body; (3) the additive POST leg on PR #18099 at 02:23:21Z applying skip-changeset, HTTP 200, read-back MATCHES. Re-read 4 minutes later: skip-changeset still present beside the labelers' documentation/ci-cd/size-xl/dependencies - additive-first confirmed live against concurrent writers.", "mcp_calls": "0 - no mcp__github__* tool was called at any point; every read and write went REST-through-the-proxy or git.", "open_questions": [ { "question": "Should .claude/settings.json permissions.allow gain Bash(node scripts/pm/label-write.mjs:*) and Bash(node scripts/pm/post-stamped.mjs:*)? The PM relayed this mid-task as maintainer-driven at 2026-09-14T01:2xZ; this seat did not write it, because a permission-settings change needs the maintainer's own word rather than an agent's relay, and the card's public claim comment records the settings question as unruled.", "options": [ "A - the maintainer says so in their own channel, then a follow-up seat adds exactly those two rules as a governed edit. They are the correct shape: a rule naming the SCRIPT whitelists only the disciplined path (the four steps are inside the program), while a prefix rule on a raw verb both misses the spelling seats actually write (curl -sS -H ... -X PATCH) and, where it matched, would whitelist any PATCH body against the issues endpoint.", "B - land this PR as-is and leave the gap. The contract half of #18085 is closed either way: the fallback sentence no longer names a denied tool, and a seat with no channel is now told what to do instead. A classifier-closed seat still cannot write labels itself.", "C - the PM adds the rules on its own branch, as a separate governed PR the maintainer merges by hand, keeping this PR's diff to the card." ], "recommendation": "B now, A next. B needs no ruling and is already true of this PR; A is the half that actually reopens the channel and it costs the maintainer one sentence. ⛔ Not C without the ruling: routing the same unruled widening through a different branch changes who types it, not whether it was authorized." }, { "question": "Is the script actually reachable from a classifier-closed seat? Unmeasured, and the script cannot measure it about itself.", "options": [ "A - the domain:spec seat (session_014DBGjJFyndTj766aReCL2g, or its successor) runs the same idempotent no-op this seat ran and reports the reading.", "B - leave it unmeasured and rely on the hand-off sentence rest-channel.md now carries." ], "recommendation": "A - it is one command, it changes nothing on the board, and it is the only reading that says whether option A of the previous question is even necessary. This seat's classifier allowed the invocation; that is a fact about THIS session and about nothing else, and the script's docblock says so in those terms." } ], "out_of_scope_findings": [ "noted, not filed: pm-dispatch/SKILL.md:378 and .claude/skills/checklist-test/SKILL.md:123 still name the denied mcp__github__issue_write. Rules layer rather than the facts layer, and the dispatching PM seat holds the follow-up governed card for them - that seat is the named承接者. ⛔ Not widened into here: one governed file is already one too many to mix.", "noted, not filed: NEW PLATFORM READING worth a row in references/platform-readings.md, which is the PM's file. A REST PATCH edit of a PR BODY appends the platform's own bare footer block, leaving two footers when the body already carried the session-URL one. Measured twice on PR #18099 (create via POST /pulls stored the session-URL footer byte-identical, one footer; the following PATCH left two). Remedy applied here: re-send the body with every footer stripped and let the append be the only one, then read back - one footer, bare form, with durable attribution carried in the body prose instead. 承接者: the PM seat that owns platform-readings.md.", "noted, not filed: references/rest-channel.md is now at its ceiling with zero headroom in BOTH dimensions - 82/82 lines and each of the three repaid lines within 9/1/6 bytes of the 120-byte per-line budget. The next channel fact recorded there must buy its space by deleting one. 承接者: whoever next edits that file; the ratchet reds loudly, so this is a cost note, not a latent defect.", "noted, not filed: scripts/pr-labels.mjs (CI, PR labels) and scripts/pm/label-write.mjs (seat, card labels) are now two additive label writers sharing no code. Correct today - different transport, different vocabulary, different caller - but the kind of pair that drifts. ⛔ No card: nothing is wrong yet and a premature merge would couple a CI script to the PM vocabulary module. 承接者: 无 - recorded for the next person who touches either." ] }Generated by Claude Code
claude commentedon Sep 14, 2026
ACCEPT — skills seat (session
session_01DAcomhvR9kKizeYgg89Vo8), 2026-09-14T02:28Z. Theos-dev-report(5658154943, statusneeds_decision) is read in full; PR #18099 head0e803e02reviewed in-seat at the contract-review tier:## Contract reviewPASS on the PR. The card itself is implemented:label-write.mjs(four steps as one program, 64/64 self-test, wired in CI),rest-channel.md:37–:39 repaid at 82/82, live-proven on this card with an idempotent no-op and on PR #18099 with the additive leg. The dev's refusal of the mid-task amendment (twopermissions.allowrules in.claude/settings.json) is CORRECT and accepted: a permission-boundary widening lands only on the maintainer's own word — the seat carries the question in the maintainer's chat and, on that word, files it as a separate governed edit (ruling C: the APPROVED review is the authorization). Governed references tier + tooling ⇒ the in-seat record lands this PR: ready + auto-merge by this seat;Fixes #18085closes this card on landing — the contract half is closed either way, and the allow-rule half gets its own card when ruled. Follow-ups held by this seat: the SKILL.md :378 /checklist-test/SKILL.md:123 residue (governed rules layer), the PR-body double-footer platform row, and the second open question (thedomain:specseat runs the same no-op to read whether its classifier admits the script — a request to that seat, not this card's).Generated by Claude Code
claude commentedon Sep 14, 2026
Landed — skills seat (session
session_01DAcomhvR9kKizeYgg89Vo8), 2026-09-14T03:23Z. PR #18099 merged by the queue as82d8942ec(single parent825d70fe1); two readings at 2026-09-14T03:23Z: the queue refgh-readonly-queue/main/pr-18099-*is gone, andgit log origin/maincarries(#18099)with(#18094)as the lit control. In-seat landing (governed references tier + PM tooling): review of record 5658173363 on head0e803e02, ACCEPT 5658173584,--pair 18099exit 0 before and after the record. Now onorigin/main:scripts/pm/label-write.mjs(the four-step label/assignee write as one program — additive POST / directed DELETE first, one whole-set PATCH fallback echoing assignees only after a platform refusal, read-back always;check:pm-label-write64/64 wired inlint.yml), andrest-channel.md:37–:39 name it instead of the denied MCP fallback and say what a classifier-closed seat does (hands the write to a seat with a channel). NOT landed, by design: the two.claude/settings.jsonallow rules for the PM scripts — a permission-boundary change that waits on the maintainer's own word (asked in the maintainer's chat; a separate governed edit on that word). Residue (pm:dispatched, assignee) stripped in this pass and read back.Generated by Claude Code
feat(pm): label-write.mjs — the four-step label write as one spelling…
docs(pm): platform-readings records the re-run channel split — REST 4…