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
Conversation
…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
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published as a package moves; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
维护者速读(终稿)— PR #18328 / #18221改了什么:两份对外发布的 skill( 验证:两文件内假句族全部归零( 要你做的:审 PR #18328,同意即 APPROVE;之后由本席按裁定 C 落地。 顺带发现(已立裸卡 #18331,不阻塞、非本车道): Generated by Claude Code |
Fixes #18221
Two PUBLISHED skills (
skills/**ships verbatim to third parties bynpx skills add objectstack-ai/objectstack/skillsandnpm create objectstack) still taught the retired batch model for thebefore*phase of a predicate (multi: true) write — thatbeforeUpdate/beforeDeletefire ONCE for the batch, thatpreviousis unbound there, thatrecordis the bare payload, and that readingpreviouson 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 thecontent/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.idnames the row,previousis that row's pre-image」; :3427 D3 — the payload is BATCH-scoped, rewrites accumulate across the N dispatches; :3473 D4.hook.zod.tssays per-rowpreviouson 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.tsBULK_WRITE_HOOK_DISPATCH_CONTRACT: all four entriesdelivered: true.packages/objectql/src/hook-wrappers.ts:136 / :968:HookConditionLimitationand both members RETIRED (ADR-0049) — no producer; the retirement pin ishook-condition-bulk-previous.test.ts:162.hook-wrappers.tspickRecordPayload(:1113): wheneverctx.previousis present the condition'srecordis stored ⊕ payload, made total over declared fields — and the per-rowbefore*context bindsprevious(engine :3535), so the old row's second sentence ("recordis 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):before*row of thepreviousbinding 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;after*event" (was :320) — now: on abefore*or anafter*event;before*row … put transition conditions onafter*" (was :332–:335) — now:before*is no exception to the condition; the asymmetry is what abefore*handler WRITES (D3 + the key-set rule + the refusal, Amendment II.3);OLD.x/ISCHANGED(x)callout (was :367–:370) — now: unbound on insert only; bound per matched row inbefore*andafter*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,previousis that row's pre-image (the same sentence PR #18220 corrected indata-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
previouson an after-type event — never on abefore*hook that can fire on amulti: truewrite" (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-lintdoes not scanskills/**, 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 nameMULTI_UPDATE_HOOK_KEY_DIVERGENCE.Reverse verification (tree
82b3032cbefore,96718735after)BEFORE (
origin/main82b3032c):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 patterngrep -c -i:whole batch0/0 ·once for the0/0 ·shared payload0/0 ·rejected by name0/0 ·after-type event0/0 ·HookConditionLimitation0/0 ·one batch carries one payload0/0 ·instead dispatch0/0 ·one exception0/0. Firing controlzzNOPEzz→ 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 :306after*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
cmpagainstorigin/main→ identical (md5251c8898cd3c); data-hooksafter*bullet (origin :249–:256 = now :256–:263) md583da3d93f544…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 name1 hit (skills/objectstack-query/rules/aggregation.md:50, a different subject with a live producer:rejectUnknownEngineOptions(… 'aggregate' …)at engine.ts :14528 — not edited);unbound2 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/objectqlvitest on a temp file, deleted afterwards, tree clean): on amulti: trueupdate of two rows with abeforeUpdateconditionrecord.status == 'done' && record.owner == 'ann'where the payload sets onlystatus, the hook fired exactly once,ctx.input.idbound,ctx.previous.owner == 'ann',ctx.previous.status == 'todo'—record.ownerresolved from the STORED row on the per-rowbefore*dispatch; andprevious.done != true && record.done == trueonbeforeUpdatefired 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, includingcheck: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-expressionsfirst read exit 3 (PREREQUISITE NOT MET:packages/lint/distabsent — the formula build alone is not enough); afterpnpm --filter '@objectstack/lint...' buildit measured, exit 0. Path face:check-governed-merges.mjs --test→ exit 3, GOVERNED (skills/**), both files. Tier:dispatch-gates.mjs --tierreads MANDATORYCONTRACT_REVIEW_TIERfor both paths (clause ①, 2026-09-10 ruling) — built at that tier. Derivation note:origin/mainmoved toa46cd8c4during the run (one commit,scripts/pm/check-half-states.mjsonly, noskills/**population in that diff); the derivation was taken at the merge-base82b3032c.Ratchets:
pnpm check:pm-skill-ratchetexit 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 pricesskills/**in tokens) exit 0: formula5227 → 5367of ceiling 6002 (headroom 635), data-hooks9781 → 9884of 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 packagefiles[]namesskills(0 of the manifests; positive control: 70 namedist), andcreate-objectstackinstalls the catalog at scaffold time throughnpx skills add, so nothing versioned moves.Acceptance notes
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 — thepickRecordPayload/pickPreviousPayloaddocblocks still state the retired model in the present tense (「Itsbefore*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-rowpreviousrule — 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 andbulk-write-per-row-hooks.test.ts:570);hookRecordStateis exported, so the first docblock may ship in the.d.tsthe way objectql's engine docblock still states the RETIRED per-rowpreviousrule — 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 apackages/objectqllane.skills/objectstack-data/references/data-hooks.mddocumentsctx.inputas a flat payload (ctx.input.email,ctx.input.owner_id) for handler code, while the engine'sHookContext.inputon update is{ id, data, options }; not measured which handler seam flattens it — outside this card's family.维护者速读(草稿)
objectstack-formula、objectstack-data/references/data-hooks.md)里关于「multi: true批量写的before*钩子整批触发一次、previous不可用、读了会被点名拒绝」的 8 处表述,改成今天的契约:两个阶段都逐行分发,previous是该行的前像,record是「库中行 ⊕ 本次写入」;只有 payload 仍是整批一份,逐行决定的改写必须每行写同一组键并原地赋值,键集不一致时引擎在写入前整批拒绝(MULTI_UPDATE_HOOK_KEY_DIVERGENCE)。engine.tsD1/D2、ADR-0058 Addendum II 与hook.zod.tssays per-rowpreviouson 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: correctbefore*bulk dispatch to the per-row model on two customer pages #18220 拉回,skills 还停在旧模型。skills/**,规则层),需要你的授权批准后由 owning seat 落地;本 PR 保持 draft,不请求 reviewer、不挂 auto-merge。Generated by Claude Code