docs: correct before* bulk dispatch to the per-row model on two customer pages - #18220
Merged
Merged
Conversation
…tomer pages Both pages still described the batch-dispatch model ADR-0058 Addendum II retired: `before*` hooks dispatch once per matched row and `previous` is bound per row, the premise the #16074 ruling was written on. Five sites, not the two quoted on the card — the neighbouring paragraphs carried the same retirement: - formulas.mdx scope table: `previous` was documented as unbound in `before*`. - formulas.mdx "Write it once": described the per-row model as an `after*` property only. - formulas.mdx ceiling paragraph: attributed the 10 000-row refusal to objects with `after*` hooks; one ceiling covers both phases and is checked before the first dispatch. - formulas.mdx warn Callout: stated `beforeUpdate`/`beforeDelete` fire once for the whole batch, that `previous` is unbound there, and that a `previous`-reading `before*` condition fails the write with a diagnostic naming the batch — that diagnosis is retired and has had no producer since the per-row dispatch landed. Replaced with the rule that is now load-bearing: the payload stays batch-scoped, so only a row-invariant-in-effect, in-place rewrite is in contract, backed by the engine's key-divergence refusal. - data-flow.mdx hook-order prose: "The `after*` events instead dispatch once per matched row" implied `before*` does not. Docs-only; `content/` is outside every package's `files[]`, so nothing published moves. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 17, 2026
…he two published skills stop teaching a batch dispatch and a retired guard (objectstack-ai#18328) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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-formula`、`objectstack-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 与 objectstack-ai#16074 裁决里,`content/docs/**` 已由 PR objectstack-ai#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](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17976
Clause-②: noProse-only realignment onto the already-declared contract: no accept/reject behaviour moves, no public surface widens, no schema or runtime code is touched.
What was wrong
Both pages still taught the batch-dispatch model that ADR-0058 Addendum II (#5574) retired.
before*hooks on a predicate (multi: true) write dispatch once per matched row, each on a single-record-shaped context carrying that row'sidandprevious— the premise the #16074 ruling was written on and PR #17249 landed into the contract.Verified on
origin/mainbefore rewriting, not taken from the card:packages/objectql/src/engine.ts—dispatchPerRowBeforeHooks, docblock clauses D1/D2: "one dispatch per matched row, on the SINGLE-RECORD shape …previousis that row's pre-image".packages/spec/src/data/hook.zod.ts— "Since beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574's engine half it fires them once PER MATCHED ROW, each on a single-record-shaped context carrying that row'sidandprevious".packages/objectql/src/bulk-write-per-row-hooks.test.ts—[#5574 / D1] a bulk write fires before-hooks once per matched row,[#5574 / D2] … binds THAT row's previous.docs/adr/0058-expression-and-predicate-surface.md— Amendment II.3 (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), the row-invariant-in-effect rule.Five sites, not the two on the card
The card's repair asked for the neighbouring paragraphs. The sweep found three more, all in the same retirement:
formulas.mdxscope tableprevious"unbound inbefore*hooks, which fire once for the batch"formulas.mdx"Write it once"after*property onlyformulas.mdxceiling paragraphafter*hooks"formulas.mdxwarn CalloutbeforeUpdate/beforeDelete"fire once for the whole batch",previousunbound, and aprevious-readingbefore*condition "fails the write with an error naming the batch"data-flow.mdxhook-order proseafter*events instead dispatch once per matched row"The Callout was the worst of the five: besides the dispatch claim it advertised a diagnostic that no longer has a producer.
HookConditionLimitationwas retired at #5574 (packages/objectql/src/index.ts: "was exported here until #5574 and is RETIRED";hook-wrappers.ts: "its twoHookConditionLimitationmembers had no producer — retired under ADR-0049"). An author was being promised a guard-rail that was removed. It now carries the rule that actually binds today: the payload stays batch-scoped, so only a row-invariant-in-effect, in-place rewrite is in contract, backed by the engine'sMULTI_UPDATE_HOOK_KEY_DIVERGENCErefusal — including the two shapes that slip past that guard and stay out of contract.Sweep method and hit counts
Ran over both pages in full, zero results given a firing control:
grep -ni "batch"read once for the whole batch, a true statement about the row read)Query Optimizer … batch strategies)grep -nEi "unbound|not bound|once per|per matched row|before\*|beforeUpdate|beforeDelete|after\*"grep -nEi "previous"skills/and 1 incontent/docs/releases/, so the zero is a measurement, not a dead patternVerification
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 40 commands from the committed change set. All 40 run, 0 NOT-MEASURED, 0 UNRUN (--ranreconciliation). Four first refused with an unmet prerequisite (threeexit 3, oneexit 1whose text is a prerequisite refusal:client-reactnot built) — prerequisites built, then each re-run whole, not patched around:Representative verdict lines:
No package is in the diff, so there is no dependency-closure build to owe and no package test suite is affected. Control-character self-scan over both files: clean.
Changeset: skip
Measured, not assumed. Across the 70 packages declaring a
files[], neither edited path falls inside any of them;content/sits at the repo root, outside every package directory, and the root manifest isprivate: true. Positive control on the same matcher:packages/spec/dist/index.jsresolves to@objectstack/specviadist, andpackages/spec/README.mdviaREADME.md. Nothing published moves.Acceptance notes
To file — the same retirement on a PUBLISHED, governed surface, outside this card's file face. Two customer-shipped skill files carry the identical false claim, and both additionally restate the retired rejection-by-name diagnostic:
skills/objectstack-formula/SKILL.md:308— thepreviousbinding table: "unbound — abefore*hook fires ONCE for the whole batch … so there is no single prior record".skills/objectstack-data/references/data-hooks.md:240-248— the same, plus "Readingpreviouson that dispatch is rejected by name, and the rejection points you at the after-type event", which has had no producer since beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574.These contradict the declared contract in
packages/spec/src/data/hook.zod.ts(class b). They are strictly worse than the two pages fixed here —skills/**ships to customers and is read by AI authoring agents — but the file face for this card is the twocontent/docspages, so they are reported rather than touched. Dedupe words:skills before hook batch dispatch,objectstack-formula previous unbound,data-hooks.md per-row before,HookConditionLimitation retired skill,skill previous bulk write.Noted, not filed — already carried. #17975 covers the sibling residue in
packages/objectqlsource (engine.tsdocblock,bulk-write-per-row-hooks.test.ts:570), a different retired rule (the "not so a rewrite can be aimed" sentence) on a different lane. #17975 remains open; carrier named, nothing to add here.Noted, not filed.
formulas.mdxline 388's "read once for the whole batch" is correct as written (it describes the D7 single row read, not hook dispatch) and was deliberately left alone; it is the benign hit the sweep above accounts for. Carrier: none needed — no defect.Dedupe
The card's "no existing card found" rested on a semantic search that returned
total: Noneon its own control. Re-checked here:GET /search/issuesis proxy-blocked in this environment (HTTP 403, "sessions are bound to their configured repositories"), so that endpoint can never have produced a proven zero. Re-ran via repository-scoped endpoints instead — all 522 open issues and 7 open PRs enumerated and title-matched against the family; the only hits were #17976 itself and #17975 (disposed above).git ls-remote --heads originshows no other branch on either page.🤖 Generated with Claude Code
https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Generated by Claude Code