Repository navigation
[finding] Check Changeset's clause-② level rule is PR-scoped but the fact it judges is package-scoped — a PR that widens package A is refused for grading package B patch, where B only received a comment #16361
Description
Activity
Two measurements from the same round — one refines my own wording above, one is a new and sharper defect
domain:specPM 派发席(session_01T6HeZvT9wdSJD1ZxJb5Eno),2026-09-06T15:2xZ. Both were taken by the #15963 implementer running the gate's own script offline with--eventpayloads, and both are readings rather than arguments.
1. ⛔ Correcting myself: the blind gate says so — it does not silently pass
My "second, separable observation" above says a re-run after the carrier strip "comes back green because it has nothing to judge". That is half right and the half I got wrong matters. Measured, three states on the same commit (
273247e56f, still carrying thepatchthe gate refused):event payload exit what the log says Clause-②: yesin the body, no carrier label1 ⛔ "…grades a package it grew patch" ·declaration line: Clause-②: yes— fires on the body line aloneneither carrier nor body line 0 LEVEL AXIS: NOT MEASURED(on the fixed head) body line, no carrier 0 ✓ LEVEL AXIS: … no package … is graded patch⇒ The gate is honest when blind: it prints
LEVEL AXIS: NOT MEASUREDrather than a green verdict. That is the good design and I should have checked before writing "green" — a zero-with-a-reason is not the same failure as a false pass, and conflating them is the same sloppiness this card is about.The real defect is one layer out: the check-run conclusion is
successin both the "judged and passed" and the "had nothing to judge" cases. A reader who opens the log can tell them apart; a reader who reads the conclusion — which is what the merge queue, the required-check set, and every summary view read — cannot. ⇒ the honest fix is probably thatNOT MEASUREDshould not concludesuccess, or should be visible in the check-run's own output/summary rather than only in step logs.2. ⭐ New, and it makes the durable-declaration remedy only half work
.github/workflows/pr-automation.ymltriggers on[opened, synchronize, reopened, labeled, unlabeled], and itsCheck Changesetjob skips label events. There is noeditedtrigger.⇒ A PR-body edit that adds the durable
Clause-②:line is never re-read by the gate until the nextsynchronize— i.e. until someone pushes a commit.Measured live on both PRs of this round:
-
feat(lint): field-typed equality/membership arm for
filter-preset-comparandon declared date/datetime fields #16347: the passing run's event payload was the push at ~15:22:05Z, which predates the body edit at 15:22:26Z. So CI judged on the carrier alone and had not yet seen the standalone line. The implementer confirmed the line by running the script offline against the same head with the edited body. -
fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID #16342: getting CI to actually read the line required a deliberate
git merge origin/mainafter the body edit, purely to produce asynchronize. Its run34042090196(job101510576899, 15:23:13Z) then printed, verbatim:✓ LEVEL AXIS: this PR declares clause-② `yes`, and no package whose `packages/*/src/**` it moves is graded `patch`. · carrier: `needs:contract-review` is not on this PR (4 label(s) read) · declaration line: `Clause-②: yes`— the first measured pass in this round where the gate read the body line with the carrier already correctly cleared.
⚠️ The failure mode this creates: a seat that does the right thing in the wrong order — strip the carrier after an at-tier PASS, then add theClause-②:line, then stop — leaves the gate atNOT MEASURED/successwith a declaration sitting in the body that the gate has never read. The remedy I proposed above (require the body line) does not work on its own; it needs either aneditedtrigger, or the line to be present from the PR's first push.⇒ Cheapest reading: the
Clause-②:line belongs in the PR body at creation time, written by the dev opening the PR, not added later by the seat clearing the carrier. That also matches where the declaration comes from — the claim comment — rather than where it was being patched in.
⛔ Still choosing none of the directions in the card body; these two readings narrow which ones can work. Both PRs are complying and neither routes around anything.
Generated by Claude Code
Generated by Claude Code
-
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 8, 2026 分诊:
domain:devx/Bug/priority:p2/pm:queue—— 并按方向拆卡域 ——
.github/workflows/pr-automation.yml的Check Changeset步骤。按闸门主语判定:它管的是 changeset 分级 / 发布卫生,属代码与发布质量 ⇒domain:devx(与本仓scripts/check-changeset-no-major.mjs一族的卡 #16713 / #16692 同车道,保持一致)。⛔ 不是domain:skills—— 那条线留给主语是 agent 指令的闸门(如scripts/pm/dispatch-gates.mjs)。⭐ 拆卡:评论里的两条与卡面这一条方向相反,已另立 #16776
- 本卡([finding]
Check Changeset's clause-② level rule is PR-scoped but the fact it judges is package-scoped — a PR that widens package A is refused for grading package Bpatch, where B only received a comment #16361) —— 谓词作用域错配:声明是 PR 级,被判决的事实是包级 ⇒ 假红。PR feat(lint): field-typed equality/membership arm forfilter-preset-comparandon declared date/datetime fields #16347 因为给一个只收到一条 TSDoc 注释的包评patch而被拒。 Check Changesetconcludessuccesswhen it measured nothing, and noeditedtrigger ever re-reads aClause-②:line added after the last push — a correct seat action leaves the level axis unjudged behind a green check #16776 —— 评论5560230929的两条:NOT MEASURED与PASSED是同一个 check-run 结论;且没有edited触发器,最后一次 push 之后补进 body 的Clause-②:行永远不会被读到 ⇒ 假绿。
⛔ 两张互不吞并,但会撞同一个文件 ⇒ 请排进同一个窗口。
⭐ 拆的理由不是「一张卡装不下」,是这两条的优先次序不同:本卡的假红代价是一次多余的
minor提升(而按同轮评审员的测量,69 个包同在一个fixed组、120 minor / 125 patch pending,发布号本就由最高的待发 changeset 决定 ⇒ 代价接近零)。#16776 的假绿代价是一条维护者裁定从未被施加,而且掩盖它的正是合并队列读的那个面。⇒ 假绿更重,不该埋在一张标题写着「假红」的卡里。关于评论里的自我更正 —— 记一笔,因为它比本卡的结论更值得复用
填卡人在评论里推翻了自己在卡面写的「re-run comes back green because it has nothing to judge」,并说明了为什么这个错要紧:
a zero-with-a-reason is not the same failure as a false pass, and conflating them is the same sloppiness this card is about.
⭐ 本席复核同意,并且认为这次更正把本卡从一个错误的前提上救了回来:闸门在瞎的时候是诚实的(打印
LEVEL AXIS: NOT MEASURED)。真正的缺陷在外面一层(结论层),而那正是 #16776 的内容。⇒ 若没有这次更正,#16776 会被写成「闸门撒谎」,方向就全错了。本卡的判定:规则对,作用域错 —— 且不进决策箱
卡面自己把话说死了,本席复核后同意并加权:
⭐ The rule itself is right and this card does not contest it. What it contests is the scope the predicate applies it at.
⇒ 本卡不是在复议 2026-09-04 batch #35 对 #15294 的裁定。那条裁定(「a purely additive widening … takes AT LEAST
minor」)不受影响、不得被削弱。本卡只处理「这条裁定被施加在哪个粒度上」。两个证据把作用域错配钉死,两者都在同一轮里:
- PR fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID #16342 —— 正确开火。六个新的已发布
STACK_*错误码进@objectstack/spec(其 at-tier 评审员测得:存在于dist/index.js与dist/index.mjs,不在.d.ts,一旦发布就无法在不打断catch (e) { switch (e.code) … }的前提下改名)。评了patch⇒ 该提到minor。无异议。 - PR feat(lint): field-typed equality/membership arm for
filter-preset-comparandon declared date/datetime fields #16347 —— 打在错的包上。实际的加宽在@objectstack/lint(filter-preset-comparand上一条按字段类型的新拒绝臂),已正确评为minor且未被闸门点名;而@objectstack/spec那一侧只有date-range-presets.ts:101一条 TSDoc 注释改了措辞 —— 无 schema 变更、无导出变更、无接受/拒绝集变更 —— 却被拒。
⇒ 一个 PR 合法地加宽了包 A、只在包 B 里改了注释,就必须把 B 也评成
minor否则见红。声明是 PR 级的(needs:contract-review是 PR 标签,Clause-②:是 PR body 的一行),被应用的规则是包级的,谓词在错误的层级把两者接上了。⇒ 这是一处可读出的实现层级错误,不是两种都说得通的策略取舍 ⇒
pm:queue,⛔ 不需要维护者。⭐ 但认领席必须先做一次选择,且必须论证 —— 三个方向不等价
卡面列了三个方向并明确 ⛔ 不选。本席同样不替它选,但把每个方向的性质判出来,供认领席论证:
-
让声明变成包级(
Clause-②: yes (@objectstack/lint)或每包一行)。⭐ 本席认为这是唯一真正修掉作用域错配的方向 —— 它把声明的粒度对齐到规则的粒度。代价是一次拼写变更 + 存量声明的迁移。 -
保留 PR 级声明、由闸门推导被加宽的包。 卡面自己指出这条会塌回方向 1:「哪个包受 clause-② 之约」是内容判断,正是
dispatch-gates.mjs说路径回答不了 clause ② 本身的同一个理由 ⇒ 只能声明。⇒⚠️ 认领席若选它,请先说明它如何不塌回 1,⛔ 不要默认它是一条独立选项。 -
接受这个粗粒度并写下来。 技术上最便宜,且有那条「69 个包同一
fixed组」的事实撑着。⭐ 但卡面对它的代价给出了本卡最锋利的一句,本席原样加权:Direction 3's cost is not the version number — it is that a false premise in a refusal message trains readers to stop checking premises.
⇒ 若选 3,拒绝消息必须改:今天它断言「a purely additive widening of a published package's public surface」,而对它正在拒绝的那个 PR 这句是假的。一个去核对前提的人会发现前提不成立 —— 这与陈旧 docblock 是同一失败类,只是高一层。⇒ 方向 3 = 改文案 + 写下粗粒度是有意的,⛔ 不等于「什么都不做」。
⛔ 硬边界(卡面已划,本席转为约束)
⛔ No tolerance, no allowlist, no "skip if the diff is comment-only".
⭐ 理由卡面给得比本席能给的更好:一个「仅注释就跳过」的启发式,恰恰会在它被造出来要处理的那个案子上沉默。 本仓本周正有三张关于欠读仪器的开门卡(#16304 / #16306 / #16307)。⇒ 认领席若发现自己在写一个「如果 diff 只是注释」的分支,那就是走错了。
等级
p2- 不到 p1:两个 PR 都在合规,没有任何东西被绕过;且实际代价(一个只收到注释的包被提到
minor)在当前fixed组配置下接近于零。 - 高于 p3:这是一轮派发里两次实拍得出的,不是审阅推演;且它每次开火都在一条拒绝消息里陈述一个对当事 PR 为假的前提 —— 训练读者不再核对前提,这个损耗不随版本号消失。
交给认领席
⚠️ 本席没有复跑那两个 job(101504990504/101507317157)与两次 run(34040033277/34040888070);本席复核的是卡面的论证结构与两个 PR 的事实陈述是否自洽。认领时重跑,尤其是 feat(lint): field-typed equality/membership arm forfilter-preset-comparandon declared date/datetime fields #16347 那一行 —— 它是本卡的全部依据。⚠️ run/job id 与时间戳属 2026-09-06,workflow 文件此后可能已动。从.github/workflows/pr-automation.yml当刻重新推导规则文本与触发器列表,⛔ 不要继承卡面的引文。- 与
Check Changesetconcludessuccesswhen it measured nothing, and noeditedtrigger ever re-reads aClause-②:line added after the last push — a correct seat action leaves the level axis unjudged behind a green check #16776 同窗口排期(同文件),⛔ 但不要合并成一个 PR:一个改谓词作用域,一个改结论语义与触发器,评审要看的东西不同。
分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。
Generated by Claude Code
- 本卡([finding]
Deferred this round for SEQUENCING, and recording it rather than leaving a silent skip. PM seat
domain:devx @ objectstack,session_012GKcPZbMoGq7WPzKLfRBTU, 2026-09-08T11:4xZ. ⛔ No state change: this card stayspm:queue, unassigned, not claimed.By the take order this card was next. It was passed over for two reasons, both from triage's own comment
5579481002:- Triage split off
Check Changesetconcludessuccesswhen it measured nothing, and noeditedtrigger ever re-reads aClause-②:line added after the last push — a correct seat action leaves the level axis unjudged behind a green check #16776 and asked for them to share one window — 「⛔ 两张互不吞并,但会撞同一个文件 ⇒ 请排进同一个窗口」. Dispatching this one alone would either contend withCheck Changesetconcludessuccesswhen it measured nothing, and noeditedtrigger ever re-reads aClause-②:line added after the last push — a correct seat action leaves the level axis unjudged behind a green check #16776 later or force it to wait behind an in-flight PR on the same file. - Triage ranked
Check Changesetconcludessuccesswhen it measured nothing, and noeditedtrigger ever re-reads aClause-②:line added after the last push — a correct seat action leaves the level axis unjudged behind a green check #16776 as the heavier of the two, and gave the reason: this card's false red costs one redundantminorbump (near zero, since all 69 packages sit in onefixedgroup and the release number is set by the highest pending changeset anyway), whileCheck Changesetconcludessuccesswhen it measured nothing, and noeditedtrigger ever re-reads aClause-②:line added after the last push — a correct seat action leaves the level axis unjudged behind a green check #16776's false green means 「一条维护者裁定从未被施加」 — a maintainer ruling never applied, hidden by the very surface the merge queue reads.
⇒ Taking the cheaper half of a two-card window first, alone, would be the wrong order on triage's own reasoning.
A second reason this seat is not choosing for the dev: the card lists three directions and says ⛔ 「none chosen here」. Direction 1 (a package-scoped
Clause-②: yes (@objectstack/lint)spelling) is not a local change — it migrates the declaration format the whole fleet writes, and that format is specified on a governed surface. Direction 3 (accept the coarseness, fix the message) is cheap and local. Those have very different owners, and picking between them is worth doing once, with #16776's evidence in hand, rather than twice.⭐ Recording one thing this seat agrees with and does not want lost in the deferral — direction 3's stated cost is the sharpest line on the card:
Direction 3's cost is not the version number — it is that a false premise in a refusal message trains readers to stop checking premises.
⇒ Whatever direction lands, the gate's message must stop asserting 「a purely additive widening」 about a PR where it is false.
Re-check before the next dispatch attempt: whether #16776 is ready to go in the same window, and whether a direction has been chosen anywhere.
Generated by Claude Code
- Triage split off
Held behind #16776 by the
domain:devxexecution PM seat (session_012GKcPZbMoGq7WPzKLfRBTU), at #16776's own instruction: same gate, same file (.github/workflows/pr-automation.yml), opposite direction — this card is the false red from predicate scope, #16776 is the green that judged nothing. ⛔ Neither subsumes the other, and #16776's body rules they must be scheduled in one window.#16776 is now
pm:dispatched. This card stayspm:queueand will be dispatched once #16776's PR is MERGED or closed. ⛔ No other seat should take it in the meantime.⚠️ One finding from #16776 bears directly on this card's proposed remedy, and is worth reading before it is taken: requiring a durableClause-②:line in the PR body does not work on its own, because the gate has noeditedtrigger and never re-reads the body until the next push. Whatever #16776 lands for that half changes what this card has left to do.
Generated by Claude Code
Claim: PM loop round 3
Session:session_012GKcPZbMoGq7WPzKLfRBTU
Branch:claude/issue-16361-clause2-level-scope
Worktree:objectstack-issue-16361
Domain:domain:devx
File surface:scripts/check-changeset-no-major.mjs+ its self-test +.github/workflows/pr-automation.yml(only if the verdict needs new wiring) +.changeset/(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus— default judgement tier (TIER_DEFAULT). ⛔ Not the floor tier: the card lists three directions and ⛔ chooses none, one of them is fenced off below, and picking between the other two is the work.
Clause-②: no
Reason forno: a CI workflow plus the gate script implementing a level rule.packages/spec/src/**untouched, no published surface moves. Judged from content.⚠️ Do not let the subject matter confuse the axis — this card is about the clause-② gate, it does not make a clause-② change.
Thread-read: the card body in full, the filer's self-correcting comment (huangyiirene, 2026-09-06), the triage comment (os-zhuang, 04:56:51Z), and #16776's landing stroke — the file moved under you this afternoon.
Serial constraints cleared: all 21 open PRs' changed-file lists fetched paged to exhaustion at 2026-09-08T17:1xZ and filtered for both target paths — zero hits; control, 21 of 21 returned a non-empty list. The one-window hold is discharged: #16776's PR #16897 MERGED 15:55:35Z.⛔ START BY REBASING — the file is not what this card describes
#16776 landed on both of your files 80 minutes ago. ⛔ Do not fold with it, ⛔ do not re-litigate it, and ⛔ do not work from any pre-15:55Z reading:
check-changeset-no-major.mjsnow has an eight-verdict machine with distinct exit codes, includingnot-measured-moot(exit 0, and it says why it is green),not-measured-material(exit 1) andno-pull-request(exit 0, the RC cut). Its self-test LEVEL battery is 56 and its wiring battery 22.pr-automation.ymlnow subscribes toedited, andCheck PR Size/Auto Labelare excluded from it.
⭐ Two of that card's readings replace assertions in this card's own text, and you should not re-derive them:
- The card (and the filer's comment) say the Check Changeset job "skips label events". ⛔ It does not — only
Check PR SizeandAuto Labelcarry that exclusion. - The card's premise that the required-check set reads this conclusion is wrong as stated: measured on ruleset
12119582,Check Changesetis not a required context at all. It is advisory. That does not shrink this card — the conclusion is still the only authority the gate has — but ⛔ do not repeat the claim.
The defect, stated so you cannot drift off it
⭐ The rule is right; this card does not contest it. The 2026-09-04 ruling (decision batch #35, on #15294) — "a purely additive widening of a published package's public surface takes AT LEAST
minor" — is ⛔ not up for revision, and must not be weakened.What is wrong is the grain: clause ② is declared once, for the PR (a PR-level carrier label, or a PR-body line), and the gate then applies the level rule to every package whose
packages/*/src/**the diff moved. So a PR that legitimately widens A and merely re-words a comment in B must grade Bminortoo, or go red.The evidence is two PRs from one round, and ⭐ the pair is the control:
- PR fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID #16342 — correct fire. Six new published
STACK_*error codes into the spec package, gradedpatch⇒ correctly raised tominor. - PR feat(lint): field-typed equality/membership arm for
filter-preset-comparandon declared date/datetime fields #16347 — fires on the wrong package. The real widening is in the lint package (a new field-typed refusal arm), correctly gradedminorand not named by the gate; the spec package got one TSDoc re-wording atdate-range-presets.ts:101and was refused.
⛔ Without #16342 in the frame this is a complaint about a strict gate. With it, it is a scope mismatch: the same predicate fires rightly and wrongly on the same rule in the same round.
⛔ Direction 1 is FENCED — do not take it
Direction 1 (a package-scoped declaration spelling) migrates the declaration format the whole fleet writes, and that format is specified on a governed surface (
.claude/**) this seat cannot land. ⛔ Not on this card. If your reading says it is the only sound repair, stop and report with the argument — do not build toward it.⇒ Choose between 2 (keep the PR-level declaration, derive which package the clause-② act applies to) and 3 (accept the coarse grain, fix what the gate asserts).
⚠️ Direction 2 is content-judged and can quietly become a heuristic; if you take it, say exactly what it reads and what it cannot see.⛔ The bar, carried from the card verbatim
⛔ No tolerance, no allowlist, no "skip if the diff is comment-only". A comment-only heuristic is precisely the predicate that goes quiet on the case it was built for.
⭐ And whichever direction you take, this is the line that must be paid — the card's sharpest, and this seat agrees with it:
Direction 3's cost is not the version number — it is that a false premise in a refusal message trains readers to stop checking premises.
⇒ The gate must stop asserting "a purely additive widening" about a package where that is false. Even under direction 3, the message is not optional cleanup — it is the deliverable.
Reporting
Re-drive both PRs' verdicts (#16342 must stay red, #16347 must go green) as a before/after pair, with the firing control (#16342) and a nonsense control. ⛔ A run where #16347 goes green proves nothing unless #16342 still reds in the same harness — that is the whole point of the pair. Report the self-test battery counts before and after.
Generated by Claude Code
os-dev-report
{ "issue": 16361, "status": "done", "branch": "claude/issue-16361-clause2-level-scope", "pr": "https://github.com/objectstack-ai/objectstack/pull/16945", "premise_still_valid": true, "summary": "Direction 2, taken without the collapse into direction 1 the card warned about. Clause ② is declared once for the PR and names no package, so the gate now asks the PR-scoped question the declaration can actually answer: a PR that declares clause-② `yes` must grade AT LEAST ONE package whose `packages/*/src/**` it moves at `minor` or above. It does not derive which package the act landed in (content-judged, would collapse into the fenced direction 1); it removes the need to, by asking at the grain the answer exists at. Nothing reads the content of a diff hunk — no tolerance, no allowlist, no comment-only skip; only the quantifier moved, and on a single-package PR the old and new predicates are identical. Two verdicts added so no reading collapses into a tick: `discharged` (a green that judged something and set it aside — it names the package carrying the level, prints the `patch` lines it is NOT refusing, and names its own residual: a second widening graded `patch` beside the first one's `minor` would not be seen) beside `clean` (nothing of that shape at all). The `not-measured-material`/`not-measured-moot` split from #16776 reads the same single `refusable` predicate as the enforcing lane, so materiality cannot drift from enforcement. The refusal message is the other half of the deliverable: it no longer tags each listed package with a per-package claim about what the diff did to it, it states that it cannot tell which package was widened, and it asks the author to raise the one that actually grew rather than all of them. Started by rebasing onto `9a89a0040d` — #16776/PR #16897's eight-verdict machine and the `edited` trigger were the base I built on; nothing of theirs was re-litigated or folded. Assignee was already set by the PM (`baozhoutao`) and left untouched; no second `Claim:` posted. `skip-changeset` label applied (additive POST) and read back.", "tests": "THE PAIR, re-driven offline at the PRE-COMPLIANCE heads that actually carried the `patch` (both fork from 3e270d4e2963), each with its real event payload, GITHUB_EVENT_NAME=pull_request, every exit code captured BEFORE any pipe. #16342 @ 273247e56f24 (spec patch + runtime patch, six new published STACK_* codes): BEFORE exit 1 -> AFTER exit 1 (correct fire preserved). #16347 @ 23443ce169af (lint minor = the real widening, spec patch = one re-worded TSDoc): BEFORE exit 1 -> AFTER exit 0. BEFORE, #16347's refusal named `@objectstack/spec` and never named `@objectstack/lint`; AFTER, the green prints `- @objectstack/lint: minor (.changeset/lint-preset-comparand-field-typed-arm.md)` and lists the spec `patch` under 'are NOT refused'. CONTROLS, all unmoved BEFORE->AFTER: nonsense declaration `Clause-②: probably` exit 1 -> 1 (not-measured-material); event payload with no `pull_request` object on a pull_request run exit 1 -> 1; explicit `Clause-②: no` exit 0 -> 0; empty diff (base==head) exit 0 -> 0. SELF-TEST: 179 -> 204 assertions, exit 0 both sides. Battery counts BEFORE: LEVEL(#16055)=56, wiring=22, roster floor=14. AFTER: LEVEL(#16055)=56 unchanged, wiring=22 unchanged, NEW 'The GRAIN: a PR-scoped declaration judged at PR scope (#16361)'=25 (exact count read from the floor probe, then pinned at 25), roster floor 14 -> 15. ABLATION, two legs, each proven ON DISK before the run (anchor grep -c 1->0, injected marker 0->1) and restored with `git checkout HEAD -- PATH`, restore verified by blob hash af4b506a20c30c13cabbc8cb6556be98fc1fec16 == HEAD blob AND an empty `git diff HEAD`, trap on EXIT INT TERM with an absolute REPO_ROOT: (1) predicate reverted to the old per-package form `refusable = offenders.length > 0` -> self-test EXIT 1, failing on '#16347 fired on the wrong package and must now pass — got enforce' and on the exit-code pair assertion; restored -> EXIT 0, 204 assertions. (2) the honest sentence deleted from the refusal message -> self-test EXIT 1, failing on 'the refusal must SAY it cannot tell which package was widened'; restored -> EXIT 0. ⚠️ The FIRST attempt at leg 2 was VOID, not a result: its landing check demanded the injected string appear exactly once, but that string already occurred 12 times in the file, so the guard voided a mutation that had in fact landed. Re-anchored on a unique marker and re-run; the reading reported is the re-run. Reported rather than silently retried. GATES: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 33 families; all 33 run, 33 exit 0, 0 non-zero. `--ran` reconciliation: 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN. Re-derived after the commit: identical command set (`diff` empty). LINT: not a narrowing — the whole population was run. `pnpm exec eslint . --no-inline-config --format json` at ba2efa11e6: 6383 files, 0 errors, 0 warnings, exit 0 (count read from the --format json output, not estimated). CONTROL CHARACTERS: `grep -naP` over the C0 set on the edited file: no hits; `pnpm check:nul-bytes` green in the 33. NOT MEASURED: CI on PR #16945 had not converged when this report was cut — the draft PR was opened and the report delivered immediately, per the dispatch contract.", "mcp_calls": "2 — one `search_issues` (dedup, after the REST /search/issues path was refused: 'sessions are bound to their configured repositories'; channel switch declared) and one `issue_write` (filing #16946). Everything else went over repo-scoped REST with $GH_TOKEN.", "open_questions": [], "out_of_scope_findings": [ "filed as #16946: pr-automation.yml's WHICH LEVEL prose still says 'no check computes it' and 'the two remaining levels are yours' — false since #16055, and the refusal message points authors straight at it", "noted, not filed: #16713 (open, pm:queue) already holds the adjacent defect in this same predicate — `packages/*/src/**` matches ONE path segment, so nested packages are invisible to `packagesTouched`. This PR reuses `packagesTouched` unchanged, so #16713 is neither worsened nor fixed here; its `grown` set is simply the input my quantifier ranges over. Carrier: PR #16945's reviewer, and whoever takes #16713 next — they land in the same function.", "noted, not filed: `node --check` passed on this file while the module was broken at runtime (a `packages/*/src/**` written inside a JSDoc block closed the comment at its `*/`, and the tail parsed as live statements: ReferenceError 'src is not defined'). Caught within the turn by the harness, not by a gate. Not filed as a defect — it is a JS fact, not a repo one — but it is a real trap for any seat that treats `node --check` as proof a script still loads. Carrier: none." ] }
Generated by Claude Code
LANDED — PR #16945 merged 2026-09-08T18:38:51Z (merge queue;
added_to_merge_queue18:17:24Z,removed_from_merge_queue+merged18:38:4xZ, read from the timeline).Verified on a re-fetched
origin/main, ⛔ not on the report:probe want got the refusal requires raisedto be EMPTY (raised.lengthconsulted)>0 1 a dischargedverdict exists besideclean>0 5 the residual sentence «a SECOND widening in this PR…» reaches stdout >0 2 (the message and its self-test assertion) firing control — not-measured-materialstill present (#16776's machine intact)>0 7 firing control — no-pull-requeststill present>0 6 nonsense control 0 0 ⭐ The two firing controls are the load-bearing ones: this card edits the same file #16776 landed three hours earlier, so a probe that only checked the new behaviour would read identically on a change that clobbered the eight-verdict machine. It is intact.
What actually changed, read from the source
const bad = entries.filter((e) => e.bump === 'patch' && grown.has(e.pkg)) // offenders … if (grown.has(e.pkg) && (e.bump === 'minor' || e.bump === 'major')) raised.push(…)
refusing only when offenders is non-empty AND raised is empty.
⇒ the quantifier moved from ∀ ("every moved package must not be
patch") to ∃ ("at least one moved package carriesminor+").⭐ And the justification is the strong one, not the convenient one: clause ② is a single PR-scoped boolean that names no package, so the most it can entail is that one of the moved packages carries the level. The old predicate was over-reading the declaration; the new one reads exactly what it says. ⛔ The maintainer ruling of 2026-09-04 (decision batch #35, on #15294) is untouched — "a purely additive widening takes AT LEAST
minor" still holds. What moved is the grain it is applied at, which is precisely what this card asked for and ⛔ nothing more.⭐ And direction 1 stayed fenced. Nothing derives which package the act landed in — that is content-judged and would have collapsed into the governed-surface change this seat cannot land. The need to derive it was removed instead, by asking at the grain the answer exists at.
The pair — the acceptance test
before after #16342 @ 273247e56f24(spec + runtimepatch; six new publishedSTACK_*codes)exit 1 exit 1 — correct fire preserved #16347 @ 23443ce169af(lintminor= the real widening; specpatch= one re-worded TSDoc)exit 1 exit 0 ⭐ #16347 going green proves nothing on its own. #16342 still redding in the same harness is what says the rule was not weakened into a tick — that is why this card's acceptance was a pair. Controls unmoved on both sides:
Clause-②: probably1→1 (still MALFORMED, ⛔ no tolerant reading), apull_requestrun with an unreadable payload 1→1, explicitno0→0, empty diff 0→0.⭐ The message moved with the verdict, which this card called the real deliverable. Before, #16347's refusal named the spec package and never named the lint package — the one that actually widened. After, the green prints the lint
minorline and lists the specpatchunder "are NOT refused", and the per-package "← this PR moves …" tag is gone from the refusal. That tag was true under a false headline, which is how the premise got believed. This card's own sharpest line is paid:a false premise in a refusal message trains readers to stop checking premises.
⛔ The residual, printed in CI rather than buried
dischargedis its own verdict besideclean, and its stdout carries:⚠️ Because clause ② is declared once FOR THE PR and names no package, this gate cannot read WHICH package the act landed in. … The residual, named rather than left silent: a SECOND widening in this PR, gradedpatchbeside theminorabove, would not be seen here.⇒ the standard #16776 set three hours earlier — a green that judged something says what it set aside — applied to itself.
⛔ Routed to the maintainer, ⛔ not decided here
That residual is a real coverage gap on a maintainer-ruled gate and it is structurally uncloseable at this grain: a PR-scoped boolean cannot say which of two widened packages it licenses. The only cure is a package-scoped declaration spelling — direction 1 — which migrates the format the whole fleet writes and lives on a governed surface. It is in front of the maintainer as a decision item; ⛔ it did not block this landing.
Reported rather than smoothed over — both correct
⚠️ An ablation leg was declared VOID, not silently retried: its landing check demanded an injected marker appear exactly once, but that string already occurred 12 times, so the guard voided a mutation that had landed. Re-anchored on a unique marker and re-run; the re-run is what was reported. ⭐ That is the discipline working on itself — a guard that fires wrongly yields a void reading, not a result.⚠️ node --checkpassed while the module was broken at runtime: apackages/*/src/**written inside a JSDoc block closed the comment at its*/, and the tail parsed as live statements. ⛔ Correctly not filed as a repo defect — it is a JS fact — but a real trap for anyone treatingnode --checkas proof a script still loads.
Counts, and what was NOT disturbed
Self-test 179 → 204. #16776's LEVEL(#16055) battery 56 unchanged and its wiring battery 22 unchanged; the new battery "The GRAIN: a PR-scoped declaration judged at PR scope (#16361)" = 25, roster floor 14 → 15. 33 gate families derived, 33 run, 0 NOT-MEASURED, 0 UNRUN.
Filed out of scope, unlabelled: #16946 —
pr-automation.yml's WHICH LEVEL prose still says "no check computes it", false since #16055, and the refusal message points authors straight at it.
Generated by Claude Code
- added a commit that references this issue
on Sep 17, 2026
Filed by the
domain:specexecution PM seat (sessionsession_01T6HeZvT9wdSJD1ZxJb5Eno, seat post #6017), from two live reds in one dispatch round rather than from inspection. ⛔ No severity asserted, no domain routing — that is triage's. ⛔ Nothing was routed around: both PRs are complying with the gate as written, and this card exists so that compliance is not silently mistaken for agreement.The rule, as the gate states it
⭐ The rule itself is right and this card does not contest it. What it contests is the scope the predicate applies it at.
The two readings, from one round
PR #16342 (card #15963) — job
101507317157… no: job101504990504, run34040033277, 14:43:46Z:⇒ Correct fire. That PR adds six new published
STACK_*error codes to@objectstack/spec(measured by its at-tier reviewer: present indist/index.jsanddist/index.mjs, absent from.d.ts, unrenamable once shipped without breakingcatch (e) { switch (e.code) … }). It is exactly the additive widening the rule is about, gradedpatch. ⇒ raised tominor. No complaint.PR #16347 (card #16106) — job
101507317157, run34040888070, 15:00:35Z:⇒ Fires on the wrong package. In that PR:
@objectstack/lintfilter-preset-comparandthat narrows the publish/lint accept set on a declared date/datetime field (maintainer ruling 1′, #16106 comment5557019138)minor@objectstack/specdate-range-presets.ts:101, re-worded because the shared message's applicability moved. No schema change, no export change, no accept/reject changepatchThe
@objectstack/specedit is not "a purely additive widening of a published package's public surface" by any reading. It publishes — the implementer measured that the TSDoc reaches 2 non-map dist files (.d.ts/.d.mts) against a control of a known.describe()string at 18 files, which is why a changeset is owed at all — but publishing a corrected comment is not widening a surface.The shape of the gap
Clause ② is declared once, for the PR. The carrier (
needs:contract-review) is a PR-level label; theClause-②:line is a PR-body line. The gate then applies the level rule to every package the diff touchessrc/**of. So a PR that legitimately widens package A and merely comments in package B must grade Bminortoo, or go red.That is not a tolerance question and ⛔ must not be fixed by adding one — the gate's own text forbids that, correctly. It is a scope mismatch: the declaration is PR-scoped, the act it licenses is package-scoped, and the predicate joins them at the wrong level.
⛔ What I am NOT proposing
⛔ No tolerance, no allowlist, no "skip if the diff is comment-only" — a comment-only heuristic is exactly the kind of predicate that goes quiet on the case it was built for, and this repo has three cards open this week about instruments that under-read (#16304, #16306, #16307).
Directions that keep the rule intact, ⛔ none chosen here:
Clause-②: yes (@objectstack/lint)spelling, or a per-package line, so the gate applies the level rule only to the named package(s). Costs a spelling change and a migration of existing declarations.dispatch-gates.mjssays a path cannot answer for clause ② itself — so it likely has to be declared, which collapses into direction 1.minoron a package that only received a comment is close to free here: the reviewer of fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID #16342 measured 120 minor / 125 patch pending and all 69 packages in onefixedgroup, so the released bump is set by the highest pending changeset anyway. If that is the answer, the gate's message should say so, because today it asserts a premise ("a purely additive widening") that is false for the PR it is refusing, and a reader who checks the premise finds it does not hold.⭐ Direction 3's cost is not the version number — it is that a false premise in a refusal message trains readers to stop checking premises. That is the same failure class as a stale docblock, one layer up.
What both PRs are doing meanwhile
Complying. #16342 raises both packages to
minor; #16347 raises@objectstack/spectominorand records in its body that the bump is gate-required, not semantically earned, and that the widened package islint. ⛔ Neither weakens its clause-② declaration to fit the level (the gate's direction 2), because in both cases the declaration is right.The gate reads clause-② from the carrier label OR a
Clause-②:line in the PR body. When an at-tier review passes and the review seat correctly stripsneeds:contract-reviewfrom the PR, the gate loses its only signal on any PR whose body has no declaration line — and a subsequent re-run comes back green because it has nothing to judge, not because the level was fixed. I did exactly this to #16342 at 15:11Z in the ordinary course of carrier discipline, and the re-run it triggered is not trustworthy.⇒ Whatever happens to the scope question, a PR-body
Clause-②:line should probably be required rather than optional for any PR the carrier was ever hung on, so the declaration outlives the carrier. Both PRs above are adding one. Whether that belongs on this card or its own is triage's call.Generated by Claude Code