feat(spec): HookContext admits a row-invariant-in-effect rewrite by per-row previous on a predicate write, kept safe by the key-divergence refusal - #17249
Conversation
…er-row previous on a predicate write Amend the D3 clause in hook.zod.ts (and its mirror in bulk-write-hook-conformance.ts, plus the ADR-0058 anchor's invariant text) so that per-row `previous` on a predicate write is supplied for a guard to REFUSE and for a `before*` hook to make a row-invariant-in-effect rewrite — one whose written key set is the same on every matched row — naming the engine's MULTI_UPDATE_HOOK_KEY_DIVERGENCE (400, `keys`, `rows`) refusal as the mechanism that makes the shape safe, and stating plainly what that refusal looks like to an operator. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 134 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e2b758bf3b54e1264b67911e05f0aed3baf8766e && git checkout e2b758bf3b54e1264b67911e05f0aed3baf8766e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7ddf13dca4a7df0c2595172ffaf8020d45ae23b5 541636ebbf32b80ff6511b1ff1219a65af9e5003 && git checkout -B drift-repro 7ddf13dca4a7df0c2595172ffaf8020d45ae23b5 && git merge --no-ff 541636ebbf32b80ff6511b1ff1219a65af9e5003
node scripts/docs-audit/affected-docs.mjs --json 7ddf13dca4a7df0c2595172ffaf8020d45ae23b5
|
Contract review at
|
|
Handoff provenance — director seat, summon #18 segment 6 ( Generated by Claude Code |
…at it Amendment by addition, in ADR-0058's own idiom: a new blockquoted block after Amendment II.2 recording the maintainer ruling (decision batch #59, 2026-09-06) that per-row `previous` on a predicate write may serve a row-invariant-in-effect rewrite, with MULTI_UPDATE_HOOK_KEY_DIVERGENCE (#14099) as the engine mechanism that makes it safe and the two shapes the rule does not admit. The superseded 2026-08 D3 sentence is left standing as the dated record and carries a forward pointer to the new block, so the AGENTS.md directive-13 grep lands on the pointer at the line that would otherwise read as the live rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…K F1/F2) F1 — the key-divergence refusal's message does not END with "Nothing was written". `buildMessage` (`packages/objectql/src/multi-update-hook-key- divergence.ts`) continues "Write those records individually, from inside the handler with 'ctx.api' or by id.", and the pin is `toContain(...)`. Say the message SAYS the phrase and then names the remedy, in `hook.zod.ts` and in the changeset that repeated it. F2 — the refusal was stated unconditionally. `dispatchPerRowBeforeHooks` only compares when `seal()` returned a key record, and `seal()` returns none when a hook REPLACED `ctx.input.data` instead of assigning into it. So the admitted shape is now qualified as an IN-PLACE assignment, the abstention is named where the refusal is claimed, and a row-conditioned REPLACEMENT is listed as a third shape the rule does not admit — it clears with no refusal at all. Mirrored in every carrier of the same clause that this PR authored: `bulk-write-hook-conformance.ts` (D3 docblock and its unenforced-residue note), ADR-0058 Amendment II.3, and the anchor JSON's `invariant` print text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Delta contract review at
|
…view F1-F4) Four prose corrections from the delta contract review's non-blocking findings. No behaviour change, no contract change, no new argument. F1 — ADR-0058 Amendment II.3's ruling paragraph cited the date and the decision batch but not the recording comment. It now names comment `5560086928`, the comment on this card that records the maintainer reply the block quotes. F2 — round 3's rewrap left a stub line (`matched). So an`) mid-paragraph. The paragraph is rewrapped to the block's own idiom; the prose is word-identical. F3 — the block said it amends D3's closing SENTENCE. It amends the bullet's last two: the "rewrite *conditioned* on the row is out of contract" sentence is superseded for the in-place / same-key-set case alongside the "not so a rewrite can be aimed" one. Now "closing sentences". F4 — the changeset attributed the ruling to the director seat. The MAINTAINER ruled; the director seat recorded it. This text ships to consumers inside the package's CHANGELOG.md, so the misattribution was published. Now "Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06)". Level re-derived rather than inherited: `packages/spec`'s files[] carries src/**/*.zod.ts and dist, while docs/adr/** is in no package's files[]. This round moves no published carrier and changes nothing behavioural, so the existing `@objectstack/spec: minor` stands. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
Contract review at
|
|
Clear-and-hold provenance — director seat, summon #18 segment 6 ( Generated by Claude Code |
Fixes #16074
Clause-②: yes
Maintainer ruling, recorded on the card by the director seat (comment 5560086928, decision batch #59, 2026-09-06; maintainer reply verbatim 「16063 c, 其他同意」): option 1 — the contract admits the shape.
hook.zod.tsis amended so that on a predicate (multi: true) write the per-rowpreviousmay be used by abefore*hook for a row-invariant-in-effect rewrite — one whose written key set is the same on every matched row AND is assigned IN PLACE — naming the engine'sMULTI_UPDATE_HOOK_KEY_DIVERGENCE(400) refusal as the mechanism that makes it safe. Option 2 (change the hooks) is not adopted and nothing underpackages/plugins/**is touched: the three shipped provenance stamps become conforming by amendment.维护者速读(草稿)
改了什么。 把 #16074 的裁决落成契约文字:
hook.zod.ts的 D3 条款改写一句,声明谓词写(multi: true)上的逐行previous除了给守卫用来拒绝之外,也可以支撑一次行不变式(row-invariant-in-effect)改写 —— 写入的键集在每一条命中行上相同;同一句话镜像到bulk-write-hook-conformance.ts。本轮(第二轮补丁)另加一件:把这条裁决同时记进 ADR-0058,新增Amendment II.3区块,并给 2026-08 的旧句子挂上前向指针(旧句子按该文件的惯例保留原文不改,只做加法)。第三轮补丁按 at-tier 契约评审的两条必改项修正措辞:一是引擎的拒绝消息并非以「Nothing was written」结尾(其后还有补救句),二是那条拒绝并非无条件——只有当 hook 就地赋值时引擎才有键记录可比,若 hook 整体替换
ctx.input.data,记录失效、整批不作判定;因此接纳的形态限定为「就地赋值」,并把「按行条件替换 payload」列为第三种契约不接纳的形态。为什么改。 维护者裁决(由总监席记录,评论
5560086928,决策批次 #59,2026-09-06),维护者回复原文:「16063 c, 其他同意」—— 即 option 1:契约接纳这个形态。三个已发布的 provenance stamp(sys_email_template/sys_sharing_rule/sys_webhook)因此成为合规,⛔ 不改它们的代码。option 2(改这三个 hook)未被采纳:#15302 实测到「在谓词写上拒绝盖章」恰好会漏掉下次启动就被覆盖的那些行,把一个看得见的 400 变成管理员编辑的静默丢失。风险与代价(含回滚)。 契约面的变化只落在一句话上 ——
hook.zod.ts中HookContextSchema.input的 D3 bullet:接受集放宽,把一个原先未被文档承认的形态声明为允许。引擎行为没有变:兜住这个形态的MULTI_UPDATE_HOOK_KEY_DIVERGENCE(#14099)早就在跑 —— 两行的 hook 链写出不同键集时,整批在任何写入之前被拒(ADR-0112 信封,status: 400,带keys与rows)。本 PR 不动 schema 键、类型、可选性,也不动引擎代码;没有数据迁移。回滚 = 直接 revert 本 PR,契约文字回到裁决前的措辞,运行时不受影响。受管面:本轮把docs/adr/**带进了 diff,按 #9495 规制一条命中即整 PR 受管(实测check-governed-merges --test全部 5 条路径 ⇒ exit 3)。因此终局是:PR 保持 draft,由维护者人工合并;⛔ 无席位翻 ready、⛔ 无入队、⛔ 无 auto-merge。席位意见: 两轮达档契约复核均已返回,可以合。
session_017Js5kTpTtxieBjPyScgxJ3,判 REWORK,两条必改都在已发布的.zod.ts面上 —— ① 拒绝消息并非以「Nothing was written」结尾;② 那条拒绝被写成无条件的,而引擎在 hook 替换ctx.input.data时弃权不判。两条都已闭,且第三轮是先对着引擎重测、再动笔。⭐ 一件请你在 diff 里特别看一眼的事,我不让它当成纯转录混过去。
Amendment II.3比裁决的字面多了一句限定:「且是就地赋值(assigned IN PLACE)」。复核判为蕴含,而非新决定 —— 理由是裁决自身的安全性主张(「由 key-divergence 拒绝兜底」)只在就地赋值下成立,三个被裁的 stamp 也确实就地赋值(email-template-provenance.ts:91、sharing-rule-provenance.ts:80、webhook-provenance.ts:82),而第一份复核的必改项要的正是这一句。席位采信这个判断;若你读作「这是一个新决定」,那它就该回决策箱,而不是随本 PR 落地。本 PR 上我自己的两处错误,一并交代:派发令写「6 个文件」实际是 5 个(dev 两种方法测出并报了假前提);第二轮令要求「changeset 逐字节不变」,第三轮令明示推翻了它 —— 因为 ① 那句假话也写在 changeset 里,级别已重新推导(
minor不变,docs/adr/**不在任何包的files[])。受管面提醒:人工合并即审核记录,但 ⛔ 不替代契约复核 —— 上面两份就是那一道,已在案。合并前请确认新 head 的 CI 已收敛(⛔ 按 check 名取最新一次,不看原始 run 列表)。
你要做的。 一件事:读过之后人工合并这个 PR。受管面的人工合并 ⛔ 不替代契约复核,两道保障叠加 —— 合并前仍欠一次
needs:contract-review的 PASS。The two inherited readings, re-verified first (execution note 1)
Both readings came from the director's review of PR #16035; neither was the filer's own. Re-measured on
origin/main@ae19f5edb(the branch base) before any edit:(a) The contract sentence, exact text —
packages/spec/src/data/hook.zod.tslines 553–560, the D3 bullet underHookContextSchema.input:The reading holds: the sentence is there, verbatim as the card quotes it.
(b) All three stamps condition
customized: trueonctx.previous— each file read in full, and each carries the identical branch:previousbound frompackages/plugins/plugin-email/src/email-template-provenance.tsconst previous = ctx?.previous …if ((previous.managed_by === 'package' || previous.managed_by === 'platform') && previous.customized !== true) { data.customized = true; }packages/plugins/plugin-sharing/src/sharing-rule-provenance.tspackages/plugins/plugin-webhooks/src/webhook-provenance.tsThe reading holds for all three, so the ruling's premise is exactly as stated — no narrower — and the amendment proceeds.
What changed
packages/spec/src/data/hook.zod.ts— the D3 bullet is amended. The amended sentence, verbatim (the operative rule; the full bullet also states the operator-facing shape below):Execution note 2 — what the refusal looks like to an operator — is stated in the same bullet: an ADR-0112 envelope with
status: 400,code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE',keys(the sorted keys some rows' hooks wrote and other rows' did not —['customized']for the stamps),rows(how many rows the predicate matched —2for a two-row batch),object, and a message that says "Nothing was written" and then names the remedy; that a bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that this is the engine working, not the hooks misbehaving; and that the remedy is the caller's (by-id writes, orctx.apifrom inside the handler). The bullet also names the three shapes the rule does NOT admit — a per-row key set (the refusal itself); the same key with a per-row VALUE, which the key-set test cannot see; and a row-conditioned REPLACEMENT ofctx.input.data, which the recording cannot attribute at all — so the rule cannot be read wider than the engine enforces.packages/spec/src/data/bulk-write-hook-conformance.ts— the same D3 clause, mirrored:hook.zod.tsnames this file as "the full clause set (D1–D7)", and its D3 bullet (lines 69–75) and the "residual hazard … a contract statement, not an enforcement" paragraph (lines 151–158) stated the pre-ruling rule; left as they were, the contract would contradict itself one file over. Both are brought to the ruled reading; the "same key, per-row values" residue is kept named as the half that is still unenforced. Same package, same contract, declared here as a same-round supplement to the claim's file surface.scripts/adr-anchors/packages__spec__src__data__bulk-write-hook-conformance.ts.json— the anchor'sinvarianttext (the sentence the gate prints when the ADR id is dropped from the file) copied the retired sentence; updated to the ruled one. Presence check unchanged;pnpm check:adr-anchorsexit 0.docs/adr/0058-expression-and-predicate-surface.md(patch round 2, seat ruling5610769499— option C) — the ADR that governs this contract is amended on the record, in the file's own idiom: amendment by addition. A new blockquoted block,Amendment II.3 (2026-09, #16074 maintainer ruling, decision batch #59), is inserted after the Amendment II.2 block and before the---preceding## TL;DR. It records the maintainer reply verbatim, states the row-invariant-in-effect rule, namesMULTI_UPDATE_HOOK_KEY_DIVERGENCE(#14099) as the engine mechanism and its operator-facing envelope (status: 400,keys,rows), names the three shapes the rule does NOT admit, records why option 2 was not adopted (#15302), and points at where the contract text lives. The superseded 2026-08 D3 sentence is ⛔ NOT rewritten — it stands as the dated record and gains a forward pointer on its own line,**→ Amended in Amendment II.3 below.**, exactly as Amendment II.1 and II.2 were pointed at, so the AGENTS.md directive-13 grep lands on the pointer at the line that would otherwise read as the live rule. The**Status**line is untouched: this file records amendments by appending blocks, not by a per-amendment status ledger. Amendment number verified free before writing (the stack holds II.1 at the Addendum II tail and II.2; II.3 was next)..changeset/hook-previous-row-invariant-rewrite.md—@objectstack/spec: minor. Judged, not inherited:packages/spec'sfiles[]carriessrc/**/*.zod.ts, so the prose ships verbatim, and the amended text is also present in the built output (ROW-INVARIANT-IN-EFFECTin 6dist/files; controlPAYLOAD stays BATCH-scopedin 4) — soskip-changesetdoes not apply. The act is an additive widening of the contract's accept set (a previously undocumented shape declared permitted — the clause ② limb), which the WHICH LEVEL rule grades at leastminorandcheck-changeset-no-major's LEVEL axis enforces for a PR declaring clause ②. No schema key, type or optionality changes; no BREAKING banner and no ADR-0087 marker, because nothing an author writes is renamed, retired or re-typed. Locally:check-empty-changeset✓ (1 declaring changeset),check-adr-0087-registration✓ (non-breaking),check-changeset-no-major✓ no major; its LEVEL axis reports NOT APPLICABLE locally (nopull_requestpayload) and reads this body in CI.Deliberately NOT changed, and why:
packages/objectql/src/engine.tslines 3183–3186,packages/objectql/src/multi-update-hook-key-divergence.tslines 12–15 (quoting the addendum as history),packages/objectql/src/bulk-write-per-row-hooks.test.tsline 570 (a test comment). Engine-internal, not published contract text, outside the claim's file surface.Verification — round 1 (head
3c1f7c1bb, baseae19f5edb)Heavy steps under
bash scripts/pm/os-verify-lock.sh(OS_VERIFY_LOCK_SLOT=issue-16074); verdicts read from the wrapper'sVERDICT command-exitline, never a bare status. Wall-clock figures are shared-box readings.@objectstack/specis empty; the package itself) + generated artifactspnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generatedVERDICT command-exit 0·✓ All 15 generated artifacts are up to date(check:docsincluded — the TSDoc feeds nocontent/docs/references/**page; 0 hits of either sentence there, controlMULTI_UPDATE_HOOK_KEY_DIVERGENCE2 files)pnpm --filter @objectstack/spec testVERDICT command-exit 0·Test Files 470 passed (470)·Tests 13218 passed (13218)pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0(tsc --noEmit,check:scripts-typecheck,check:test-typecheck54 files / 261 pinned errors held)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack→ 84 commands off merge-baseae19f5edb(no hand-fed paths); every exit code landed to disk before reading--ran:84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN· 81 exit 0 · 3 exit 3 = PREREQUISITE NOT MET → NOT MEASURED, not green, not red, declared to CI:check:dual-build-cjs-loads(reads built output of 87 packages, none built here),check:lean-entry-closure(loadspackages/objectql/dist/core.*),@objectstack/lint check:doc-formula-expressions(imports built@objectstack/formulaand@objectstack/lint). None of the three reads a file this diff touches.pnpm check:nul-bytes+grep -naPover the four changed filesnode node_modules/eslint/bin/eslint.js --no-inline-config --format jsonover the two edited TS fileseslint.config.mjs's own statement (line 328: "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file"); ② file count 2 from the--format jsonoutput; ③ invariance: with type-aware linting off, a prose-only diff moves no untouched file's verdict — the repo-widepnpm lintis CI's.Reverse verification / ablation: not applicable — the diff moves prose and one anchor string, no behaviour. The behaviour the amended prose describes is already pinned against the real engine by
packages/objectql/src/multi-update-hook-key-divergence.test.ts(assertscode,status400,keys,rows, message "Nothing was written") and by the three*-provenance.per-row.test.tsfiles (keys: ['customized'],rows: 2, nothing written); those pins are unchanged.Zero-with-control readings: retired sentence
not so a rewriteinpackages/spec/src→ 0 (control:ROW-INVARIANT-IN-EFFECT→ 2, the two amended sites); either sentence incontent/docs→ 0 (control above). Every ADR id cited in the new text (ADR-0049, ADR-0058, ADR-0112) resolves underdocs/adr/.Consumer sweep: not owed — no type, export or schema moved; the public face of
HookContextSchemais byte-identical.Verification — round 2 (head
d6711fcff, the ADR amendment)Gate families were derived, not recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackoff merge base
ae19f5edb(no hand-fed paths). Of the families it places, 13 are placed by the newly addeddocs/adr/0058-…path; 2 of those take a value from the workflow and 1 is checker-health only, so thelocally runnable set attributable to this round is the 10 below. Every exit code was landed to disk before
being read (
cmd > log 2>&1; EXIT=$?), never through a pipe.pnpm check:adr-anchorsnode scripts/check-adr-links.mjs(+--self-test)node scripts/check-adr-symbol-anchors.mjs(+--self-test)pnpm check:doc-authoringpnpm check:cross-package-test-inputsnode scripts/check-ci-filter-parity.mjspnpm check:pm-governed-mergespnpm check:pm-governed-prosepnpm check:nul-bytes(+grep -naPover the edited file)pnpm --filter @objectstack/lint run check:doc-formula-expressionscheck:doc-formula-expressionsfirst answered exit 3 = PREREQUISITE NOT MET ("Nothing was measured"), because itimports the built
@objectstack/formula/@objectstack/lint. It was not reported as green on that basis: theprerequisite was built under the shared lock (
bash scripts/pm/os-verify-lock.sh -c '… turbo run build --filter=@objectstack/formula --filter=@objectstack/lint --concurrency=2'→
VERDICT command-exit 0) and the gate re-run, giving a real exit 0.No formatter of record owns the ADR tree — looked up rather than assumed: AGENTS.md states
pnpm lint(eslint) is the onlystyle authority and that Prettier is deliberately absent (no
.prettierrc*, noformatscript); there is nomarkdownlint/remark config in the repo. So the block is hand-wrapped to the file's own blockquote idiom
(every line prefixed
>, bare---between neighbours, wrap at or under 79 columns, which the whole new block holds).Reverse verification that the new anchor is READ, not merely unseen. A green symbol-anchor gate could mean the
anchor was never detected. Proved otherwise, from the committed state: the anchor
packages/spec/src/data/hook.zod.ts#HookContextSchemawas mutated on disk to a symbol that does not exist(mutation proven landed by
grep -c, 1 hit new / 0 hits old), andcheck-adr-symbol-anchorsturned exit 1 with[unresolved-symbol] docs/adr/0058-…:489. Restored withgit checkout HEAD -- FILEPATHunder anEXIT INT TERMtrap andverified by STATE, not exit code:
git diff HEADempty andgit hash-objectequal to the HEAD blob(
9c1ea25c7495d96143f0c994419bcdd81fb84bfe), then the gate re-measured exit 0.Changeset — unchanged and byte-identical, re-derived rather than assumed:
docs/adr/**sits in no package'sfiles[], so the added path publishes nothing and moves no level. Blob hash at3c1f7c1bband atd6711fcffare thesame object (
d6ede1d34a2a57981c2789d4d0745a3208a2afaf); no second changeset was added. This round's commit touchesexactly one file (
git diff --name-only 3c1f7c1bb..HEAD⇒docs/adr/0058-expression-and-predicate-surface.md), so thefour files of round 1 are untouched.
Verification — round 3 (head
5e7e22768, contract-review REWORK F1/F2)Patch round for the two must-fix findings of the
CONTRACT_REVIEW_TIERreview (comment5610784303, which judgedhead
3c1f7c1bb). Both were re-measured against the engine before any prose was written; both findings held.F1 — the refusal's message does not END with "Nothing was written". Measured in
packages/objectql/src/multi-update-hook-key-divergence.tsbuildMessage: the message continuesWrite those records individually, from inside the handler with 'ctx.api' or by id.after that phrase, and the pin isexpect(err.message).toContain('Nothing was written')(multi-update-hook-key-divergence.test.ts, the "names theobject, the diverging key and the prescription" case).
hook.zod.tsand the changeset now say the message says thephrase and then names the remedy.
F2 — the refusal was stated unconditionally; the engine abstains. Measured in
engine.tsdispatchPerRowBeforeHooks: the comparison runs only underif (sealedLoopWrites?.hookWrittenKeys !== undefined), andseal(current)inhook-write-provenance.tsreturns{ data }with nohookWrittenKeyswhencurrent !== payload— i.e. when a hook REPLACED
ctx.input.datainstead of assigning into it. So the abstention is real and is conditionedexactly on replacement-vs-in-place assignment. The admitted shape is now qualified as an in-place assignment, the
abstention is named where the refusal is claimed, and a row-conditioned REPLACEMENT is listed as a third shape the
rule does not admit — it clears with no refusal at all, which is precisely the silent widening this clause exists to
prevent.
Where the clause was mirrored. Every carrier of the same sentence that this PR itself authored:
packages/spec/src/data/hook.zod.ts(the D3 bullet),packages/spec/src/data/bulk-write-hook-conformance.ts(the D3docblock and its residual-hazard note, which listed per-row VALUES as the only unenforced blind spot),
docs/adr/0058-…mdAmendment II.3, and the anchor JSON'sinvariantprint text. Leaving any one of them behind wouldput the contract in contradiction one file over — the same reason the review gave for the conformance mirror.
Changeset — level re-derived, not inherited. Round 2 said the changeset must stay byte-identical; F1 requires
touching it, so the level was re-derived from scratch rather than carried over. The edit is prose inside the existing
changeset body; the widening it describes is unchanged,
HookContextSchema's public face is still byte-identical,nothing is renamed/retired/re-typed, and
src/**/*.zod.tsis still inpackages/spec'sfiles[]so the prose stillpublishes.
@objectstack/spec: minortherefore stands. No second changeset was added and the level was not changed.Gates run on this head (exit codes captured to disk before reading, never through a pipe):
pnpm --filter @objectstack/spec check:generated0 ("All 15 generated artifacts are up to date"),pnpm check:adr-anchors0,pnpm --filter @objectstack/spec build0,targeted spec tests
vitest run --project local src/data/bulk-write-hook-conformance.test.ts src/data/hook.test.ts src/data/hook-body.test.ts0 (3 files / 123 tests passed),pnpm --filter @objectstack/spec typecheck0,pnpm check:nul-bytes0. Beyond those, the fullscripts/pm/dispatch-gates.mjsderivation for these five paths wasrun: 87 of 89 derived families exit 0. The two not measured are
pnpm check:dual-build-cjs-loadsandpnpm check:lean-entry-closure, both of which exited 3 = PREREQUISITE NOT MET ("this gate reads built output …⛔ This is NOT a pass: nothing was measured") because they need a whole-repo
pnpm build; that is CI's Build Core /Lint & Repo Gates run, and this is a declared narrowing, not a skipped gate.
Reverse verification / ablation: still not applicable — the diff moves prose, one anchor string and one changeset body;
no behaviour. The behaviour the corrected prose now describes (the abstention) is pinned engine-side by the
hook-write-provenanceKNOWN-LIMIT tests, not by anything inpackages/spec.验收备注
5610769499). The dev recommended a separate governed ADR PR (option A); the seat ruled the ADR amendment folds into THIS PR, which makes the whole PR governed and human-merge only. Landed in patch round 2 above; B ("leave it as historical record") was refused because directive 13 instructs the next agent to grep the ADRs, so an unamended bullet is not a dated artefact but the answer that grep returns.bulk-write-hook-conformance.tslines 138–145 ("Nothing measured needs divergent payloads … everybeforeUpdatepayload rewrite in this repo is row-invariant") predates the three stamps and Amulti: trueupdate applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099; still true at the key-set level, left as the measured record it is. 承接者:无.Generated by Claude Code