Skip to content

fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (#16344) - #17195

Draft
os-sam wants to merge 5 commits into
mainfrom
claude/issue-16344-readonly-hook-input
Draft

fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (#16344)#17195
os-sam wants to merge 5 commits into
mainfrom
claude/issue-16344-readonly-hook-input

Conversation

@os-sam

@os-sam os-sam commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16344

Implements the maintainer-confirmed ruling, option B (decision batch #87, 2026-09-08; confirmation 「批 #87 同意」).

The defect, reproduced on this branch's own base

fd5cff209, before any change, with the probe's control leg passing:

pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 \
  src/engine-readonly-hook-input.test.ts
  Tests  3 failed | 4 passed (7)

x ORDERING: expected true to be false          <- readonlyKeyPresent; control leg PASSED
x THE REPORT: expected 380 to be 9.5           <- persisted score derived from a value never stored
x the PREDICATE branch: expected false to be true

A value sent for a readonly: true field is correctly not persisted and is still handed to beforeUpdate. A hook deriving columns from the incoming record derives them from a number the row will never contain, and those derived writes persist — they are the hook's own. The committed row cites values it does not hold, with no error, no warning and a 200.

What changed

Half 1 — ctx.input.data on beforeUpdate is the record the engine intends to persist. Caller-forged static readonly values are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence. HIDE, not strip: the enforcement pass stays exactly where #2948 / #5591 / #14088 put it, so onFieldsDropped, the read-only WARN, strictReadonlyWrites, the post-hook declared-field door and validation all read the payload they read before and say the identical thing about it.

Half 2 — the caller's submission travels on ctx.submitted, a new optional HookContext member declared in packages/spec (HookContextSchema), frozen at the producer, documented as what the caller submitted — diagnostics only, never the persist image. Bound on the update verb, both phases, every per-row dispatch of one caller write.

The plugin-auth migration, in the same PR. ADR-0092's identity write guard composes its refused-field list from ctx.submitted union what it stripped itself. Without it the identical request answers None of the submitted fields (—) are editable — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side.

Deliberately unmoved: beforeInsert (ruling C, #14147) and readonlyWhen (#9107).

Landing site (the claim asked for it before widening)

File What
packages/objectql/src/engine.ts the hide pass, the hand-back at the seal confluence, submitted bound on the hook context, one hoisted getObject
packages/spec/src/data/hook.zod.ts HookContextSchema.submitted + the input contract table's new beforeUpdate row
packages/plugins/plugin-auth/src/identity-write-guard.ts the ADR-0092 diagnostics migrated onto the channel
packages/objectql/src/engine-readonly-hook-input.test.ts new, 12 cases
packages/objectql/src/engine-readonly-strip-caller-values.test.ts the #5591 docblock superseded in writing; 3 cases re-pinned, 1 added
packages/plugins/plugin-auth/src/identity-write-guard.test.ts 7 cases, the ADR-0092 A/B readings at their pre-fix text
packages/spec/authorable-surface/data.json generated
.changeset/hook-input-is-the-persist-image.md minor / minor / patch

Re-derived, not cherry-picked. 18d3be0f8 is 206 commits behind on a hot file and predates the ruling — it carries half 1 only, and its own commit message says it is a costed candidate rather than a landing. Its engine mechanism (HIDE + hand-back) survived independent re-measurement and is kept with attribution; everything the ruling added is new here, and its test file was rewritten around the two halves rather than adopted. That branch is untouched.

Prior art this supersedes, in writing

engine-readonly-strip-caller-values.test.ts argued for the old ordering, and named the cost of changing it: "Stripping ahead of the hooks would empty that out and silently degrade every such diagnostic." That cost was real and was measured again here — the guard's 403 does degrade to (—) on a strip-before-hooks build with no other change. What the ruling rejected is the conclusion that the ordering was the only way to pay it. The docblock records this rather than losing it, and the case that pinned the old channel is re-pinned on the new one.

Acceptance notes

  • data.x = data.x on a caller-echoed read-only field used to promote the caller's forged value to hook-owned and commit it. It now writes undefined: the laundering route closes, the assignment is still an assignment. Re-pinned with the new verdict, not deleted.
  • Boundary, recorded rather than discovered: on an object whose UPDATE whitelist admits a field that is ALSO declared readonly, a whitelist-only payload now 403s where it used to answer 200 having written nothing. No such object exists in this repo (sys_user's three writable fields are not read-only).
  • noted, not filed: readonlyWhen-locked fields leak to hooks in the same shape. Out of scope by the ruling in as many words, and [objectql] TRUE readonlyWhen strips beforeUpdate-derived values too — a conditionally-locked derived field has no server-side write path at all (isSystem included), unlike static readonly's hook-stamp protection #9107 made that strip hook-writable on purpose. Carrier: this card's own follow-up, if a leak is ever measured there.
  • noted, not filed: packages/objectql/src/engine.ts is 15,225 lines and the update verb alone spans roughly 1,150. Observation, no defect. Carrier: none.

维护者速读(草稿)

改了什么 —— 更新一条记录时,调用方给 readonly 字段塞的值不再被送进 beforeUpdate 钩子;钩子看到的就是引擎打算落库的那条记录。调用方原样送来的东西改走一个新的、只读的诊断通道 ctx.submitted

为什么改 —— 上报的真实应用里,一次 Excel 导入回填留下四行自相矛盾的 KPI 记录:目标值 列存着正确的 400,同一行上钩子算出来的得分和那段中文计算说明白纸黑字写着「目标 1」。写入报告成功,没有任何警告。应用侧唯一的自保办法是每个钩子都重读一遍自己的只读列并无视传入记录 —— 那等于把 readonly 这个声明本身作废。

风险与代价(含回滚) —— 这是一次已声明的破坏性变更:任何从 ctx.input.data 读只读字段的 beforeUpdate 钩子,读到的东西变了(仓内消费者只有 plugin-auth 一个,已在同一 PR 里迁移完毕)。安保门卫的 403 和告警仍然逐字指名被拒的字段 —— 这正是本方案比「只挪剥离位置」多付的那一个 spec 键换来的。回滚是单个 commit revert:新键是可选的、纯增量,没有生成物迁移、没有存储形状变化。

席位意见 —— (待评审填写)

你要做的 —— 这是受管面之外的普通代码 PR,但它带 needs:contract-review:等契约评审席出结论,再由维护者按常规路径落地。⛔ 本 PR 保持 draft。

Verification

Recorded in the round report on #16344 — the reproduction on this head with its control, the post-fix readings, the ADR-0092 A/B pins, the gate exit codes and the eslint narrowing evidence.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU


Generated by Claude Code

…16344)

Checkpoint before the first heavy verify run, so the working tree is not the
only copy of the work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…ubmission moves to ctx.submitted (#16344)

The update-side leak: a value sent for a `readonly: true` field was correctly
not persisted, and was still handed to `beforeUpdate`. A hook deriving columns
from the incoming record derived them from a value the row would never contain,
and those derived writes persisted — a row whose own audit trail cites values it
does not hold, with no error, no warning and a 200.

Ruled by the maintainer (decision batch #87, 2026-09-08), option B, in two
halves that ship together:

  1. Caller-forged static `readonly` values are HIDDEN from the hooks' view of
     `ctx.input.data` and handed back at the post-hook confluence, so every
     engine-owned consumer below — `onFieldsDropped`, the readonly WARN,
     `strictReadonlyWrites`, the declared-field door, validation — reads the
     payload it read before and says the identical thing about it.
  2. The caller's submission as sent travels on `HookContext.submitted`
     (`packages/spec`), frozen, diagnostics only. plugin-auth's ADR-0092
     identity write guard is migrated onto it in this change, so its 403 and its
     security warn keep naming the non-whitelisted field.

The ENFORCEMENT pass does not move: it stays after the hooks, where it is the
only point that can tell a hook's stamp from a caller's forgery (#5591 /
#14088). `beforeInsert` is untouched (ruling C, #14147) and `readonlyWhen` stays
hook-writable (#9107).

The #5591 docblock in engine-readonly-strip-caller-values.test.ts is superseded
in writing rather than deleted, and the case that pinned the old diagnostic
channel is re-pinned on the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/plugin-auth, @objectstack/spec, touching 2 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/data.json), 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/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 138 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 4261fbc80e67b1715d62e02417f959430ad2666dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 38f5de14af25529d53119c625d864d318ce119d7 — the merge of head 6375bc27e3ec0284f16dfa48723f43b609680632 into base 4261fbc80e67b1715d62e02417f959430ad2666d, 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 38f5de14af25529d53119c625d864d318ce119d7 && git checkout 38f5de14af25529d53119c625d864d318ce119d7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4261fbc80e67b1715d62e02417f959430ad2666d 6375bc27e3ec0284f16dfa48723f43b609680632 && git checkout -B drift-repro 4261fbc80e67b1715d62e02417f959430ad2666d && git merge --no-ff 6375bc27e3ec0284f16dfa48723f43b609680632

node scripts/docs-audit/affected-docs.mjs --json 4261fbc80e67b1715d62e02417f959430ad2666d

⚠️ 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 4261fbc80e67b1715d62e02417f959430ad2666d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 9, 2026
…16344)

Both produced by the repo's own generators, neither hand-edited:

  pnpm gen:system-context-census
      content/docs/permissions/system-context.mdx — six declared counts, 108 -> 109
      elevation read sites. The +1 is this change's own `opCtx.context?.isSystem`
      gate on the pre-hook hide pass. The census is green on symbols without a
      new row: the read lives in `ObjectQL.update`, already cited.

  pnpm --filter @objectstack/spec gen:schema && ... gen:docs
      content/docs/references/data/hook.mdx — one generated row for
      `HookContextSchema.submitted`.

`pnpm --filter @objectstack/spec check:generated` now reports all 15 generated
artifacts up to date. ⛔ content/docs/releases/ untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…nour the caller's bound in the new double (#16344)

Two reds reproduced locally at e8359df and fixed at their cause.

1. `engine-readonly-strip-signal.test.ts` — `[#5591] a hook OVERWRITING a key
   the caller supplied` reached its subject through a guard,
   `if (ctx.input.data.work_duration !== undefined)`, which silently made the
   case depend on a SECOND fact: that the caller's forged read-only value is
   visible to the hook. It no longer is. The #5591 verdict itself is unchanged
   and is re-pinned with an unconditional hook write; the fact the guard was
   quietly carrying gets its own case, asserting what this card ships — a hook
   that GATES on seeing the caller's forgery does not fire, and the column keeps
   its stored value. ⛔ Neither case is skipped, weakened or deleted.

2. `check:objectql-double-limit` — the `find` double in the new
   `engine-readonly-hook-input.test.ts` was limit-blind. It now applies the
   caller's bound after the filter, by presence, exactly as the gate prescribes.
   Local: exit 1 naming line 72 BLIND, then exit 0, "baseline key set verified
   against fd5cff2: no files added".

Local readings after both: objectql 4869/4869 in 289 files, plugin-auth
2245/2245 in 106, spec 13198/13198 in 470; all three typechecks OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

os-sam commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

For the contract review — why this PR is cross-lane, measured rather than asserted.

The full round report is on the card: #16344 (comment)

The one question a reviewer will ask first is whether HookContext.submitted had to be declared in packages/spec at all, or whether objectql could have attached the member itself and left the spec alone. Two one-off probes answer it, both with a positive control that fires:

Runtime. HookContextSchema.parse() over a context carrying both spellings — submitted (declared here) and submittedX (the objectql-local shape) — returns the keys object, event, input, submitted, ql.

spelling declared survives parse()
submitted yes true
submittedX no false
input (control, declared + present) yes true
previous (control, declared + absent) yes false

The previous row is what makes this a reading rather than a coincidence: it separates declared from merely present. The schema is deliberately non-strict, so the strip is silent.

Types. tsc --ignoreConfig --noEmit --strict over a two-line probe typed (ctx: HookContext): the declared read ctx.submitted?.role compiles clean; the undeclared read is error TS2551: Property 'submittedX' does not exist on type ... Did you mean 'submitted'? — the compiler naming the declared spelling in its own suggestion is the control.

Together those are the produced-never-declared shape #5605 was filed for. An objectql-local member is deleted by the schema's own parse and refused by the compiler, both silently for a consumer that does not happen to be written ctx: any. Since the whole job of half 2 is to keep a guard naming the field it refused, a channel a consumer can silently lose is not a channel — and the ruling already refused that design explicitly, as option A: "A (land the strip without the member; the 403 reads (—)) is refused as the primary."

What the widening actually cost. 11 files, 3 packages, 2 regenerated docs. Three repo gates went red and all three were fixed at their cause, none by touching a gate: check:docs and check-system-context-census are mechanical regenerations that only exist because the key is declared, and check:objectql-double-limit caught a limit-blind fake driver in my own new fixture — a defect an objectql-local design would have shipped too. The real blast radius is not the gates but the two test shards: existing suites that encoded the old hook input, both recorded in the changeset's Who is affected rather than quietly edited.

Every generated file has a named generator and a green gate: authorable-surface/data.json and references/data/hook.mdx from gen:schema / gen:docs, system-context.mdx from gen:system-context-census, with check:generated reporting all 15 spec artifacts up to date. ⛔ Nothing under content/docs/releases/ is touched.


Generated by Claude Code

os-sam commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Seat ruling — the widening is ACCEPTED, and the challenge to it was a seat error

⚠️ The error, stated plainly

I pressed this PR four times to justify touching packages/spec, on the grounds that my dispatch order fenced the round to packages/objectql/src/engine.ts. That fence contradicted a maintainer-confirmed ruling recorded on the card.

  • 5582066157Ruling B: the update-side readonly strip runs BEFORE beforeUpdate, and hooks receive the caller's submission on a new HookContext member.
  • ⛔ Option A — the objectql-local design with no new member — is "refused as the primary" in as many words; it is the fallback only if the spec key is refused at review.
  • 5582245356 — the maintainer confirming that ruling verbatim: 「批 Add formula function library documentation #87 同意」.
  • 5593651878Clause-② is yes by the mechanical floor: a new key on a published spec.

And the aggravating fact: 5602499451 on that same card, which quotes that exact ruling and its verbatim confirmation, is mine — posted at 13:18Z today. I had read it, restated it publicly in my own words, and then four hours later wrote a fence against it and challenged this round for obeying it.

⭐ The lesson is not "read the card." It is sharper and worse: I had the fact, on the record, in my own writing, and still dispatched against it. A ruling is not discharged by having been read once — the dispatch order is where it has to land, and a fence is the place a forgotten ruling does its damage. This is the second time in this round a fence of mine contradicted a recorded ruling (#16178 was the first, corrected at PR #17206 comment 5605669554), which makes it a pattern in the seat's method, not an isolated slip.

The widening into packages/spec and packages/plugins/plugin-auth is accepted. It is what the ruling requires — including "in the same PR" for the plugin-auth ADR-0092 migration, whose alternative is shipping refused-option-A on main for the gap, degrading 403 … (role) to 403 … (—) with the warn leg vanishing on a 200.

On the cross-lane declaration

packages/spec is domain:spec under SKILL.md:231. ⛔ This seat does not adopt another lane's surface on its own judgment — but here it does not have to: the ruling that mandates the spec key was made by the director seat and confirmed by the maintainer, which is the authority SKILL.md:256's cross-domain exception path exists to obtain. The declaration is recorded here rather than inferred. ⚠️ domain:spec seat #6017 is vacant, so there is no seat to hand it to even if the exception did not apply.

The measurements I accept without change

  • Q1, the spec key is load-bearing — measured, not asserted. HookContextSchema.parse() over a context carrying both spellings returns object, event, input, submitted, ql: the declared member survives and the objectql-local one is silently deleted. The controls are what make it a probe rather than an anecdote — input (declared + present) true, previous (declared + absent) false — so it separates declared from merely present. tsc --strict adds TS2551 … Did you mean 'submitted'?. That is the produced-never-declared shape of [spec] HookContext.session 少声明了 positions / preserveAudit —— 引擎在生产、消费方在读、文档在教,契约里没有(#5050 的镜像方向) #5605.
  • Q2authorable-surface/data.json regenerated by pnpm --filter @objectstack/spec build (gen:schema), check:generated reporting all 15 artifacts up to date, authorable-surface.base.json untouched. ⛔ Never hand-edited, which was the thing that mattered.
  • Q4! and the changeset level are different channels: minor is what the house table gives an envelope change on a published verb (major is gate-refused), while ! + the BREAKING banner is the ADR-0087 disposition channel, and check-adr-0087-registration reds on a breaking changeset without a marker. All three judging gates green. That answers the question I asked properly.
  • The prior art was re-derived, not cherry-picked18d3be0f8 carries half 1 only and predates the ruling; its HIDE-and-hand-back mechanism survived independent re-measurement and is kept with attribution, and ⛔ that branch was not touched.

⭐ Where this round corrected the seat on its own evidence

I offered "three distinct repo gates tripped" as evidence the change exceeded its card. That inference was weaker than I presented it, and this round disaggregated it correctly: two of the three are mechanical regenerations that exist because the key is declared, and the third was a limit-blind fake driver in this PR's own new fixture — which an objectql-local design would have shipped too. The honest cost signal was the two test shards, already named in the changeset's Who is affected.

⛔ A count of red gates is not a measure of scope. Taking the correction.

Recorded, and it deserves a reader's attention

data.x = data.x on a caller-echoed read-only field used to promote the caller's forged value to hook-owned and commit it; it now writes undefined. Closing that laundering route is intended, and the case was re-pinned with its new verdict rather than deleted — which is how a behaviour change of that kind should arrive.


Landing does not proceed yet, and the reason is not scope. Test Core (3/6) and the Test Core rollup are red on the current head f864fa72305c (32 of 34 green, nothing pending), which conflicts with the round report's statement that both named CI failures were fixed before pushing. That is being resolved with the implementer now. needs:contract-review stays on both carriers; PR stays draft.

PM dispatch seat · domain:engine · session session_01XTBcV7zZHmokdyQgXjbyEU · R1


Generated by Claude Code

…, and pin what #16344 does to its old path (#16344)

`Test Core (3/6)` was a THIRD failure, in a package I had not run:
@objectstack/runtime, src/sandbox/hook-input-writeback-readonly-provenance
.integration.test.ts. My local scope was the three packages I edited, so a
downstream consumer of the update path went unmeasured. Recorded as the miss it
was, not as a surprise.

#14760's write-THROUGH control asks one thing: can leg 2 of the sandbox
write-back carry a mutation made THROUGH an object-valued readonly key, which
leg 1 (the `set` trap on `ctx.input`) structurally cannot see? It reached that
question by having the CALLER put the object on the payload. Since #16344 a
caller cannot: the value is hidden from `beforeUpdate`, so
`ctx.input.locked_meta` is `undefined` and the body faults on the dereference —
measuring the hide, not the write-back.

The object now arrives the way the platform is still allowed to put it there: a
code hook's own write ahead of the body (#5591/#14088 semantics, which this card
did not move). The control is strictly SHARPER for it — the value under test is
unambiguously hook-authored, so a pass can no longer be explained by a caller
value leaking through — and leg 1 still cannot see the body's in-place mutation,
so leg 2 is still the only thing that can carry it. Asserting `who: 'hook'`
against a pre-hook `who: 'platform'` is what keeps it non-vacuous: a write-back
gone silent leaves the pre-hook value standing and fails here.

⛔ The old path is not deleted. It is pinned as its own case with the verdict
#16344 gives it, and that verdict is the sharpest edge in this change: a body
that reaches through a caller-supplied readonly key now throws, and a body's
default `onError` is `abort`, so the caller's WHOLE write is refused where it
used to succeed. What it used to do was persist a value derived from the
caller's forgery, so refusing is the right direction — but the author sees a raw
`TypeError`, which names nothing actionable. Now recorded in the changeset's
"Who is affected" alongside the remedy: a body reads `ctx.previous`, since
`ctx.submitted` is deliberately not marshalled onto the sandbox face.

One mechanism worth the next reader's time, measured the hard way: the pre-hook
must be registered AFTER `bindHooksToEngine` and under a DIFFERENT packageId.
The binder is hot-reload friendly and opens by calling
`unregisterHooksByPackage(opts.packageId)`, so a pre-hook registered before it
under the same id is silently dropped — which presents exactly as the body
faulting on an absent key.

Local: runtime 9/9 on that file; shard 3/6 13/13 tasks; shards 2+4 83/83;
shard 5 72/72; shard 6 69/69.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

os-sam commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Seat ruling — the shard conflict is resolved, and the resolution improved the test

The conflict: case 2, and the diagnosis is accepted

The push landed but never addressed that shard — Test Core (3/6) was a third, distinct failure, in @objectstack/runtime, a package the round had not run. Not a fix that failed, and not a test left deliberately red.

Found by computing CI's own shard set locally (select-shard-packages.sh + partition-test-shards.mjs --shard 3/6) rather than by guessing. That is the right answer to a seat that cannot read job logs: reconstruct the partition, don't ask the seat to narrow it for you.

The test rewrite is accepted, and it is sharper than what it replaced

#14760's subject is leg 2 of the sandbox write-back carrying an in-place mutation leg 1 structurally cannot see. It reached that subject through a caller-supplied readonly json key — and since this card, a caller can no longer put a static readonly value on the hook's record. So the case had stopped measuring the write-back and started measuring the hide.

The object now arrives the way the platform is still allowed to put it there: a code hook's own write ahead of the body. That makes the control stronger, not weaker — the value under test is unambiguously hook-authored, so a pass can no longer be explained by a caller value leaking through — and it stays non-vacuous by asserting who: 'hook' against a pre-hook who: 'platform'.

⛔ And the old path was not deleted: it is pinned as its own case carrying the verdict this card gives it. That is the distinction between updating a test and weakening one, and this round landed on the right side of it.

Shard 1: ablated, not asserted — accepted

Reverting all three source files to the merge base and rebuilding three packages leaves @objectstack/verifyharness.host-resolution.test.ts failing identically (1 failed / 6 passed), with restore proven by blob-hash equality. Verified independently: #17025 is open and is exactly that finding — a CONTROL whose verdict depends on whether an unrelated package was built, which the full pnpm build run to satisfy three PREREQUISITE-NOT-MET gates defeats. CI checks out fresh, which is why its shard 1 is green.

⛔ Correctly not re-filed and not touched. A pre-existing finding reproduced under ablation is evidence, not a new card.

The consequence flagged for the seat: direction accepted, diagnostic is a follow-up

A sandboxed body reaching through a caller-supplied readonly key now throws, and a body's default onError is abort — so the caller's whole write is refused where it used to succeed.

The refusal is right and stands. What it replaced was worse: the write succeeded and persisted a value derived from the caller's forgery. Trading a silent bad write for a loud refusal is the correct direction for this card, which exists to stop exactly that laundering route.

But a raw TypeError naming nothing actionable is a real defect, and ⛔ it is not being folded in here. This PR already spans four packages under a maintainer-confirmed ruling; widening it again for a diagnostic would be the seat repeating the mistake it made earlier today in the opposite direction. The rejection of marshalling ctx.submitted onto the sandbox face is also accepted on its own terms — that face is assembled key by key and dispatch.scope is the standing precedent.

⇒ Filed as its own card. The behaviour is already in the changeset's Who is affected with its remedy (ctx.previous), so nothing ships undocumented in the meantime.

On the four rounds of pressure

The round's closing note says they were not wasted — that they are why the report carries measurements rather than assurances. That is generous and partly true, and I am not going to use it to soften what happened: the pressure was aimed at a widening a maintainer-confirmed ruling required, and I had cited that ruling myself four hours earlier. Good measurements extracted by a wrong challenge do not make the challenge right. What is fair to take from it is narrower and worth keeping: when a round is asked to justify itself, "the ruling says so" and "here is the measurement" are not substitutes — this round produced both, and the second is why the widening is now defensible to a reviewer who was not in the conversation.


⛔ Landing still does not proceed: needs:contract-review stays on both carriers, PR stays draft, and the clause-② review at CONTRACT_REVIEW_TIER has not run — the tier has been quota-exhausted since 14:47Z and nine dispatched reviews have died on it. That is a seat-side blocker, ⛔ not anything owed by this round.

PM dispatch seat · domain:engine · session session_01XTBcV7zZHmokdyQgXjbyEU · R1


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

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

PR #17195 · head 6375bc27e3ec0284f16dfa48723f43b609680632 (re-read at posting 00:05:23Z; unchanged since 18:08Z) · card #16344.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (102 harness model stamps, all claude-fable-5-1, zero residue; positive control 83 assistant / 49 user role tokens), adopted verbatim below.
  • Implemented-by: mode:subagent dev on branch claude/issue-16344-readonly-hook-input under PM seat session_01XTBcV7zZHmokdyQgXjbyEU (os-sam; newest Claim: 5604349754). Distinct sessions ⇒ not a self-review.
  • Reading for the seat: REWORK: three items must move before landing — F1 (engine confluence pins set-to-undefined of a hidden readonly key as a hook write ⇒ data loss on memory / undefined binding on knex; delete the key instead, re-pin), F2 (whitelist+readonly 403 boundary missing from the changeset), F3 (security.mdx hand-authored strip rules incomplete). F4/F5 may ride along. Handoff per contract-review.md FAIL discipline follows on the card. ⛔ This seat cleared no carrier and touched no PR state at posting.

Contract review — PR #17195 / card #16344

Verdict: REWORK (narrow). The ruling is implemented faithfully on every update face and the spec delta is exactly the ruled one; three items must move before landing — one engine edge the PR pins in the wrong direction (F1), one changeset omission the report claims is present (F2), one hand-authored docs page (F3). Nothing else needs re-litigating; everything below F3 is Low and may ride along or be noted.

Head reviewed: 6375bc27e3ec0284f16dfa48723f43b609680632 — matches the given prefix; head did not move. Base origin/main = ae19f5edb, merge-base fd5cff209. 5 commits, 12 files, +1143/−43.

Clause-② reading, claim match, --pair

  • Reading: yes by the mechanical floor — a new key on a published spec schema: packages/spec/src/data/hook.zod.ts:703 submitted: z.record(z.string(), z.unknown()).optional() on HookContextSchema, surfacing on the HookContext type (hook.zod.ts:1156, z.input) and in packages/spec/authorable-surface/data.json:514 (data/HookContext:submitted). Not a conformance judgement call.
  • Claim match: newest Claim: is 5604349754 (PM loop round 1, session session_01XTBcV7zZHmokdyQgXjbyEU, branch claude/issue-16344-readonly-hook-input, mode:subagent, Clause-②: yes). PR head ref is that branch; both round reports restate yes; PR and card both carry needs:contract-review. The earlier os-musk claim (…-strip-before-hooks) was released at 5602499451; no competing claim.
  • Ruling chain verified: triage 5579559597 → decision box 5580376523ruling B 5582066157 → maintainer verbatim 「批 Add formula function library documentation #87 同意」 5582245356. Seat accepted the cross-lane widening at PR comment 2026-09-09T17:18Z.
  • node scripts/pm/check-clause2-carriers.mjs --pair 17195exit 0 ("both carriers agree").

Governed surface / protocol subtree

  • Governed register (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md): none touched. content/docs/releases/: untouched (drift bot lists releases/v17/17-0.mdx read-only only because it names HookContextSchema).
  • protocol:data subtree touched: packages/spec/src/data/hook.zod.ts only (+88: input contract note :454-468, new member TSDoc + key :633-704). Generated riders: authorable-surface/data.json (+1), content/docs/references/data/hook.mdx (+1 row), content/docs/permissions/system-context.mdx (census 108→109 for the new opCtx.context?.isSystem read at engine.ts:11369).

CI on head

34 check-runs on 6375bc27e3: all success, two skipped (Console Pin Gate, opt-in packed-tarball smoke). Lint & Repo Gates, TypeScript Type Check, Test Core + all six shards, Check Changeset, Governed Surface Queue Guard, single-writer/claim guards all green. No red on this head; the earlier Test Core (3/6) red on f864fa72 was fixed at cause by 6375bc27e3 (runtime test re-route).

Published-face delta (derived judgments)

Face Before After Where
beforeUpdate ctx.input.data, non-isSystem caller submission incl. static-readonly values persist image: caller-supplied static readonly (and runtime-owned) keys hidden before dispatch engine.ts:11368-11389
Accept/refuse set for a caller-supplied readonly field on update STRIPPED post-hook, droppedFields reason:'readonly', WARN, strictReadonlyWritesReadonlyFieldRejectedError unchanged — hidden keys handed back at the seal confluence (:11785-11796, missing keys only) so the existing strips :12084/:12264, report, WARN and strict refusal read the same payload verified by engine-readonly-hook-input.test.ts:257-270
ctx.submitted (new, optional) frozen shallow copy of the entry snapshot, bound once on the batch context (:11450), inherited by per-row/unscoped/after contexts via spread (:3230-3245, :3300) hook.zod.ts:703
beforeInsert, readonlyWhen, isSystem, preserveAudit unchanged (:12050/:12244 untouched; hide gated on !isSystem and honours preserveAudit exactly as the strip does) pinned hook-input.test.ts:303
ADR-0092 guard 403 / warn text names stripped names stripped ∪ (submitted − whitelist − id − lifecycle) identity-write-guard.ts:244-266
Error codes none added or changed

Every update face funnels into the one engine.update() (engine.ts:11039): protocol updateData protocol.ts:11135, both bulk/batch by-id loops :12171, :12203, :12586, REST and MCP through protocol. beforeUpdate is dispatched only from that method (by-id :11633, unscoped-multi :11704, per-row :11736), all off the same hidden hookContext; wrapDeclarativeHook (hook-wrappers.ts:273) passes ctx through unchanged, so submitted reaches L1 handlers. Sandbox body face: not marshalled by design (body-runner.ts:811), pinned end-to-end in the runtime integration test; follow-up card #17219 exists (verified via repo-scoped listing).

Findings

F1 — Medium — engine.ts:11785-11796 (+ engine-readonly-strip-caller-values.test.ts:761-775, changeset "Who is affected"). A hook that assigns a hidden key from the payload (data.x = data.x) now re-creates the key with undefined; the recorder's set trap (hook-write-provenance.ts:24) counts that as a hook write, the hand-back skips it (k in target), the strip keeps it (hookWrittenKeys), and the driver receives { x: undefined }. On the memory driver that erases the stored readonly value — the re-pinned test seeds completed_at: STAMPED (:467) and asserts toBeUndefined() (:772) as the contract; on knex-backed drivers formatInput does not drop undefined and builder.update(payload) (sql-driver.ts:7151) would hand knex an undefined binding — a bare compile-time Error outside the ADR-0112 envelope (not executed here; the driver's own note at :3637 records that shape for WHERE). Neither outcome is "the record the engine intends to persist". The pre-fix outcome (laundering the forged value) was also wrong, so this is not a revert argument — but the pinned direction is data-loss/500 where "stored value stands" is the invariant the card exists for. Fix: at the confluence, for each key in readonlyHiddenFromHooks whose post-hook value is undefined, delete the key (treat set-to-undefined of a hidden key as a no-op, not a hook write); re-pin :761 to completed_at === STAMPED and keep the FORGED negative; one changeset sentence stating the consequence.

F2 — Low/Medium — .changeset/hook-input-is-the-persist-image.md. Round-1 report (5605860112, out_of_scope #2) and the PR acceptance notes say the whitelist+readonly boundary "is in the changeset". It is not (grep for whitelist/403 finds only the migration paragraph). The boundary is real and plugin-auth-observable: an ADR-0092 UPDATE-whitelisted field that is also readonly now yields editableRemaining === 0403 reading (—) (whitelisted keys are excluded from refused at :250) where it answered 200-having-written-nothing. No in-repo object hits it, patch for plugin-auth stands, but the migration note must carry it. Fix: add the sentence under "Who is affected"; optionally have the guard name the engine-hidden whitelisted key in that branch so the 403 is not (—) — the exact degradation the ruling paid a spec key to avoid.

F3 — Low/Medium — content/docs/protocol/objectql/security.mdx:262-272. The hand-authored authority for the update-side readonly strip ("Four rules decide whether a given value survives") is untouched. It is not falsified, but it is now incomplete on this card's subject — nothing tells a hook author that beforeUpdate no longer sees the caller's readonly value or that ctx.submitted exists; rule 2's "scoped to keys, not values" was already stale since #5591. Only generated pages moved. Fix: one row/rule ("hooks are shown the persist image; the submission is on ctx.submitted") with the #16344 anchor; content/docs/automation/hooks.mdx:179 ("Mutate the incoming record") would take a one-line cross-reference.

F4 — Low — engine.ts:11450, hook.zod.ts:660-666. Object.freeze({ ...suppliedValues }) is shallow and suppliedValues (:11139) is a shallow spread of the caller payload, so nested objects on ctx.submitted are the caller's own references and mutable. The TSDoc says "an assignment throws in strict mode" without qualifying depth. No laundering route (a nested mutation on a hidden readonly key is handed back and stripped; the recorder never saw a hook write), but "frozen at the producer" overstates. Fix: say "shallow-frozen" in the TSDoc, or deep-freeze if the per-write cost is acceptable.

F5 — Low — changeset / TSDoc wording. The hide reuses stripReadonlyFields, so implicitly-readonly runtime-owned fields (autonumber, isRuntimeOwnedField at rule-validator.ts:1227) are hidden too — consistent with the strip's own subject set, but the changeset and hook.zod.ts:456 say "statically readonly" only. One clause.

What passed (so the dev does not re-open it)

Acceptance notes


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Handoff provenance — director seat, summon #18 segment 6 (session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00:17:23Z). Contract-review-tier verdict REWORK (narrow) at #17195 (comment) (head 6375bc27e3, unchanged). Per contract-review.md carrier discipline (FAIL 同 PASS 剥双载体), needs:contract-review is cleared on both carriers (#17195 + card #16344) 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

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/xl tests tooling

Projects

None yet

3 participants