Skip to content

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

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-16074-hook-previous-row-invariant-rewrite
Sep 10, 2026
Merged

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
os-zhuang merged 4 commits into
mainfrom
claude/issue-16074-hook-previous-row-invariant-rewrite

Conversation

@os-bill

@os-bill os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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.ts is amended so that on a predicate (multi: true) write the per-row previous may be used by a before* 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's MULTI_UPDATE_HOOK_KEY_DIVERGENCE (400) refusal as the mechanism that makes it safe. Option 2 (change the hooks) is not adopted and nothing under packages/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.tsHookContextSchema.input 的 D3 bullet:接受集放宽,把一个原先未被文档承认的形态声明为允许。引擎行为没有变:兜住这个形态的 MULTI_UPDATE_HOOK_KEY_DIVERGENCE(#14099)早就在跑 —— 两行的 hook 链写出不同键集时,整批在任何写入之前被拒(ADR-0112 信封,status: 400,带 keysrows)。本 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弃权不判。两条都已闭,且第三轮是先对着引擎重测、再动笔。
  • 第二份(增量):判 PASS WITH FINDINGS,四条散文项(引用 comment id、换行残句、「closing sentences」、changeset 的署名)已在第四轮修完。⚠️ 独立性说实话:这一份是本席位派出的子代理,继承本 session id ⇒ 席位内达档复核,⛔ 不是第一份那种跨席位独立。

一件请你在 diff 里特别看一眼的事,我不让它当成纯转录混过去。 Amendment II.3 比裁决的字面多了一句限定:「且是就地赋值(assigned IN PLACE)」。复核判为蕴含,而非新决定 —— 理由是裁决自身的安全性主张(「由 key-divergence 拒绝兜底」)只在就地赋值下成立,三个被裁的 stamp 也确实就地赋值(email-template-provenance.ts:91sharing-rule-provenance.ts:80webhook-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 textpackages/spec/src/data/hook.zod.ts lines 553–560, the D3 bullet under HookContextSchema.input:

 - The PAYLOAD stays BATCH-scoped (D3). Every per-row `beforeUpdate`
   context carries THE one payload, not a copy — `driver.updateMany` takes
   one SET clause for N rows — so a rewrite applies to the whole batch
   whichever row's dispatch made it, and rewrites accumulate in dispatch
   order. A rewrite CONDITIONED on the row is therefore out of contract:
   it widens to every matched row instead of scoping itself. Per-row
   `previous` is supplied so a guard can REFUSE (throw), not so a rewrite
   can be aimed.

The reading holds: the sentence is there, verbatim as the card quotes it.

(b) All three stamps condition customized: true on ctx.previous — each file read in full, and each carries the identical branch:

stamp previous bound from the conditioned write
packages/plugins/plugin-email/src/email-template-provenance.ts line 88 const previous = ctx?.previous … line 90–92 if ((previous.managed_by === 'package' || previous.managed_by === 'platform') && previous.customized !== true) { data.customized = true; }
packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts line 77 line 79–81, same condition, same write
packages/plugins/plugin-webhooks/src/webhook-provenance.ts line 79 line 81–83, same condition, same write

The 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):

Per-row previous is supplied so a guard can REFUSE (throw), and — ruled on #16074 — so a before* hook can make a ROW-INVARIANT-IN-EFFECT rewrite: one whose written KEY SET is the same on every matched row AND is assigned IN PLACE (ctx.input.data.customized = true), such as a provenance stamp that writes customized: true on every row whose previous.managed_by is package-seeded. What makes that shape safe is not the hook but the engine's MULTI_UPDATE_HOOK_KEY_DIVERGENCE refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned IN PLACE, and if any two rows disagree the WHOLE batch is refused before any write — nothing is written, not the first row. In-place is the load-bearing half of that condition, not a detail of spelling: a hook that REPLACES ctx.input.data hands the dispatch a fresh object whose keys it cannot attribute, so the recording yields nothing and the comparison is SKIPPED — the batch is not judged at all, rather than judged and passed.

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 — 2 for 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, or ctx.api from 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 of ctx.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.ts names 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's invariant text (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-anchors exit 0.

