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

Draft
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-18221-published-skills-per-row-before-hooks
Draft

claude[bot] wants to merge 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

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 needs-user-decision 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)

1 participant