Skip to content

docs: correct before* bulk dispatch to the per-row model on two customer pages - #18220

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-17976-before-hooks-per-row
Sep 14, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-17976-before-hooks-per-row

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #17976

Clause-②: no
Prose-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's id and previous — the premise the #16074 ruling was written on and PR #17249 landed into the contract.

Verified on origin/main before rewriting, not taken from the card:

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:

Site Was Now
formulas.mdx scope table previous "unbound in before* hooks, which fire once for the batch" bound per row in both phases
formulas.mdx "Write it once" framed per-row dispatch as an after* property only both phases
formulas.mdx ceiling paragraph 10 000-row refusal attributed to objects "with after* hooks" one ceiling covers both phases, checked before the first dispatch (D6)
formulas.mdx warn Callout beforeUpdate/beforeDelete "fire once for the whole batch", previous unbound, and a previous-reading before* condition "fails the write with an error naming the batch" replaced — see below
data-flow.mdx hook-order prose "The after* events instead dispatch once per matched row" both phases, with the surviving payload asymmetry named

The Callout was the worst of the five: besides the dispatch claim it advertised a diagnostic that no longer has a producer. HookConditionLimitation was retired at #5574 (packages/objectql/src/index.ts: "was exported here until #5574 and is RETIRED"; hook-wrappers.ts: "its two HookConditionLimitation members 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's MULTI_UPDATE_HOOK_KEY_DIVERGENCE refusal — 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:

Sweep formulas.mdx data-flow.mdx
grep -ni "batch" 4 hits → 3 in family, 1 benign (read once for the whole batch, a true statement about the row read) 2 hits → 1 in family, 1 benign (Query Optimizer … batch strategies)
grep -nEi "unbound|not bound|once per|per matched row|before\*|beforeUpdate|beforeDelete|after\*" 9 hits → 5 in family 4 hits → 1 in family
grep -nEi "previous" covered by the above 0 hits outside line 311 — firing control: the same pattern returns 4 hits in skills/ and 1 in content/docs/releases/, so the zero is a measurement, not a dead pattern

Verification

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 40 commands from the committed change set. All 40 run, 0 NOT-MEASURED, 0 UNRUN (--ran reconciliation). Four first refused with an unmet prerequisite (three exit 3, one exit 1 whose text is a prerequisite refusal: client-react not built) — prerequisites built, then each re-run whole, not patched around:

0  pnpm --filter @objectstack/lint run check:doc-formula-expressions
0  pnpm --filter @objectstack/lint run check:doc-security-posture
0  pnpm --filter @objectstack/spec run check:skill-examples
0  pnpm check:docs-transcript-drift

Representative verdict lines:

✓ check-docs-transcript-drift: 4 declared transcript value(s) across 402 page(s) under
  content/docs/ equal what the registry derives today, and no undeclared block quotes one.
✅ 258 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc
  ran the SEMANTIC pass on all of them
✓ check:doc-formula-expressions: 22 record-scoped formula example(s) across 438 files /
  1377 TS blocks judged clean by @objectstack/formula.

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 is private: true. Positive control on the same matcher: packages/spec/dist/index.js resolves to @objectstack/spec via dist, and packages/spec/README.md via README.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:

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 two content/docs pages, 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/objectql source (engine.ts docblock, 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.mdx line 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: None on its own control. Re-checked here: GET /search/issues is 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 origin shows no other branch on either page.


🤖 Generated with Claude Code

https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk


Generated by Claude Code

…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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 14, 2026
@claude
claude Bot marked this pull request as ready for review September 14, 2026 18:01
@claude
claude Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit e88289b Sep 14, 2026
38 checks passed
@claude
claude Bot deleted the claude/issue-17976-before-hooks-per-row branch September 14, 2026 18:14
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>
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.

docs: two pages still say before* hooks fire once for the batch — false since #5574, and it is the axis that decides how a hook must be written

1 participant