docs/adr/0058-expression-and-predicate-surface.md (patch round 2, seat ruling 5610769499 — 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, names MULTI_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's files[] carries src/**/*.zod.ts, so the prose ships verbatim, and the amended text is also present in the built output (ROW-INVARIANT-IN-EFFECT in 6 dist/ files; control PAYLOAD stays BATCH-scoped in 4) — so skip-changeset does 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 least minor and check-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 (no pull_request payload) and reads this body in CI.

Deliberately NOT changed, and why:

  • objectql docblocks that restate the pre-ruling sentence as the engine's own rationale/history: packages/objectql/src/engine.ts lines 3183–3186, packages/objectql/src/multi-update-hook-key-divergence.ts lines 12–15 (quoting the addendum as history), packages/objectql/src/bulk-write-per-row-hooks.test.ts line 570 (a test comment). Engine-internal, not published contract text, outside the claim's file surface.
  • Execution note 3: no class-wide census was run.

Verification — round 1 (head 3c1f7c1bb, base ae19f5edb)

Heavy steps under bash scripts/pm/os-verify-lock.sh (OS_VERIFY_LOCK_SLOT=issue-16074); verdicts read from the wrapper's VERDICT command-exit line, never a bare status. Wall-clock figures are shared-box readings.

step command verdict
① build (closure of @objectstack/spec is empty; the package itself) + generated artifacts pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated VERDICT command-exit 0 · ✓ All 15 generated artifacts are up to date (check:docs included — the TSDoc feeds no content/docs/references/** page; 0 hits of either sentence there, control MULTI_UPDATE_HOOK_KEY_DIVERGENCE 2 files)
② full package suite pnpm --filter @objectstack/spec test VERDICT command-exit 0 · Test Files 470 passed (470) · Tests 13218 passed (13218)
② typecheck pnpm --filter @objectstack/spec typecheck VERDICT command-exit 0 (tsc --noEmit, check:scripts-typecheck, check:test-typecheck 54 files / 261 pinned errors held)
③④ gate families node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 84 commands off merge-base ae19f5edb (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 (loads packages/objectql/dist/core.*), @objectstack/lint check:doc-formula-expressions (imports built @objectstack/formula and @objectstack/lint). None of the three reads a file this diff touches.
nul-bytes pnpm check:nul-bytes + grep -naP over the four changed files OK — 8100 tracked text files, no raw control bytes; 0 hits on the four files
lint, narrowed and proven node node_modules/eslint/bin/eslint.js --no-inline-config --format json over the two edited TS files 2 files linted, 0 errors, 0 warnings. ① population read from eslint.config.mjs's own statement (line 328: "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file"); ② file count 2 from the --format json output; ③ invariance: with type-aware linting off, a prose-only diff moves no untouched file's verdict — the repo-wide pnpm lint is 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 (asserts code, status 400, keys, rows, message "Nothing was written") and by the three *-provenance.per-row.test.ts files (keys: ['customized'], rows: 2, nothing written); those pins are unchanged.

Zero-with-control readings: retired sentence not so a rewrite in packages/spec/src → 0 (control: ROW-INVARIANT-IN-EFFECT → 2, the two amended sites); either sentence in content/docs → 0 (control above). Every ADR id cited in the new text (ADR-0049, ADR-0058, ADR-0112) resolves under docs/adr/.

Consumer sweep: not owed — no type, export or schema moved; the public face of HookContextSchema is 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/objectstack
off merge base ae19f5edb (no hand-fed paths). Of the families it places, 13 are placed by the newly added
docs/adr/0058-… path
; 2 of those take a value from the workflow and 1 is checker-health only, so the
locally 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.

command exit
pnpm check:adr-anchors 0
node scripts/check-adr-links.mjs (+ --self-test) 0 / 0
node scripts/check-adr-symbol-anchors.mjs (+ --self-test) 0 / 0
pnpm check:doc-authoring 0
pnpm check:cross-package-test-inputs 0
node scripts/check-ci-filter-parity.mjs 0
pnpm check:pm-governed-merges 0
pnpm check:pm-governed-prose 0
pnpm check:nul-bytes (+ grep -naP over the edited file) 0 / no hits
pnpm --filter @objectstack/lint run check:doc-formula-expressions 0 (see below)

check:doc-formula-expressions first answered exit 3 = PREREQUISITE NOT MET ("Nothing was measured"), because it
imports the built @objectstack/formula / @objectstack/lint. It was not reported as green on that basis: the
prerequisite 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 only
style authority and that Prettier is deliberately absent (no .prettierrc*, no format script); there is no
markdownlint/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#HookContextSchema was mutated on disk to a symbol that does not exist
(mutation proven landed by grep -c, 1 hit new / 0 hits old), and check-adr-symbol-anchors turned exit 1 with
[unresolved-symbol] docs/adr/0058-…:489. Restored with git checkout HEAD -- FILEPATH under an EXIT INT TERM trap and
verified by STATE, not exit code: git diff HEAD empty and git hash-object equal 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's
files[], so the added path publishes nothing and moves no level. Blob hash at 3c1f7c1bb and at d6711fcff are the
same object (d6ede1d34a2a57981c2789d4d0745a3208a2afaf); no second changeset was added. This round's commit touches
exactly one file (git diff --name-only 3c1f7c1bb..HEADdocs/adr/0058-expression-and-predicate-surface.md), so the
four 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_TIER review (comment 5610784303, which judged
head 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.ts buildMessage: the message continues
Write those records individually, from inside the handler with 'ctx.api' or by id. after that phrase, and the pin is
expect(err.message).toContain('Nothing was written') (multi-update-hook-key-divergence.test.ts, the "names the
object, the diverging key and the prescription" case). hook.zod.ts and the changeset now say the message says the
phrase and then names the remedy.

F2 — the refusal was stated unconditionally; the engine abstains. Measured in engine.ts
dispatchPerRowBeforeHooks: the comparison runs only under if (sealedLoopWrites?.hookWrittenKeys !== undefined), and
seal(current) in hook-write-provenance.ts returns { data } with no hookWrittenKeys when current !== payload
— i.e. when a hook REPLACED ctx.input.data instead of assigning into it. So the abstention is real and is conditioned
exactly 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 D3
docblock and its residual-hazard note, which listed per-row VALUES as the only unenforced blind spot),
docs/adr/0058-…md Amendment II.3, and the anchor JSON's invariant print text. Leaving any one of them behind would
put 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.ts is still in packages/spec's files[] so the prose still
publishes. @objectstack/spec: minor therefore 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:generated 0 ("All 15 generated artifacts are up to date"),
pnpm check:adr-anchors 0, pnpm --filter @objectstack/spec build 0,
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.ts 0 (3 files / 123 tests passed), pnpm --filter @objectstack/spec typecheck 0,
pnpm check:nul-bytes 0. Beyond those, the full scripts/pm/dispatch-gates.mjs derivation for these five paths was
run: 87 of 89 derived families exit 0. The two not measured are pnpm check:dual-build-cjs-loads and
pnpm 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-provenance KNOWN-LIMIT tests, not by anything in packages/spec.

验收备注

  • Open question 1 — SETTLED, option C (seat ruling, comment 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.
  • noted, not filed: the three objectql docblocks listed above restate the pre-ruling sentence; engine-internal, no published text moves. 承接者:无.
  • noted, not filed: bulk-write-hook-conformance.ts lines 138–145 ("Nothing measured needs divergent payloads … every beforeUpdate payload rewrite in this repo is row-invariant") predates the three stamps and A multi: true update 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

…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
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/bulk-write-hook-conformance.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via HookContextSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/bulk-write-hook-conformance.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 134 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7ddf13dca4a7df0c2595172ffaf8020d45ae23b5packageMentionDocs.

Which tree this was computed on

This run read content/docs from e2b758bf3b54e1264b67911e05f0aed3baf8766e — the merge of head 541636ebbf32b80ff6511b1ff1219a65af9e5003 into base 7ddf13dca4a7df0c2595172ffaf8020d45ae23b5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7ddf13dca4a7df0c2595172ffaf8020d45ae23b5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: REWORK (small, prose-only) (audit reading; director seat, summon #18 segment 6, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00xxZ)

PR #17249 · head 3c1f7c1bb75bcd6375d8e227a23af22c3e3ca59d (re-read at posting 00:32:12Z; unchanged since 00:18Z) · card #16074.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (85 harness model stamps, all claude-fable-5-1, zero residue; positive control 69 assistant / 46 user role tokens), adopted verbatim below.
  • Implemented-by: mode:subagent os-dev on branch claude/issue-16074-hook-previous-row-invariant-rewrite under domain:spec seat session_01MkQhmuuJAVDjmeWNixwDDH (os-bill; newest Claim: 5610325974). Distinct sessions ⇒ not a self-review.
  • Reading for the seat: ruling executed exactly and the engine already delivers what the amended text declares; REWORK for two sentences on the published .zod.ts face — F1 hook.zod.ts:574 "a message ending 'Nothing was written'" is false (the message contains, not ends with, that phrase; same in the changeset), F2 hook.zod.ts:565-568 states the key-divergence refusal unconditionally while the engine abstains when a hook replaces ctx.input.data (name the in-place-assignment condition and the abstention; mirror in bulk-write-hook-conformance.ts). Test Core (4/6) red is the known turso flake driver-turso: turso-driver-timeout.test.ts ejected PR #17101 from the merge queue on expect(remote.requests()).toBeGreaterThan(0) — a 100 ms budget asserted through a request COUNT, so it reads as a behaviour regression rather than a load failure #17121, not this diff. Handoff per contract-review.md FAIL discipline follows on the card. ⛔ This seat cleared no carrier and touched no PR state at posting.

Verdict

REWORK (small, prose-only) — two sentences on the published face; everything else passes. The PR executes the ruling exactly (option 1, no plugin edits, no census, operator-facing shape stated), the engine already delivers what the amended text declares, scope/changeset/Clause-② are correct. Two accuracy defects in the shipped .zod.ts prose (F1 factual, F2 an overclaim on the very safety mechanism the ruling turns on) need a one-sentence edit each before PASS. The red CI shard is a known flake unrelated to this diff.

Head reviewed

3c1f7c1bb75bcd6375d8e227a23af22c3e3ca59d — unchanged from the head named in the brief; one commit; merge-base with origin/main = ae19f5edb (matches the PR body's stated base); origin/main at fetch = 6058cb229. Diff: 4 files, +69/−16 — .changeset/hook-previous-row-invariant-rewrite.md (new), packages/spec/src/data/hook.zod.ts (TSDoc only), packages/spec/src/data/bulk-write-hook-conformance.ts (docblock only), scripts/adr-anchors/packages__spec__src__data__bulk-write-hook-conformance.ts.json (invariant string only).

Clause-② reading · claim match · --pair

  • Correct reading: yes — the conformance limb (contract-review.md: 「conformance 类 ⛔ 不机械化」). No new schema key, no new export, no .describe() change (mechanical floor alone would read no; check-widening-tells finds no tell). But a shape the contract declared out-of-contract is now declared permitted — the accept set of the contract widens, exactly as the ruling (comment 5560086928) pre-declared: "Clause-② conformance limb: a previously undocumented shape is declared permitted".
  • Claim match: claim comment 5610325974 says yes; PR body line 3 Clause-②: yes; needs:contract-review on both carriers. Match.
  • node scripts/pm/check-clause2-carriers.mjs --pair 17249 → exit 0 ("declaration readable in the fixed spelling and both carriers agree"). Run from the byte-identical origin/main mirror at /tmp/claude-0/.../scratchpad/os-scripts/scripts — the working-tree copies of check-clause2-carriers.mjs and check-widening-tells.mjs in /home/user/objectstack differ from origin/main (stale); check-governed-merges.mjs is identical in both.

Governed surface / protocol label

  • check-governed-merges.mjs --test <4 paths>0 of 4 governed, exit 0 — ordinary queue landing. (docs/adr/0058-…md, which the dev deliberately left out, tests governed → exit 3; see F4.)
  • Labels on PR: protocol:data, needs:contract-review, documentation, tooling, size/s. protocol:data is right: packages/spec/src/data/hook.zod.ts ships (packages/spec/package.json files[] carries src/**/*.zod.ts), so the TSDoc is published text.

CI on head (polled 2026-09-10 ~00:29Z; PR opened 00:18Z)

Findings

F1 — published-face factual error (must fix, one word). packages/spec/src/data/hook.zod.ts:574 (PR head): "…plus object naming the target and a message ending "Nothing was written"." The engine's message (packages/objectql/src/multi-update-hook-key-divergence.ts:202-203, buildMessage) ends "…Nothing was written. Write those records individually, from inside the handler with 'ctx.api' or by id." The message contains the phrase; it does not end with it, and the pin is toContain('Nothing was written') (multi-update-hook-key-divergence.test.ts:201). Same wording in .changeset/hook-previous-row-invariant-rewrite.md:13. Shipped .zod.ts prose that is false is a defect (review-checklist: 「假 TSDoc/.describe() 即缺陷」). Fix: "a message that says "Nothing was written"" (or quote the sentence).

F2 — overclaim on the safety mechanism (must fix, one sentence). hook.zod.ts:565-568: "the dispatch records, per row, the payload keys that row's hook chain assigned, and if any two rows disagree the WHOLE batch is refused before any write". Unconditional as written; the engine is not. packages/objectql/src/engine.ts:3292-3318 (dispatchPerRowBeforeHooks): when any hook in the batch replaces ctx.input.data rather than mutating it, seal() returns no key record and the comparison is skipped entirely — "the whole batch ABSTAINS … keeps the pre-#14099 behaviour" (module docblock multi-update-hook-key-divergence.ts:85 "When the recording cannot speak, the batch is not judged at all"). hook.zod.ts nowhere mentions this limit (grep replace/abstain/KNOWN LIMIT/#14088 → only the unrelated line 955). The bullet now admits a shape on the strength of this refusal and names "two shapes this rule does NOT admit" — the PR body's own goal is "so the rule cannot be read wider than the engine enforces" — but a row-conditioned replacement of the payload is a third shape that clears with no refusal at all and widens silently. Fix: one clause qualifying the admitted shape as an in-place assignment and naming the abstention (a hook that replaces ctx.input.data leaves the dispatch unable to attribute keys; the batch is then not judged). Mirror the same clause in bulk-write-hook-conformance.ts:69-83 if the D3 bullet there is meant to stay a faithful mirror.

F3 — engine docblock now contradicts the contract (file a card; not blocking). packages/objectql/src/engine.ts:3183-3186 states the retired rule in the present tense ("…is outside the contract … not so a rewrite can be aimed"), and bulk-write-per-row-hooks.test.ts:570 repeats it. multi-update-hook-key-divergence.ts:12-15 quotes it as history — fine. The dev listed these as "noted, not filed: 承接者:无". This PR is the change that falsifies them, and the new contract text sends readers to that very module; "no owner" is not a terminal disposition under the review checklist (noticed-untouched ⇒ card). Recommend a follow-up card on the objectql lane rather than folding in (moving packages/objectql/src/** on a clause-② yes PR would drag the LEVEL axis into a second package for a comment edit).

F4 — ADR-0058 Addendum II line 236 still states the pre-ruling rule (governed follow-up; not blocking). docs/adr/0058-expression-and-predicate-surface.md:236 "Per-row previous is supplied so a guard can REFUSE, not so a rewrite can be aimed." Prime Directive #13 wants a reversed recorded decision amended on the record. The dev's open question 1 recommends option A (a one-line dated Amendment note citing decision batch #59 / #16074 in its own governed, maintainer-landed PR). Agree with A: docs/adr/** is governed (exit 3), folding it in would make this whole PR human-merge-only; the machine-readable record (scripts/adr-anchors/...json invariant) already carries the ruled reading, so only the ADR prose lags. Director/PM to file it; Fixes #16074 stays correct because the ruling named only hook.zod.ts.

F5 — docs (hand-authored and generated): no page falsified; two pre-existing stale statements, out of scope. Retired sentence / row-invariant / KEY_DIVERGENCE across content/docs/** (excluding references, releases), content/docs/protocol/**, skills/** → 0 hits; content/docs/automation/hooks.mdx and hook-bodies.mdx say nothing about the purpose of per-row previous on a predicate write. Generated riders correctly untouched: content/docs/references/data/hook.mdx is built by packages/spec/scripts/build-docs.ts from .describe() text (row 95 previous = "Record state before operation"); the amended text is key-level TSDoc inside lazySchema(() => z.object({...})), not rendered; no .describe() changed; authorable-surface/*.json carries no HookContext (runtime, not authorable). check:generated is inside Lint & Repo Gates (pending). Pre-existing drift unrelated to this card, worth a docs card: content/docs/data-modeling/formulas.mdx:204 ("previous … unbound in before* hooks, which fire once for the batch") and content/docs/api/data-flow.mdx:311 (implies only after* dispatches per row) — both false since ADR-0058 Addendum II / #5574.

F6 — changeset: @objectstack/spec: minor, non-breaking, no !/BREAKING banner, no ADR-0087 marker — correct. WHICH LEVEL (.github/workflows/pr-automation.yml:712, batch #35) grades an additive widening ≥ minor; the LEVEL axis (#16055) requires ≥ minor on a clause-② yes PR and Check Changeset is green twice. Nothing authorable is renamed, retired or re-typed and HookContextSchema's public face is byte-identical, so no BREAKING banner; the ADR-0087 disposition marker is owed only by declared-breaking changesets (AGENTS.md:1047) — none owed, none present, check-adr-0087-registration reads non-breaking. skip-changeset correctly rejected (prose ships in src/**/*.zod.ts and in dist/*.d.ts). The changeset body inherits F1 — fix there too.

F7 — tests pin the declared semantics (existing pins; no new test needed for a prose amendment). Success path (same key set on every row → one updateMany payload with customized: true, both rows stamped): the three *-provenance.per-row.test.ts lines 138-150. Refusal path (code, keys: ['customized'], nothing written): same files 184-205; envelope shape (code, status 400, message contains "Nothing was written"): multi-update-hook-key-divergence.test.ts:178-201. Spec-side bulk-write-hook-conformance.test.ts:79 pins the D3 payload scope table; no test asserts the prose. Acceptable — the spec has no engine to run — but after F2 is added, nothing pins the abstain clause from the contract side either; the objectql hook-write-provenance KNOWN-LIMIT tests cover the behaviour.

F8 — scope: exact. Four files, all one contract clause and its mirrors; bulk-write-hook-conformance.ts and the anchor are necessary same-round supplements (hook.zod.ts names that file as "the full clause set (D1–D7)" — left alone the contract contradicts itself one file over; the anchor's invariant is the gate's print text, presence check unchanged). No content/docs/releases/ edit; nothing under packages/plugins/** (option 2 not adopted — matches the ruling and the claim's write set); no census (execution note 3).

F9 — ruling execution: exact. Execution note 1: both inherited readings re-verified and recorded (report comment 5610669820); I re-measured on origin/main: hook.zod.ts:559 carries the old sentence verbatim; all three stamps bind previous = ctx?.previous and write data.customized = true iff previous.managed_by ∈ {'package','platform'} && previous.customized !== true (email-template-provenance.ts ~88-92, sharing-rule-provenance.ts ~77-81, webhook-provenance.ts ~79-83). Note 2: operator-facing shape (400, code, keys, rows, object, remedy) is in the bullet — engine agrees on every field: divergingHookPayloadKeys returns sorted keys, rows.length is passed, refusal thrown after the loop and before seal/strips/validation/driver.updateMany (engine.ts:3318), "LAST dispatch's value" matches the module's measured note. Spec-text-only PR; the engine already conforms (modulo F2's abstention, which is an omission in the text, not an engine gap).

Acceptance notes

  • Implemented-by: mode:subagent os-dev on branch claude/issue-16074-hook-previous-row-invariant-rewrite (newest Claim: = comment 5610325974, dispatch session session_01MkQhmuuJAVDjmeWNixwDDH, seat os-bill, claude-fable-5-1). Reviewed-by: director seat, session_017Js5kTpTtxieBjPyScgxJ3 — distinct session ⇒ independent review, not SELF-REVIEW.
  • Path to PASS: patch round to the same dev — F1 (hook.zod.ts:574 + changeset line 13) and F2 (hook.zod.ts:565-568, mirror in bulk-write-hook-conformance.ts); re-run pnpm --filter @objectstack/spec check:generated and check:adr-anchors on the new head; then a fresh head brings its own CI (no manual rerun of the flaked shard needed).
  • At ACCEPT, file: (a) the governed ADR-0058 Amendment note (F4, option A); (b) objectql docblock/test-comment drift (F3); (c) docs card for formulas.mdx:204 / data-flow.mdx:311 (F5). The turso flake is already tracked on driver-turso: turso-driver-timeout.test.ts ejected PR #17101 from the merge queue on expect(remote.requests()).toBeGreaterThan(0) — a 100 ms budget asserted through a request COUNT, so it reads as a behaviour regression rather than a load failure #17121 — nothing to file.
  • Before flipping ready: Lint & Repo Gates and the remaining Test Core / Type Check jobs must conclude success on the final head; check-clause2-carriers --pair re-run after the patch push.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Handoff provenance — director seat, summon #18 segment 6 (session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00:32:16Z). Contract-review-tier verdict REWORK (small, prose-only) at #17249 (comment) (head 3c1f7c1bb7, unchanged). Per contract-review.md carrier discipline (FAIL 同 PASS 剥双载体), needs:contract-review is cleared on both carriers (#17249 + card #16074) in one stroke and the handoff comment is on the card; PR stays draft, ⛔ not queue-eligible until a patch round lands and the new head is re-reviewed (delta) and re-hung if needed.


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

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Delta contract review at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS (adopted, with one caveat about what kind of review this is)

Head 5e7e227688fae24e4f693e577469aab348ac13ae · delta judged 3c1f7c1bbd6711fcff (round 2, the ADR amendment) → 5e7e22768 (round 3, the REWORK fixes) · merge-base ae19f5edb · 5 files · adopted by the domain:spec seat 2026-09-10T01:52Z.

No must-fix. Five non-blocking findings.

⚠️ What kind of review this is — the reviewer flagged this itself, unprompted

  • Tier fuse passes. ⛔ A subagent's claim about its own tier is never a reading, so: 136 harness-stamped "model":"claude-fable-5-1" across the 727 KB transcript, zero residue, with a lit control proving the file is scanned ("role":"assistant" = 101, "role":"user" = 36).
  • ⚠️ But it reported its own session id, and it is this seat's ownsession_01MkQhmuuJAVDjmeWNixwDDH, the same id the implementing os-dev ran under, because a subagent inherits the dispatching session's id. So this is a seat-side at-tier delta reading. ⛔ It is not the cross-seat independence the standing verdict on this PR had: that one was the director seat, session_017Js5kTpTtxieBjPyScgxJ3 — a different seat entirely, not dispatched by the seat whose work it judged.
  • The protocol's own definition of a qualifying at-tier reviewer is 「显式传 model 的契约复审档子代理(逐份过转录核验)」, which this satisfies. ⭐ I am recording the distinction anyway, because the reviewer volunteered it and because the same overclaim of mine has just been corrected on the sibling PR feat(spec)!: dataSource.filter and the four object-* filter doors converge onto the ViewFilterRule array form — one filter orthography reaches the family (objectui#6206-B, option A) #17257 — an unmeasured sentence about who checked whose work is precisely what a later reader cannot falsify.

Both must-fix findings of the standing verdict (5610784303) are closed, verified against the engine

F1 closed. buildMessage (multi-update-hook-key-divergence.ts:197-205) ends "…Nothing was written. Write those records individually, from inside the handler with 'ctx.api' or by id.", and the pin is toContain. New wording — hook.zod.ts:581-582 "a message that says "Nothing was written" and then names the remedy", changeset :12 "…before naming the remedy" — both true. No ending residue in any carrier, and the zero carries a lit control (three shapes lights once in each of the three prose carriers, and the hits are the new sentences).

F2 closed, and closed accurately — the reviewer re-derived the engine rather than trusting the dev: one recording armed over the batch payload (engine.ts:3241-3245), seal(batchCtx.input.data) at :3299, comparison only under if (sealedLoopWrites?.hookWrittenKeys !== undefined) (:3318); and seal(current) (hook-write-provenance.ts:224-231) returns no hookWrittenKeys iff current !== payload — iff a hook replaced ctx.input.data. ⭐ Pinned end-to-end already: multi-update-hook-key-divergence.test.ts:375-397 is exactly a row-conditioned replacement and produces one updateMany with no refusal. The reviewer looked specifically for a replacement overclaim in the fix and found none.

The scope supplement — judged correct and necessary

Round 3 mirrored the F2 clause into docs/adr/0058…'s Amendment II.3 and the anchor invariant, beyond the two files my order named. Confirmed: the round-2 ADR block carried the unconditional sentence verbatim — the exact overclaim F2 named — sitting in the governed record Prime Directive 13 sends the next author to grep. ⛔ Reverting it would have shipped the contradiction into the ADR. Each mirrored sentence checks out against the engine reading, and the anchor invariant is confirmed to be print text only (check-adr-anchors.mjs:1099-1106 echoes it when the anchored file stops citing its ADR; it is never validated against the file). check:adr-anchors exit 0 at head.

Amendment II.3 as a governed record — reviewed for the first time by anyone

Number ✔ (II.2 is #6752 at :380; II.3 was next free). Placement and idiom ✔ (blockquoted, dated 2026-09 after II.2's 2026-08, at :442-506, before the --- preceding ## TL;DR). The :236 pointer ✔ — the base sentence is byte-identical with the pointer appended on the same line, so a grep landing on the stale sentence lands on the pointer. Faithful to ruling 5560086928 ✔ — 「16063 c, 其他同意」 byte-identical including the double space, batch #59, option 1, option 2 rejected on #15302's measurement.

One thing goes beyond the ruling's literal words, and the maintainer will see it in a governed diff, so it is named here rather than left to pass as transcription: the ruling says "same written key set on every row"; the block adds "and is assigned IN PLACE". The reviewer judges this an entailment, not a new decision — the ruling's own safety claim is only true under in-place assignment, all three ruled-on stamps do assign in place (email-template-provenance.ts:91, sharing-rule-provenance.ts:80, webhook-provenance.ts:82), and the previous review's F2 demanded exactly this clause. I adopt that reading, and this seat's 席位意见 will state it explicitly.

Prime Directive 13, whole PR

docs/adr/ grepped for not so a rewrite can be aimed, row-invariant, MULTI_UPDATE_HOOK_KEY_DIVERGENCE, #14099, #16074, REPLACES, Amendment II.3 — hits only in ADR-0058. The zeros (#14088, hook-write-provenance, per-row previous) carry #5574 as a lit control, lit at ADR-0058 :123/:151/:155 and read. customized: true also lights ADR-0131 :170/:522; both read — seeder prose, no decision about hook shape on predicate writes. ⇒ ⛔ No ADR records a decision this diff reverses beyond the D3 sentence the PR amends by addition.

Clause ②, level, governed reading

--pair 17249 exit 0; --test over all 5 paths exit 3, GOVERNED — intended. Level re-derived rather than accepted: packages/spec's files[] carries src/**/*.zod.ts and dist (so bulk-write-hook-conformance.ts's docblock ships in .d.ts via data/index.ts:147), while docs/adr/** and scripts/adr-anchors/** are in no files[]; rounds 2–3 add no published carrier and change nothing behavioural. minor stands.

⚠️ One correction to my own vocabulary, from the reviewer: --pair is a flag of check-clause2-carriers.mjs, not of check-governed-merges.mjs — the latter silently runs its default sweep if handed it. My readings were from the right script; the shorthand was sloppy.

Findings — all non-blocking

CI, live at 01:49Z — newest run per check name

40 raw runs → 34 names: 28 success, 5 skipped, 1 in progress (Lint & Repo Gates), 0 red. Test Core (4/6) is green — #17121 did not fire on this head.

Next

F1–F4 are four small prose edits in files this PR already touches, three of them in the governed ADR the maintainer will read. This seat will fold them into one short round rather than card them, then fill the ## 维护者速读(草稿)'s 席位意见 — which will name the in-place entailment explicitly — and hand the PR over. ⛔ It stays draft: governed, human merge, and 「受管面人工合并 ⛔ 不替代复核」.

File at ACCEPT (⛔ not folded in)

  1. packages/objectql/src/engine.ts:3183-3186 and bulk-write-per-row-hooks.test.ts:570 restate the retired D3 purpose in the present tense; this PR is what falsifies them. 承接者: domain:engine seat.
  2. content/docs/data-modeling/formulas.mdx:204, content/docs/api/data-flow.mdx:311 — false since Addendum II / beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574. Carried from the standing verdict, ⛔ not re-measured here. 承接者: docs lane.
  3. Seat action, not a card: after PR fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (#16344) #17195 (open, same file) lands, re-read the amended D3 bullet against its hunks.

Generated by Claude Code

…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>

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS (delta re-review) (audit reading; director seat, summon #18 segment 6, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T05xxZ)

PR #17249 · head 541636ebbf32b80ff6511b1ff1219a65af9e5003 (re-read at posting 05:02:59Z; unchanged since 02:41Z) · card #16074.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (95 harness model stamps, all claude-fable-5-1, zero residue; positive control 85 assistant / 65 user role tokens), adopted verbatim below.
  • Implemented-by: mode:subagent os-dev on branch claude/issue-16074-hook-previous-row-invariant-rewrite under domain:spec seat session_01MkQhmuuJAVDjmeWNixwDDH (os-bill; newest Claim: 5610325974). Distinct sessions ⇒ not a self-review.
  • Reading for the seat: F1/F2 of the REWORK closed and each corrected sentence verified against the engine on origin/main; every D3 carrier agrees; CI fully green. The delta also carries ADR-0058 Amendment II.3 (seat ruling 5610769499, option C) ⇒ docs/adr/**governed — draft-only, maintainer hand merge; F3/F5 follow-up cards still owed at ACCEPT. ⛔ This seat cleared no carrier and touched no PR state at posting.

Verdict

PASS WITH FINDINGS. Both must-fix items from 5610784303 are closed and each corrected sentence is true against origin/main's engine; every carrier of the D3 clause (published .zod.ts, conformance mirror, changeset, ADR-0058 Amendment II.3, anchor invariant) now agrees. CI is fully green on the head. The delta is wider than F1/F2 — it carries the ADR-0058 amendment (seat ruling 5610769499, option C) — which is a landing-path change (governed, human merge), not a contract defect. No carrier touched, nothing posted, no files edited.

Head reviewed

541636ebbf32b80ff6511b1ff1219a65af9e5003 — matches the brief; unchanged at start and at end of this review (re-read 04:3xZ). origin/main at fetch 439331895; merge-base ae19f5edb (still an ancestor of main, 33 commits behind, mergeable_state: clean). PR draft, base main, labels documentation, size/m, tooling, needs:contract-review, protocol:data.

Delta summary (3c1f7c1bb7..541636ebbf, 3 commits, 5 files, +128/−45)

  • d6711fcff (round 2, 00:37Z) — docs/adr/0058-…md: new blockquoted Amendment II.3 at :442-505 (before the ---/## TL;DR), plus the forward pointer appended inline to the dated D3 sentence at :236 (sentence byte-identical; same inline idiom as II.2's pointer at :359). Numbering correct (II.2 at :380). Seat ruling 5610769499 (option C), posted 00:30:38Z — 94 s before the director verdict recommended option A.
  • 5e7e22768 (round 3, 01:13Z) — F1/F2 in hook.zod.ts, bulk-write-hook-conformance.ts (D3 bullet and residual-hazard note), the changeset, ADR II.3 and the anchor invariant.
  • 541636ebb (round 4, 02:34Z) — the in-seat delta review's four prose nits (ADR cites comment 5560086928; rewrap stub removed; "closing sentences"; changeset "Maintainer ruling (recorded by the director seat…)"). Touches only ADR + changeset.

Verified: both TS diffs vs origin/main are comment-only (zero non-* changed lines); anchor JSON changes only the invariant string; nothing under packages/plugins/**, packages/objectql/**, content/docs/**. Word-level diff of the hook.zod.ts D3 bullet 3c1f7c1→head shows exactly the F1/F2 edits and nothing else (8 ops, all listed below).

F1 / F2 status

F1 — closed. hook.zod.ts:581-582 "a message that says "Nothing was written" and then names the remedy"; changeset :13 "…before naming the remedy". Engine: multi-update-hook-key-divergence.ts buildMessage ends "…Nothing was written. Write those records individually, from inside the handler with 'ctx.api' or by id."; pin toContain('Nothing was written') at …test.ts:201. No "ending" residue in any carrier.

F2 — closed, accurately. hook.zod.ts:562 (admitted shape "…AND is assigned IN PLACE (ctx.input.data.customized = true)"), :567-568 ("assigned IN PLACE"), :570-575 (replacement ⇒ "the recording yields nothing and the comparison is SKIPPED — the batch is not judged at all, rather than judged and passed"), :586-593 (third non-admitted shape: row-conditioned REPLACEMENT). Each sentence checked against packages/objectql/src/engine.ts @ 439331895 dispatchPerRowBeforeHooks: one recorder armed over the batch payload :3294-3299; per-row closeWindow() :3331; seal(batchCtx.input.data) :3353; comparison only under if (sealedLoopWrites?.hookWrittenKeys !== undefined) :3372; and hook-write-provenance.ts:224-231 returns no hookWrittenKeys iff current !== payload (i.e. a hook replaced ctx.input.data). Pinned end-to-end: multi-update-hook-key-divergence.test.ts:375-397 (row-conditioned replacement → one updateMany, no refusal) and hook-write-provenance.test.ts:113-126. Mirrors consistent: bulk-write-hook-conformance.ts:74-84 (D3) and :168-173 (residual note now names both blind spots); anchor invariant ("ABSTAINS … when a hook REPLACES ctx.input.data"); ADR II.3 :466-474, :489-493. The three ruled-on stamps do assign in place (email-template-provenance.ts:91, sharing-rule-provenance.ts:80, webhook-provenance.ts:82, each on data = ctx?.input?.data).

Clause-② · claim · --pair

  • Reading unchanged: yes (conformance limb — a previously out-of-contract shape declared permitted). Claim 5610325974 yes; PR body line 3 Clause-②: yes; newest Claim: names branch claude/issue-16074-hook-previous-row-invariant-rewrite = head.ref; no later Claim:.
  • needs:contract-review present on both carriers (re-hung 01:02Z, 5611077817). check-clause2-carriers.mjs --pair 17249 from the byte-identical mirror → exit 0 ("readable in the fixed spelling and both carriers agree"; token present, 3 reads).
  • check-governed-merges.mjs --test over all 5 paths → raw exit 3, GOVERNED (docs/adr/** ×1; worktree script blob 081df968d = origin/main). Human merge only; no ready flip, enqueue, or auto-merge.
  • Changeset @objectstack/spec: minor stands: ADR and anchor sit in no files[]; the published carriers moved prose only; HookContextSchema public face byte-identical. Check Changeset green ×2. Fixes #16074 correct; no other closing keyword adjoins an issue number.

CI on head 541636ebbf

40 raw check-runs (push 02:34Z + label event 02:42Z); newest per name: 0 red. Lint & Repo Gates success (02:59:05Z — carries check:adr-anchors, check-adr-symbol-anchors, governed-merges/prose, changeset gates, check:generated --reconcile-only); TypeScript Type Check + 4 sub-jobs success; Test Core rollup + 6/6 shards success(4/6) green on this head; #17121 closed 03:45Z; Build Core, Governed Surface Queue Guard, Dogfood ×4, Temporal Conformance, Spec property liveness, link/size/claim/Part-of guards all success. Skipped: Build Docs, Console Pin Gate, Packed-tarball, and the label-event re-triggers of Auto Label / Check PR Size. The full 15-artifact check:generated is not a CI job (CI runs reconcile + skill-docs/meta-url-spelling/spec-changes/upgrade-guide); the dev's local reading after build was exit 0 at 5e7e22768 and 541636ebb, and round 4 touched no spec TS — accepted as measured.

Remaining findings (none blocking)

  1. Scope beyond F1/F2 — ADR-0058 Amendment II.3 (docs/adr/**). Contradicts the director's F4 recommendation (option A) but was ruled C by the seat before the verdict landed, with the disagreement recorded openly (5611077817) and F4 marked non-blocking. Content is a faithful record of ruling 5560086928 (quote byte-identical incl. double space; verified issue_url = hook.zod.ts says per-row previous on 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, 2026-09-06T15:02:20Z); max line 79 chars (my earlier 80 was em-dash bytes). Consequence is the governed landing path only. If the director or maintainer prefers A, d6711fcff + the II.3 hunks of the later commits are separable.
  2. "Assigned IN PLACE" exceeds the ruling's literal words. I concur it is an entailment, not a new decision: the ruling's own safety claim ("refused on divergence") holds only under in-place assignment, the three stamps assign in place, and F2 demanded exactly this clause. The maintainer sees it in the governed diff; flag it in the handover rather than the decision box.
  3. ACCEPT-time follow-ups not yet filed. F3 (objectql docblock drift — now engine.ts:3237-3240 on 439331895, bulk-write-per-row-hooks.test.ts:570) and F5 (formulas.mdx:204, data-flow.mdx:311) — no issue created since 2026-09-09T20:00Z mentions hook.zod.ts says per-row previous on 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/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 except [Decision] 批量创建把作者声明的行级 check 整个跳过,违规行照落 —— 修法方向已定,要裁的是「失败语义」 #17297 (unrelated) and AGENTS.md declares the agent commit trailer pair model-free and nothing enforces it — 18 commits on 5 open PR branches carry a model identifier, all with green CI #17280 (trailer census). Seat lists both as "still owed at ACCEPT". F4 is moot (folded in).
  4. PR-body footer is the bare comment form; AGENTS.md:424-425 wants the session-URL form for PR bodies. Cosmetic; seat's body pass.
  5. Neighbour PR fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (#16344) #17195 (open draft, fix(objectql)!, touches hook.zod.ts): whichever lands second re-reads the D3 bullet. Seat already noted.
  6. Informational: commits d6711fcff/5e7e22768 carry Co-Authored-By: Claude Opus 5 while the claim states claude-fable-5-1; per contract-review.md the trailer is a signature constant, not tier evidence (already censused on AGENTS.md declares the agent commit trailer pair model-free and nothing enforces it — 18 commits on 5 open PR branches carry a model identifier, all with green CI #17280, this branch 3/3).

Acceptance notes

  • Implemented-by: mode:subagent os-dev on branch claude/issue-16074-hook-previous-row-invariant-rewrite under domain:spec seat session_01MkQhmuuJAVDjmeWNixwDDH (os-bill; newest Claim: 5610325974; rounds 2–4 pushed under the same claim).
  • Reviewed-by: director seat session_017Js5kTpTtxieBjPyScgxJ3 (this isolated cross-seat re-review; tier verification is the parent's transcript read). Distinct sessions ⇒ not SELF-REVIEW. The in-seat delta review 5611459907 self-declared same-session as the implementer and is seat-side only; this is the independent reading.
  • Landing: on adoption, the seat strips needs:contract-review from both carriers in one stroke with a provenance comment citing this PASS; PR stays draft for maintainer human merge (governed); file F3 and F5 cards at ACCEPT.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Clear-and-hold provenance — director seat, summon #18 segment 6 (session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T05:03:04Z). Clearing needs:contract-review on both carriers (#17249 + card #16074) on the strength of the contract-review-tier PASS WITH FINDINGS (delta re-review) at #17249 (comment) (head 541636ebbf, unchanged). ② --pair 17249 exit 0; ③ 40 check-runs on head, 0 red. ⛔ Governed surface: check-governed-merges.mjs --test exit 3 — the delta adds ADR-0058 Amendment II.3 under docs/adr/** (seat ruling 5610769499, option C) — on the governed register (raw exit 3). Per Prime Directive #14 this PR stays draft-only for the maintainer's hand merge; ⛔ no ready flip, no queue entry from this seat. Listed under 待维护者 in the segment-6 brief on #12708.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 10, 2026 12:47
@os-zhuang
os-zhuang requested a review from hotlong as a code owner September 10, 2026 12:47
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 10, 2026
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 protocol:data size/m tooling

Projects

None yet

4 participants