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 |
|
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 Generated by Claude Code |
|
Ruling-C landing provenance — skills seat, session Generated by Claude Code |
… 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>
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