Skip to content

docs(skills): before* hooks on a predicate write dispatch per row — the two published skills stop teaching a batch dispatch and a retired guard - #18328

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-18221-published-skills-per-row-before-hooks
Sep 16, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-18221-published-skills-per-row-before-hooks

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #18221

Two PUBLISHED skills (skills/** ships verbatim to third parties by npx skills add objectstack-ai/objectstack/skills and npm create objectstack) still taught the retired batch model for the before* phase of a predicate (multi: true) write — that beforeUpdate / beforeDelete fire ONCE for the batch, that previous is unbound there, that record is the bare payload, and that reading previous on that dispatch is "rejected by name" (a guard retired under ADR-0049 with no producer). This PR pulls both files back to the contract: per-row dispatch in both phases. PR #18220 is the content/docs/** precedent; its corrected wording is mirrored here.

What the contract says (read, not recalled)

  • packages/objectql/src/engine.ts :3414 (dispatchPerRowBeforeHooks, :3480): 「D1/D2 — one dispatch per matched row, on the SINGLE-RECORD shape — input.id names the row, previous is that row's pre-image」; :3427 D3 — the payload is BATCH-scoped, rewrites accumulate across the N dispatches; :3473 D4.
  • ADR-0058 Addendum II Part B (D1–D7) and Amendment II.3 (ruling hook.zod.ts says per-row previous on a predicate write is for a guard to REFUSE with, not for a rewrite to aim by — three shipped provenance stamps aim by it, kept safe only by the engine's divergence refusal #16074): a row-invariant-in-effect, in-place rewrite is admitted; key-set divergence is refused whole before any write (MULTI_UPDATE_HOOK_KEY_DIVERGENCE, status 400). D6: one ceiling (MAX_BULK_PER_ROW_HOOK_ROWS, 10 000) for both phases, checked before the first dispatch — engine call sites :12158 (update) and :14231 (delete).
  • packages/spec/src/data/bulk-write-hook-conformance.ts BULK_WRITE_HOOK_DISPATCH_CONTRACT: all four entries delivered: true.
  • packages/objectql/src/hook-wrappers.ts :136 / :968: HookConditionLimitation and both members RETIRED (ADR-0049) — no producer; the retirement pin is hook-condition-bulk-previous.test.ts :162.
  • hook-wrappers.ts pickRecordPayload (:1113): whenever ctx.previous is present the condition's record is stored ⊕ payload, made total over declared fields — and the per-row before* context binds previous (engine :3535), so the old row's second sentence ("record is the bare payload here too") was false on the per-row shape as well.

Sites corrected — 8 for the card's 3 claims (the card named 2 lines; the sweep found the family)

skills/objectstack-formula/SKILL.md (5):

  1. the before* row of the previous binding table (was :308) — now: that row's pre-write row; per-row dispatch; record = stored ⊕ payload; only the payload stays batch-scoped, pointer to the prose below the table;
  2. the lead-in "Write it once, on an after* event" (was :320) — now: on a before* or an after* event;
  3. "The one exception is the before* row … put transition conditions on after*" (was :332–:335) — now: before* is no exception to the condition; the asymmetry is what a before* handler WRITES (D3 + the key-set rule + the refusal, Amendment II.3);
  4. the ceiling sentence "on an object with after-hooks" (was :337) — now: per-row hooks in either phase, one ceiling, checked before the first dispatch (D6);
  5. the OLD.x / ISCHANGED(x) callout (was :367–:370) — now: unbound on insert only; bound per matched row in before* and after* alike.

skills/objectstack-data/references/data-hooks.md (3):
6. the events callout "The after* events instead dispatch once per matched row" (was :47–:49) — now: both phases dispatch once per matched row, previous is that row's pre-image (the same sentence PR #18220 corrected in data-flow.mdx);
7. the "Two cases … fires once for the whole batch … rejected by name … after-type event" bullet (was :240–:248) — now: one case (insert); the before* dispatch of a predicate write is not a second case; D1/D2, record = stored ⊕ payload, the payload rule and the divergence refusal;
8. "put a condition that reads previous on an after-type event — never on a before* hook that can fire on a multi: true write" (was :275–:280) — now: on an update or delete event, either phase; never on an insert event.

Citation convention followed: the catalog cites ADRs parenthetically (existing (ADR-0058, bulk-write addendum), (ADR-0068), (ADR-0032)) and names exported identifiers; it carries no issue numbers and no repo file paths in body text (check:pm-skill-id-lint does not scan skills/**, so this is convention, not a gate). Sources are written as (ADR-0058 Addendum II, D1/D2), (D3), (ADR-0058 Addendum II, D6), ADR-0058 Amendment II.3, plus the live code name MULTI_UPDATE_HOOK_KEY_DIVERGENCE.

Reverse verification (tree 82b3032c before, 96718735 after)

BEFORE (origin/main 82b3032c): grep -c -i 'whole batch' → 1 / 1 (formula :308, data-hooks :244); grep -n -i rejected data-hooks.md → :247 (this passage) and :449 (objectstack build — unrelated, untouched); HookConditionLimitation → 0 / 0 (the promise is worded "rejected by name").

AFTER (96718735), inside the two files, each pattern grep -c -i: whole batch 0/0 · once for the 0/0 · shared payload 0/0 · rejected by name 0/0 · after-type event 0/0 · HookConditionLimitation 0/0 · one batch carries one payload 0/0 · instead dispatch 0/0 · one exception 0/0. Firing control zzNOPEzz → 0/0 (the pipeline reads zero for an absent token, so the zeros above are measurements). Controls that MUST remain: formula :305 insert row ("unbound — there is no prior state") present; formula :306 after* row ("fires after-hooks once PER MATCHED ROW") present; data-hooks :256 "after* hooks fire PER ROW" bullet present.

Control rows byte-for-byte: formula table rows :304–:307 cmp against origin/main → identical (md5 251c8898cd3c); data-hooks after* bullet (origin :249–:256 = now :256–:263) md5 83da3d93f544… on both sides. git diff --numstat: 22/15 formula, 21/15 data-hooks; 7 hunks, all at the sites listed above.

Whole skills/** sweep after the edit — outside the two files: rejected by name 1 hit (skills/objectstack-query/rules/aggregation.md:50, a different subject with a live producer: rejectUnknownEngineOptions(… 'aggregate' …) at engine.ts :14528 — not edited); unbound 2 substring hits are the word "unbounded" (objectstack-ai/SKILL.md:303, objectstack-query/rules/pagination.md:176); every other pattern 0.

One-off probe (committed fix, packages/objectql vitest on a temp file, deleted afterwards, tree clean): on a multi: true update of two rows with a beforeUpdate condition record.status == 'done' && record.owner == 'ann' where the payload sets only status, the hook fired exactly once, ctx.input.id bound, ctx.previous.owner == 'ann', ctx.previous.status == 'todo'record.owner resolved from the STORED row on the per-row before* dispatch; and previous.done != true && record.done == true on beforeUpdate fired only for the row that transitioned. Test Files 1 passed · Tests 2 passed.

Gates (all on 96718735, captured by redirect before any pipe)

node scripts/pm/dispatch-gates.mjs --commands (with the two paths, and again with no paths from the merge-base — identical lists) → 25 commands; --ran: 「Run reconciliation — 25 derived, 25 run, 0 NOT-MEASURED, 0 UNRUN」. Every one exit 0, including check:skills-token-ratchet (+ --self-test), check:skill-identifier-liveness, check:corpus-claim-drift, check:skill-frame-sync, check:skill-compatibility, check:doc-authoring, check:role-word, check:nul-bytes, check:skill-docs, check:skill-refs, check:pm-governed-merges. pnpm --filter @objectstack/lint run check:doc-formula-expressions first read exit 3 (PREREQUISITE NOT MET: packages/lint/dist absent — the formula build alone is not enough); after pnpm --filter '@objectstack/lint...' build it measured, exit 0. Path face: check-governed-merges.mjs --test → exit 3, GOVERNED (skills/**), both files. Tier: dispatch-gates.mjs --tier reads MANDATORY CONTRACT_REVIEW_TIER for both paths (clause ①, 2026-09-10 ruling) — built at that tier. Derivation note: origin/main moved to a46cd8c4 during the run (one commit, scripts/pm/check-half-states.mjs only, no skills/** population in that diff); the derivation was taken at the merge-base 82b3032c.

Ratchets: pnpm check:pm-skill-ratchet exit 0 — the published catalog carries no ceiling in that map (its self-test pins 「the published skills/ catalog is deliberately uncovered」). node scripts/check-skills-token-ratchet.mjs (the sibling that prices skills/** in tokens) exit 0: formula 5227 → 5367 of ceiling 6002 (headroom 635), data-hooks 9781 → 9884 of 12611 (headroom 2727). No rule line was deleted to pay for the new sentences; the growth is the contract text the card's acceptance asks for (sources written in), not a feature expansion.

Changeset: skip-changeset — no package files[] names skills (0 of the manifests; positive control: 70 name dist), and create-objectstack installs the catalog at scaffold time through npx skills add, so nothing versioned moves.

Acceptance notes

  • To file (class b, seat files it; dedupe words: hook-wrappers pickPreviousPayload before dispatch once, pickRecordPayload before* fires once for the batch, objectql docblock retired batch model): packages/objectql/src/hook-wrappers.ts :1083–:1088 and :1177–:1182 — the pickRecordPayload / pickPreviousPayload docblocks still state the retired model in the present tense (「Its before* dispatch still fires once for the batch with no prior row」, 「it fires ONCE for N matched rows, so there is no single prior record to bind」) while the code below them binds per row. Not the sites objectql's engine docblock still states the RETIRED per-row previous rule — and it ships in the published .d.ts, so the next spec release makes two packages state opposite contracts #17975 names (that card carries engine.ts :3448–:3451 and bulk-write-per-row-hooks.test.ts :570); hookRecordState is exported, so the first docblock may ship in the .d.ts the way objectql's engine docblock still states the RETIRED per-row previous rule — and it ships in the published .d.ts, so the next spec release makes two packages state opposite contracts #17975 measured. Not edited here: outside the file surface and a packages/objectql lane.
  • noted, not filed (承接者:无): skills/objectstack-data/references/data-hooks.md documents ctx.input as a flat payload (ctx.input.email, ctx.input.owner_id) for handler code, while the engine's HookContext.input on update is { id, data, options }; not measured which handler seam flattens it — outside this card's family.
  • Design choice met (four axes, one line each): table cell vs. contract pointer — the cell carries only the binding fact and a pointer, the payload rule lives once in the prose below the table and once in the hooks reference (each skill installs standalone, so each must be self-sufficient). 实际业务需求: an AI author writing a guard or an audit hook reads exactly these two files and no ADR — the rule has to be in the text, not behind a link. 项目长远合理性: the text now states the contract's own clause names (D1/D2/D3/D6, Amendment II.3), so the next contract move has a named anchor to update. 防 AI 犯错: the false text promised a rejection that does not exist and steered transition guards away from the phase where they refuse fail-open writes; the true text names the one refusal the engine really makes. 创业阶段不扩散: no new capability described, no staged wording, +243 tokens total against the two ceilings.

维护者速读(草稿)

  • 改了什么:两份对外发布的 skills(objectstack-formulaobjectstack-data/references/data-hooks.md)里关于「multi: true 批量写的 before* 钩子整批触发一次、previous 不可用、读了会被点名拒绝」的 8 处表述,改成今天的契约:两个阶段都逐行分发,previous 是该行的前像,record 是「库中行 ⊕ 本次写入」;只有 payload 仍是整批一份,逐行决定的改写必须每行写同一组键并原地赋值,键集不一致时引擎在写入前整批拒绝(MULTI_UPDATE_HOOK_KEY_DIVERGENCE)。
  • 为什么改:这是已发布面,AI 编写代理按它写钩子会写出批量循环、漏掉逐行守卫,并指望一个已退休(ADR-0049,无生产者)的拒绝来兜底;契约在 engine.ts D1/D2、ADR-0058 Addendum II 与 hook.zod.ts says per-row previous on a predicate write is for a guard to REFUSE with, not for a rewrite to aim by — three shipped provenance stamps aim by it, kept safe only by the engine's divergence refusal #16074 裁决里,content/docs/** 已由 PR docs: correct before* bulk dispatch to the per-row model on two customer pages #18220 拉回,skills 还停在旧模型。
  • 风险与代价(含回滚):纯文档,不改代码、不改 schema;token 棘轮内(formula +140、data-hooks +103);回滚 = revert 本 PR 一个 commit。唯一的判断点是「表格单元格里教多少」——本 PR 选单元格只放绑定事实,规则放正文一次。
  • 席位意见:(留空,席位定稿成评论)
  • 你要做的:受管面(skills/**,规则层),需要你的授权批准后由 owning seat 落地;本 PR 保持 draft,不请求 reviewer、不挂 auto-merge。

Generated by Claude Code

…he two published skills stop teaching a batch dispatch and a retired guard

Two published skills still taught the retired batch model for the `before*`
phase of a predicate (`multi: true`) write: that `beforeUpdate` /
`beforeDelete` fire ONCE for the batch, that `previous` is unbound there, that
`record` is the bare payload, and that reading `previous` on that dispatch is
"rejected by name" (a guard retired under ADR-0049 with no producer).

The contract is per-row dispatch in both phases (ADR-0058 Addendum II, D1/D2;
`packages/objectql/src/engine.ts` `dispatchPerRowBeforeHooks`): `input.id`
names the row, `previous` is that row's pre-image, `record` is stored ⊕
payload. What stays batch-scoped is the payload (D3), with key-set divergence
refused whole before any write (`MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, ADR-0058
Amendment II.3), and one per-row ceiling for both phases (D6).

Eight sites corrected — five in `skills/objectstack-formula/SKILL.md` (the
`before*` row of the `previous` binding table, the "write it once on after*"
lead-in, the "one exception is the before* row" paragraph, the ceiling
sentence, the OLD.x / ISCHANGED callout) and three in
`skills/objectstack-data/references/data-hooks.md` (the "after* events instead
dispatch per row" callout, the "two cases … whole batch … rejected by name"
bullet, the "put previous on an after-type event" consequence). The true rows
(`after*` per row, insert unbound) are byte-identical to origin/main.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 967187354d51a9caa514d2d2d27ab75711790d64

Reviewing seat: domain:skills, in-seat review at the contract-review tier — get_session read at 2026-09-15T07:15Z: configured_model = session_context.model = last_served_model, the constant's tier, no fallback notice this round. Card #18221, claim 5685168650, report 5685846136 (posted through the tool; read back clean); Clause-②: no on the claim; the dev built at the review tier by the skills/** path mandate (clause ①, no one-line exemption); seat review at tier.

① Derived judgments

  • Accept set / public surface: none moves in code — the two PUBLISHED skill files skills/objectstack-formula/SKILL.md (+22/−15) and skills/objectstack-data/references/data-hooks.md (+21/−15) against merge base 82b3032c (the branch does not carry a46cd8c4, which touches neither file); no package files[] ships skills/** (0 manifests name it), the catalog installs at scaffold time by npx skills addskip-changeset read back (documentation + size/s + skip-changeset). GOVERNED (skills/** ×2 by check-governed-merges --test, exit 3) ⇒ the four-piece hangs here and the PR waits for an authorized APPROVED; ruling C after it.
  • The change, read whole on a detached worktree of the head: eight sites corrected to the contract's per-row model (the card named two lines and one promise; the family sweep inside the two files found five more neighbours — PR docs: correct before* bulk dispatch to the per-row model on two customer pages #18220's 2 → 5 is the precedent, and the claim's surface admitted 「any same-family sentence in these two files」): the previous binding table's before* row now binds 「that row's pre-write row」 (ADR-0058 Addendum II, D1 / D2) with record = stored ⊕ payload; the 「one exception」 paragraph becomes the payload rule — only the payload stays batch-scoped (D3), a per-row rewrite must assign the same key set in place, key-set divergence is refused before any write (MULTI_UPDATE_HOOK_KEY_DIVERGENCE, status 400; Amendment II.3); the 10 000-row ceiling now covers both phases (D6); the OLD.x / ISCHANGED callout, the events callout and the 「after-type event」 consequence all say update / delete in either phase, never insert. The four true table rows (:304–:307) are byte-identical to origin/main (md5 251c8898cd3c on both sides, the seat's own reading) and the after* bullet is byte-identical. Family greps by the seat, origin/main → head, inside the two files: 「whole batch」 1/1 → 0/0, 「once for the」 1/0 → 0/0, 「shared payload」 1/1 → 0/0, 「after-type event」 0/1 → 0/0, 「one batch carries one payload」 0/1 → 0/0, HookConditionLimitation 0/0 → 0/0, control zzNOPEzz 0/0; the true rows still present (insert 「there is no prior state」 1, after* 「PER MATCHED ROW」 2, data-hooks 「fire PER ROW」 1). Sources are written into the text in the catalog's own convention (ADR clause ids + the live error code; no issue numbers, no repo paths — the catalog's body text carries neither, which is why the card's literal 「engine.ts / hook.zod.ts says per-row previous on a predicate write is for a guard to REFUSE with, not for a rewrite to aim by — three shipped provenance stamps aim by it, kept safe only by the engine's divergence refusal #16074」 string is cited in the PR body and the commit instead).
  • Gates 25 / 25 / 0 / 0 by --ran on the head (check:doc-formula-expressions MEASURED at exit 0 after the @objectstack/lint closure was built — the seat could not re-run that one gate without the build and records the dev's measurement); the seat re-ran on the head: check-skills-token-ratchet 0 (formula 5227 → 5367 of 6002, data-hooks 9781 → 9884 of 12611; no rule line deleted), skill-frame-sync / skill-compatibility / skill-identifier-liveness / pm-skill-ratchet (the published catalog is deliberately uncovered, pinned by its self-test) / doc-authoring / role-word / doc-route-spelling --advisory all 0, --pair 18328 0. Reverse verification: the dev's one-off objectql probe (a temporary vitest file, deleted, porcelain clean) — on a multi: true update of two rows a beforeUpdate condition reading record.owner and previous.status fired exactly once with ctx.input.id bound and previous = that row's pre-image; the transition guard fired only for the row that transitioned — the per-row shape the text now teaches, measured on the engine.

② Semver level

Not applicable — nothing published as a package moves; skip-changeset measured. The published catalog's text changes shape for third parties (what it teaches about before*), which is the point of the card, not a version event.

③ Boundary flags

open_questions: none. No seat premise refuted; ten deviations answered: eight sites instead of three — accepted (within the claim); the lint closure build for the doc-formula gate — accepted; origin/main advanced by one unrelated commit, not merged — accepted; the citation spelling (ADR clause ids + error code in the skill text, the card's literal string in the PR body) — accepted, it IS the catalog's convention; a two-step edit to keep the family grep at zero — accepted; the commit trailer spelled Co-Authored-By (the dispatch's spelling; AGENTS.md spells Co-authored-by) — accepted, the pre-push check passed it, the seat's next dispatches use AGENTS.md's spelling; the one-off probe test deleted after the run — accepted; PR body stored minus its trailing newline — accepted; the Write tool's refusal of a scratch file named report.md — accepted; labeler-added labels — accepted. Out of scope: one card FILED by the seat, #18331 (packages/objectql/src/hook-wrappers.ts :1083–:1088 / :1177–:1182 docblocks still state the retired batch model in the present tense; bare, not this lane's, dedupe vs #17975 noted for triage); noted, not filed: data-hooks.md documents ctx.input as a flat payload while the engine's HookContext.input on update is { id, data, options } — which seam flattens it was not measured (承接者:无); skills/objectstack-query/rules/aggregation.md :50 「rejected by name」 is a coincidence of words, verified true. Landing: governed — the four-piece hangs here (this record, the 速读终稿 below, the ACCEPT on #18221, needs-user-decision, reviewers os-zhuang + hotlong requested); ⛔ this seat writes no approving review; ruling C after an authorized APPROVED — record on the current head, --pair 0, checks green, clear needs-user-decision, ready through the CCR route, auto-merge SQUASH.

Implemented-by: claude/issue-18221-published-skills-per-row-before-hooks
Reviewed-by: session_01HZfg2AwVX191qCizp88gQr

VERDICT: PASS


Generated by Claude Code

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)— PR #18328 / #18221

改了什么:两份对外发布的 skill(skills/objectstack-formula/SKILL.mdskills/objectstack-data/references/data-hooks.md)仍在教 #5574 退休掉的模型——「谓词(multi: true)写入的 before* 钩子整批触发一次,previous 不绑定」,并承诺一个已退休、无生产者的按名拒绝(HookConditionLimitation)。按契约(ADR-0058 Addendum II,D1/D2:逐行分发,previous 是该行前像;D3:只有 payload 是批量作用域,逐行改写须同键集且原地赋值,键集分歧在写入前整批拒绝 MULTI_UPDATE_HOOK_KEY_DIVERGENCE;D6:两个阶段共用一条 10 000 行上限)改正了 8 处(卡上点名 2 行 + 1 个承诺,同族扫描多找出 5 处邻居,与 PR #18220 的 2 → 5 同形)。四行正确的表行与 after* 段落逐字节未动。出处按目录自身惯例写进正文(ADR 条款号 + 实际错误码),不写 issue 号和仓库路径。

验证:两文件内假句族全部归零(whole batch / once for the / shared payload / after-type event / one batch carries one payload 各 0),对照行仍在;对照表行 md5 主干与 head 相同;25 条派生门禁全绿(doc-formula-expressions 需先构建 lint 闭包,dev 构建后实测 0);skills token ratchet 有余量(formula 5367/6002,data-hooks 9884/12611),没有为塞句子删任何规则行;--pair 0;dev 用一次性 vitest 探针在 objectql 引擎上实测:两行谓词更新下 beforeUpdate 条件逐行触发、previous 为该行前像、转换守卫只在真正转换的那一行触发。发布目录 skills/** 为复审档强制面,dev 在该档运行,本席在该档复核。

要你做的:审 PR #18328,同意即 APPROVE;之后由本席按裁定 C 落地。

顺带发现(已立裸卡 #18331,不阻塞、非本车道):packages/objectql/src/hook-wrappers.ts 两处 docblock 仍以现在时陈述退休模型,而其下代码逐行绑定;与 #17975 同族不同位置,由分诊决定合并或分开。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Maintainer reply on record (director seat, 2026-09-15T23:56Z): batch #138 presented this PR as a governed draft row — the two published skills' before* hook prose — with the skills seat's in-seat contract review PASS on the current head and 「一个动作:Approve」; the maintainer answered 「138 同意」. Review is already requested from hotlong and os-zhuang; the approval itself is the maintainer's click, after which the owning seat lands it under ruling C. ⛔ This seat does not approve, arm or merge a governed PR. Card: #18221.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 16, 2026 01:53
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 16, 2026

Copy link
Copy Markdown
Contributor

Ruling-C landing provenance — skills seat, session session_01HZfg2AwVX191qCizp88gQr, 2026-09-16T01:56Z. Governed rules layer (the published catalog skills/** ×2): the authorized APPROVED review is os-zhuang's 5217676382 at 2026-09-16T01:53Z on the current head 967187354d51a9caa514d2d2d27ab75711790d64 (not dismissed) — the record 5685957577 PASS is on that same head, check-clause2-carriers.mjs --pair 18328 exits 0, every check on the head reads green (25 success / 15 skipped / running none / failed none — one check re-ran on the ready flip and completed green before this record). The approver flipped the PR ready (2026-09-16T01:53Z) and the added_to_merge_queue event reads 2026-09-16T01:53Z by os-zhuang; the seat's strokes reduce to clearing needs-user-decision (removed through label-write.mjs; read back: documentation, size/s, skip-changeset) and this record; the queue ref pr-18328-* read 1 at the seat's look. ⛔ This seat wrote no approving review. Landing is confirmed by the three readings (merged_at + git log origin/main carrying (#18328) single-parent + the queue ref gone) at the next wake; then #18221's residue is stripped and the landing record posted.


Generated by Claude Code

Merged via the queue into main with commit fae612a Sep 16, 2026
44 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-18221-published-skills-per-row-before-hooks branch September 16, 2026 02:13
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… skip is not a failure, and a check reports the skips outside the declared roster (objectstack-ai#18357)

Fixes objectstack-ai#18308

## What

`.claude/skills/pm-dispatch/SKILL.md` :629 read 「入队资格 = PR 上每一个 check
全绿,⛔ 不是 required 子集;required 集是队列强制的地板。」 — a bar no landing on this
repository can satisfy, because `skipped` is the ordinary conclusion of
a path-filtered job and the charter defined no state for it. Measured
over the ten most recent landed heads (below), every head carried 8–19
skipped check-runs beside its successes; the seat's landings read
「success = green, skipped = not failed」 by an unstated convention.

Two changes, both inside the claimed file surface:

1. **SKILL.md :629, re-keyed in place** (112 B → 119 B, ≤120 B; 812 →
812 lines; no issue number):

> `- 入队资格 = 每个 check 为 success 或预期 skip(名单:check-expected-skips.mjs),⛔
不是 required 子集。`

The bar now names the two states a check may be in — `success`, or an
expected skip — and points at the one machine-readable roster of
expected skips. The third clause of the old line (「required 集是队列强制的地板」)
did not fit the byte ceiling and is carried by AGENTS.md §7 (「the queue
enforces only the required set」); the ⛔ clause is kept verbatim.

2. **`scripts/pm/check-expected-skips.mjs`** (new; `package.json` gains
`check:pm-expected-skips` = its `--self-test`): given `--pr N` or
`--head SHA` (or a pre-fetched payload via `--check-runs-json FILE|-`),
it reads the head's check-runs and judges every `skipped` run against a
roster declared once, in the file, as data with a one-line reason per
row. Exit register: **0** every skip is in the roster · **4** a skip is
outside it (each named and classified: a filter miss, or a dependency
skip when the same check suite holds a failed run; a raw `matrix`
template in the name is read as "skipped before matrix expansion, i.e. a
job-level gate — never a workflow-level `paths:` filter, which creates
no check-run at all") · **3** NOT MEASURED (unresolvable sha, 404,
network, no check-runs on the head, or a check-run still running — the
skip set is not final). Report-only; the self-test pins structurally
that the file carries no `method:` key and imports no writer.

The roster is **tied to the workflows, not remembered**: `--self-test`
parses each row's workflow with the `yaml` package and asserts the job
exists, carries the row's name, carries an `if:`, that the `if:` spells
the declared gate (`needs.filter.outputs.X != 'false'`, the
`github.event.action` exclusion, or the label literal), and — for ci.yml
rows — that the `filter` job's output keeps its `|| 'true'` widening,
which is what makes "the merge-queue build runs it" true. The audit is
driven red in the self-test on a deleted, renamed, un-gated and re-gated
job, a lost widening and an unreadable workflow.

## The roster (11 names), measured over ten landed heads

| name | workflow › job | mechanism | over the ten heads |
|---|---|---|---|
| `Build Core` | ci.yml › build-core | `filter` output `core` said
false; REQUIRED context, judged on the queue build | skipped 10/10 |
| `Temporal Conformance (live PG + MySQL)` | ci.yml ›
temporal-conformance | same, REQUIRED context | skipped 10/10 |
| `Dogfood Regression Gate (${{ matrix.shard }}/3)` | ci.yml › dogfood |
same; raw matrix template = pre-expansion name (the aggregate `Dogfood
Regression Gate` runs `if: always()`, never skips) | skipped 10/10 |
| `Dogfood Verify CLI` | ci.yml › dogfood-verify | same | skipped 10/10
|
| `Test Core (${{ matrix.shard }}/6)` | ci.yml › test | `core` OR
`crosspkg` both false (scripts/** is in `crosspkg`, so scripts/pm heads
RUN it) | skipped 3/10 — only the .md-only heads |
| `Build Docs` | ci.yml › build-docs | `filter` output `docs` | skipped
10/10 |
| `Console Pin Gate` | ci.yml › console-pin | `filter` output `console`
| skipped 10/10 |
| `Check PR Size` | pr-automation.yml › pr-size | `if:` excludes
`labeled` / `unlabeled` / `edited` events; each event is its own run on
the same head | skipped 9/10, success beside it 10/10 |
| `Auto Label` | pr-automation.yml › auto-label | same | skipped 9/10,
success beside it 10/10 |
| `Check Changeset` | pr-automation.yml › changeset-check | `if:` skips
a PR carrying `skip-changeset` | skipped 10/10 (every head carried the
label), success beside it 9/10 (the run before the label) |
| `Packed-tarball smoke (opt-in)` | pack-smoke-optin.yml › pack-smoke |
opt-in by `needs:pack-smoke` | skipped 10/10 |

Never skipped on any of the ten heads (and carrying no `if:`): `Lint &
Repo Gates`, the four `Type Check ·` lanes, `TypeScript Type Check`,
`Test Core` and `Dogfood Regression Gate` (the aggregates), `Governed
Surface Queue Guard`, `filter`, the four claim/keyword guards, `Check
Documentation Links`, `Close issues referenced in other repositories`.
Workflows with a workflow-level `paths:` filter
(`half-state-patrol.yml`, `board-snapshot.yml`) produce no check-run at
all on a non-matching head — they are absent on 6 of the ten heads,
never `skipped` — which is the measured basis for the "a skipped
check-run is never a `paths:` filter" reading.

## Reverse verification (all at `7a1f99ea`)

| leg | result |
|---|---|
| `--head` on the ten landed heads objectstack-ai#18298 · objectstack-ai#18307 · objectstack-ai#18311 · objectstack-ai#18315 ·
objectstack-ai#18316 · objectstack-ai#18322 · objectstack-ai#18326 · objectstack-ai#18327 · objectstack-ai#18328 · objectstack-ai#18332 | **exit 0 on every
one**; accepted skips per head: 11 · 12 · 11 · 19 · 12 · 8 · 11 · 18 ·
18 · 11, every name in the roster; e.g. objectstack-ai#18322 (the 8-skip head): `Build
Core`, `Build Docs`, `Check Changeset`, `Console Pin Gate`, `Dogfood
Regression Gate (…/3)`, `Dogfood Verify CLI`, `Packed-tarball smoke
(opt-in)`, `Temporal Conformance` |
| constructed fixture: the real objectstack-ai#18322 payload with `Lint & Repo Gates`
mutated to `skipped` | **exit 4**, naming `Lint & Repo Gates (check
suite 94780297729)` and classifying it `filter-miss` |
| garbage sha `--head deadbeef…deadbeef` | **exit 3** — `NOT MEASURED —
HTTP 422 — the API cannot resolve that sha` |
| `--pr 18315` (the head is looked up through the proxy) | exit 0, `19
skipped check-run(s), every one in the roster`; `--pr 18308` (an issue
number, not a PR) → exit 3 (HTTP 404) |
| `--self-test` | 99 cases pass, offline (the roster's truth on the live
workflows and its audit driven red six ways; the judge on the measured
39-run objectstack-ai#18315 head and on fixtures for 0 / 4 / 3; read classification;
argv; the real CLI on payload files incl. `--json`; the structural pins)
|
| SKILL.md ratchet | `wc -l` 812 → 812; :629 112 B → 119 B;
`check-skill-line-ratchet: SKILL.md is 812 lines (ceiling 812; headroom
0)` |

## Gates (local, at `7a1f99ea`)

`node scripts/pm/dispatch-gates.mjs --commands
.claude/skills/pm-dispatch/SKILL.md scripts/pm/check-expected-skips.mjs
package.json` derived 45 commands; all 45 were run with the exit
captured by redirect, and `--ran` reconciles: `✓ dispatch-gates --ran:
45 derived famil(ies) accounted for — 40 run, 5 NOT-MEASURED (5 DERIVED
from a recorded exit 3)`. The five NOT MEASURED are the `dist/`-reading
families on an unbuilt tree (`check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`, `@objectstack/lint
check:doc-formula-expressions` — each prints `PREREQUISITE NOT MET`);
this diff touches no package, so no build closure is owed locally and CI
runs them built. The `pnpm check:pm-dispatch-gates` battery was not
derived, so it was not run.

Named gates, verdict lines quoted: `check-skill-line-ratchet: SKILL.md
is 812 lines (ceiling 812; headroom 0)` · `check-skill-id-lint: 27
file(s) clean` · `check-skill-frame-sync: the one declared copy of the
decision frame is internally coherent` · `check-self-test-wired: every
one of the 212 script(s) CI runs that ship a --self-test has that
self-test run by CI` (the new script is not in that population — see
Acceptance notes) · `check-nul-bytes: OK (scanned 8707 text file(s))` ·
`check-governed-prose: 2 instruction surface(s) name all 5 registered
governed surfaces` · ESLint (`--no-inline-config`) on the new file: exit
0 · `check-governed-merges.mjs --test
.claude/skills/pm-dispatch/SKILL.md`: **GOVERNED** (`.claude/**` ×1),
exit 3 as designed. `check-clause2-carriers.mjs --pair` is run once this
PR exists and its reading goes in the report comment.

## The one design choice, on the four axes: a roster declared in the
check vs. deriving expectedness live from the workflows' `paths` filters

- **实际业务需求** — the measured need is name-level: 31 landings this shift
and the ten heads above were judged by "is this skipped name one that
always skips?", and zero of them needed a diff-level answer. The
diff-level question ("should `Build Core` have run on THIS diff?") is
already answered for the required family by the platform: on
`merge_group` ci.yml's `filter` widens every output to `'true'` (the `||
'true'` half of the filter contract, now pinned by this check's
self-test), so the family runs on the merged tree before `main` moves. A
live derivation would answer a question nobody measured a need for, at
the cost below.
- **项目长远合理性** — a roster is a declaration that can rot; a live
derivation is a second evaluator of the platform's own semantics
(dorny/paths-filter's picomatch dialect, GitHub's expression language,
matrix name templates, per-event runs) that can drift from the real
evaluator. Both are drift; the roster's drift is made LOUD here (every
row is pinned to its live job, name, `if:` and gate spelling — a rename
or re-gate reddens CI), while an evaluator's drift is silent by
construction (a wrong glob yields a confident "expected").
Contract-first: the workflow file is the contract, and the roster is a
checked reading of it, not a copy of its path lists.
- **防 AI 写代码犯错** — the roster makes the wrong move structurally hard: a
new gated job's first skip is exit 4 until someone adds a row WITH its
mechanism, and a row that names a job the tree does not gate is red. A
live evaluator is where an AI would quietly mis-implement glob semantics
and produce the false green this tree refuses everywhere else (the
"could not read" ≠ "clean" class). The declared-vs-delivered line is
kept: the check advertises the name question only, and says so in its
header and report.
- **创业阶段不扩散需求** — the roster is ~11 rows of data and one audit; live
derivation is a YAML-expression evaluator with parity tests against
GitHub. No pull exists for the latter; if a rostered required job is
ever found skipped on a diff inside its filter, that measurement is the
card that would justify it.

**Recommendation: the roster in the check (implemented).** Should the
seat prefer live derivation, nothing here blocks it — the roster rows
already carry `workflow`, `job` and the gate's outputs, which is the
input a derivation would start from.

## Acceptance notes

- **Self-test wiring.** `check:pm-expected-skips` exists in
`package.json` (mirroring the report-only siblings), but no workflow
names it and lint.yml was outside this card's file surface, so
`check-self-test-wired` (correctly) does not count it and CI does not
run its 99 cases. The completion is one lint.yml step beside the other
`check:pm-*` steps (`run: pnpm check:pm-expected-skips`); left to the
seat — 承接者:the skills seat, on this PR or a sibling. Noted, not filed.
- **:629's floor clause dropped for the byte ceiling** (「required
集是队列强制的地板」); AGENTS.md §7 carries the fact. Noted, not filed.
- **Exit 4 judges skips only.** Other conclusions on the head
(`failure`, `cancelled`, `neutral`, …) are printed loudly under `other
conclusions` and do not move this check's exit; the bar's success half
is read from the same listing. A malformed `--head` (non-hex) is a usage
error (exit 2), a well-formed sha the API cannot resolve is exit 3.
Noted, not filed.
- **The card's five-name family was a subset.** The measured recurring
family is eleven names (six ci.yml `filter`-gated jobs the card did not
list, including two REQUIRED contexts); the card's citation of a
"platform-readings discipline (a skip is not a pass)" has no verbatim
carrier — the nearest lines are AGENTS.md §7 (「Green means the
gate-carrying jobs' conclusion is success」) and
`references/review-checklist.md:43`. Recorded in the report, no card.
- objectstack-ai#18349 is not addressed here; it holds :513 / :523 of the same file
(region-level parallel). `origin/main` did not move under this branch
after cut (`ceb6b5fb`).

## 维护者速读(草稿)

**改了什么**:入队资格这一行改成「每个 check 为 success 或预期 skip」,并新增一个只读的检查脚本
`scripts/pm/check-expected-skips.mjs`:给它一个 PR 号或提交 SHA,它读出该提交上所有
check,凡是 `skipped` 的都对照脚本内声明的「预期 skip 名单」(11 个名字,每个带一句为什么会 skip
的机制),名单外的 skip 会被点名并退出码 4;读不到就退出码 3,绝不当作通过。

**为什么改**:原来的「每一个 check 全绿」在本仓库任何一个 PR 上都做不到——路径过滤的 job 本来就以 `skipped`
结束,实测最近十次落地每次都有 8–19 个 skip。席位一直靠「记得哪些通常会 skip」在判断,而真正要分辨的是「预期
skip」与「本该跑却没跑」。现在名单是机器可读的,并且自测会把名单逐条对照真实 workflow 文件校验(job
存在、名字一致、带条件、条件拼写一致),名单不会悄悄过期。

**风险与代价(含回滚)**:规则层只改一行(≤120 B、行数 812 不变);脚本只读不写、不接入任何门禁,CI
不因它变红。名单是名字层面的判断,不回答「这个 diff 是否本该触发某个
job」——必查项由合并队列在合并树上全量重跑兜底,这一点写在脚本头部。回滚 = revert 本 PR。

**席位意见**:(留空,席位定稿)

**你要做的**:本 PR 触及 `.claude/**`(规则层),需要你的 APPROVED;之后由席位落地。是否把该脚本的自测接进
lint.yml(一行 `pnpm check:pm-expected-skips`)由席位决定,本 PR 未动 lint.yml。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] 两份已发布 skills 仍教 before* 钩子整批触发,并推销一个没有生产者的诊断(HookConditionLimitation)

3 participants