Skip to content

Commit d2c1d19

Browse files
os-samclaude
andauthored
fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (#16344) (#17195)
* wip(objectql): hide caller-forged readonly values from beforeUpdate (#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 * fix(objectql)!: beforeUpdate receives the persist image; the caller submission 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 * chore: regenerate the two artifacts the new HookContext member moves (#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 * test(objectql): re-pin the one suite the new hook input moves, and honour 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 * test(runtime): re-route #14760's write-THROUGH control off the caller, 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 * fix(objectql): set-to-undefined of an engine-hidden readonly key is a no-op, not a hook write (#16344) Contract-review finding F1. A `beforeUpdate` hook that assigns a hidden key from the payload it was shown (`data.x = data.x`) reads `undefined` and re-creates the key holding it. Three mechanisms then agreed the wrong way: the recorder's `set` trap counted it as a hook write, the hand-back skipped the key because `k in target`, and the static strip kept it on that record — so a driver was handed `{ x: undefined }`. On the memory driver that ERASES the stored read-only value; on a knex-backed one `formatInput` does not drop `undefined` and `builder.update(payload)` hands knex an undefined binding — a bare compile-time Error outside the ADR-0112 envelope. Neither is "the record the engine intends to persist". At the confluence the key is now deleted, dropped from the sealed record, and the ordinary hand-back puts the caller's value back for the strip to judge. The write reads exactly as it would with no hook at all: stripped, reported on `onFieldsDropped`, warned, refused under `strictReadonlyWrites`. Dropping the key from `hookWrittenKeys` is load-bearing, not tidiness: handing the caller's value back over a key the record still calls hook-owned would credit the forgery with hook provenance. The narrowing reaches only keys this card's pass hid, and only the one value no driver can store, so #14088's deliberate blindness to VALUE is unchanged for every key a hook can see. The pin moves in both directions: the stored value now STANDS (`completed_at === STAMPED`, the seed), and the FORGED negative stays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * docs(objectql): carry the whitelist+readonly boundary and the persist-image rule where authors read them (#16344) Contract-review findings F2, F3, and F4/F5 riding along. F2 — the changeset now carries the ADR-0092 boundary the round report claimed was already in it. An UPDATE-whitelisted field that is ALSO declared `readonly` now answers 403 where it answered 200-having-written-nothing, and the refusal reads `(—)` because a whitelisted key is excluded from the guard's refused list by design. No in-repo object is on that boundary; `patch` for plugin-auth stands. The self-assignment row is also corrected to the verdict that now holds: a no-op, stored value standing, not a persisted `undefined`. F3 — `content/docs/protocol/objectql/security.mdx` is the hand-authored authority for the update-side strip and said nothing about this card. It gains a fifth rule ("hooks are shown the persist image, not the submission") and a migration callout naming `ctx.submitted`, `ctx.previous`, the self-assignment no-op and the sandbox `body` exclusion. Rule 2's trailing paragraph is corrected while there: "cannot rescue one the caller supplied" has been false since #5591/#14088 — a hook that ASSIGNS a caller-sent key owns the value and the strip keeps that write. `content/docs/automation/hooks.mdx` takes the one-line cross-reference from "Mutate the incoming record". F4 — the `submitted` TSDoc said "FROZEN by the producer" without qualifying depth. It is a shallow spread shallow-frozen, so a nested object reached through a key here is the caller's own mutable reference. Not a laundering route, but not a deep guarantee either, and now it says so. F5 — the hidden set is the update strip's own subject set: author-declared `readonly: true` AND runtime-owned types (`autonumber`, #5503), not "statically readonly" alone. Stated in the changeset and in the `input` contract note. No generated artifact moves: the edited prose is TSDoc, and only the `.describe()` string reaches `authorable-surface/data.json` and `references/data/hook.mdx`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f03f6c7 commit d2c1d19

14 files changed

Lines changed: 1264 additions & 50 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/plugin-auth": patch
5+
---
6+
7+
fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) an enforcement-ORDER change plus one ADDITIVE optional key on a runtime context schema. No authorable key, spelling or stored shape moves, so a stored `sys_metadata` row needs no conversion and an upgrader has nothing to hand-edit. What changes is which image a `beforeUpdate` handler is shown; the remedy for a handler that depended on seeing a refused value is to read `ctx.submitted`, which is a code edit in the handler, not a metadata migration. Nothing is retired: `HookContext.submitted` is new and optional. -->
10+
11+
**BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden.
12+
13+
## The defect
14+
15+
On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own.
16+
17+
Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`:
18+
19+
```
20+
read back: target_value 400 weight 10 ← the strip worked
21+
score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%"
22+
```
23+
24+
The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all.
25+
26+
## What changed
27+
28+
**`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes.
29+
30+
**The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write.
31+
32+
Two things deliberately did **not** move:
33+
34+
- **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088.
35+
- **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107.
36+
37+
`@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration 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 in `identity-write-guard.test.ts`.
38+
39+
Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected.
40+
41+
## Who is affected
42+
43+
A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each:
44+
45+
- **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent.
46+
- **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`.
47+
- **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed.
48+
49+
⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`.
50+
51+
A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way.
52+
53+
⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way.
54+
55+
An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide.

content/docs/automation/hooks.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ record's fields **directly on `ctx.input`** (a flat view over the internal
181181
`{ data, options }` wrapper — reads and writes of record fields route through
182182
`ctx.input.data`):
183183

184+
On **update**, "the incoming record" means *the record the engine intends to
185+
persist* — a caller-supplied value for a `readonly` field is not on it, and the
186+
caller's submission is on `ctx.submitted` instead (diagnostics only). See
187+
[Static `readonly` fields on the write path](/docs/protocol/objectql/security#static-readonly-fields-on-the-write-path)
188+
for the five rules and the migration.
189+
184190
<Callout type="warn" title="`id` / `options` / `ast` / `data` are RESERVED on the flat `ctx.input` face">
185191
These four names always resolve to the envelope, never to a record field —
186192
even if your object declares a field with one of those names. A field named

content/docs/permissions/system-context.mdx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
99
service self-write, a migration.
1010

1111
This page is **the authority** for what that flag actually does. It exists
12-
because the flag is not one concept: it is a single boolean read at **108
12+
because the flag is not one concept: it is a single boolean read at **109
1313
distinct sites across 20 packages**, and knowing three of those behaviours gives
1414
no hint that the other hundred-and-four exist. Every documented app-side bug
1515
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -132,7 +132,7 @@ that silently does not happen.
132132

133133
### 3. Sharing (`plugin-sharing`)
134134

135-
The largest single consumer — **17 of the 108 sites**.
135+
The largest single consumer — **17 of the 109 sites**.
136136

137137
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
138138
|:--|:---|:---|:---|
@@ -278,7 +278,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
278278
independent decisions, and a seed loader plausibly wants the first two but not
279279
the third. The concept is nevertheless **staying as one boolean**:
280280

281-
- **Shipped semantics.** `isSystem` is a published contract with 108 read sites
281+
- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
282282
in 20 packages. Splitting it is a breaking contract change across all of them.
283283
(The ruling was taken when the census read 80 sites in 18 packages; the count
284284
has grown, which strengthens rather than weakens the argument.)
@@ -352,12 +352,12 @@ still holds equal to the census on every pull request:
352352
| Appearances of the bare identifier `isSystem` in non-test sources | 813 ||
353353
| — parsed as a declaration | 23 ||
354354
| — parsed as an object-literal / type key (producers and option objects) | 310 ||
355-
| — parsed as a property **read** | 114 ||
355+
| — parsed as a property **read** | 115 ||
356356
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 ||
357357
| — the remainder: text inside comments and string literals | 358 ||
358358
| Of those reads: reads of one of the unrelated metadata fields | 6 ||
359-
| Of those reads: reads of `ExecutionContext.isSystem` | **108** ||
360-
| — behaviour-bearing (rows 1–63 above) | 104 ||
359+
| Of those reads: reads of `ExecutionContext.isSystem` | **109** ||
360+
| — behaviour-bearing (rows 1–63 above) | 105 ||
361361
| — carry the flag onward only (rows 64–67 above) | 4 ||
362362
| Packages containing at least one elevation read | **20** ||
363363
| Files containing at least one elevation read | 45 ||

content/docs/protocol/objectql/security.mdx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,18 +259,45 @@ rejecting — the offending key is removed from the payload and the rest of the
259259
committed. The write therefore **succeeds** (REST answers `200`), and the read-only column
260260
simply keeps its stored value.
261261

262-
Four rules decide whether a given value survives:
262+
Five rules decide whether a given value survives:
263263

264264
| # | Rule | Effect |
265265
|:--|:---|:---|
266266
| 1 | **Trusted context is exempt** | A write carrying `context.isSystem === true` skips the strip entirely and may set read-only columns. |
267267
| 2 | **Only *caller-supplied* keys are candidates** | The engine snapshots the payload's keys at entry (`suppliedKeys`), *before* middleware and `beforeUpdate` hooks run. Only keys in that snapshot can be stripped. |
268268
| 3 | **Hook / middleware backfill survives** | A key a `beforeUpdate` hook *adds* to `data` is absent from the entry snapshot, so it is not a candidate — this is why the built-in `updated_by` / `updated_at` stamps land even though those columns are `readonly`. |
269269
| 4 | **`context.preserveAudit` admits a whitelist — on UPDATE only** | An opt-in historical import reinstates the audit/timestamp family and author-declared business `readonly` fields; platform-managed `system` columns (tenancy, generated) stay stripped. This exemption exists on the **UPDATE** path and nowhere else — see below. |
270-
271-
Rule 2 is scoped to keys, not values: a key the caller sent stays a strip candidate even if
272-
a hook later overwrites its value. So a `beforeUpdate` hook can *backfill* a read-only
273-
field, but cannot *rescue* one the caller supplied.
270+
| 5 | **Hooks are shown the persist image, not the submission** (#16344) | On UPDATE, a caller-supplied value for a `readonly` field is hidden from `ctx.input.data` *before* `beforeUpdate` is dispatched, so a hook cannot derive a persisted column from a value the row will never hold. What the caller actually sent is on **`ctx.submitted`** — diagnostics only, never the persist image. |
271+
272+
Rule 2 selects the *candidates*; rules 3 and 5 decide what a hook can do about one. A key
273+
the caller never sent is not a candidate at all, which is why the built-in `updated_by` /
274+
`updated_at` stamps land. A key the caller **did** send stays a candidate — but a hook that
275+
**assigns** it owns the value standing on it and the strip keeps that write (#5591 /
276+
#14088: authorship is *recorded* while the hook writes happen, not inferred from value
277+
equality afterwards). So a `beforeUpdate` hook can both *backfill* a read-only field and
278+
*overwrite* one the caller supplied; what it can no longer do is *rescue the caller's own
279+
value*, because since #16344 that value is not on `ctx.input.data` for it to echo back.
280+
281+
<Callout type="warn" title="`beforeUpdate` no longer sees the caller's read-only values (#16344)">
282+
This is a **breaking** change to what a hook reads, not to what is stored: the accept /
283+
refuse set is unchanged, `onFieldsDropped` reports the same fields under the same
284+
`readonly` reason, the WARN says the same sentence, and `strictReadonlyWrites` refuses the
285+
same writes. What moved is the hook's view.
286+
287+
- A handler **deriving** a column from a read-only field reads the stored row on
288+
`ctx.previous`, or treats the key's absence as "unchanged" — which is what absence
289+
always meant for a field the caller never sent.
290+
- A handler **reporting on what the caller sent** (a guard naming an offending key) reads
291+
`ctx.submitted`.
292+
- A self-assignment (`data.x = data.x`) on such a field is now a **no-op** — the stored
293+
value stands, and the write is stripped and reported exactly as an un-hooked one is.
294+
- `ctx.submitted` is **not** marshalled onto the sandboxed `body` face. A `body` deriving
295+
from a read-only column reads `ctx.previous`, the stored row, which is the correct
296+
source either way.
297+
298+
`beforeInsert` is untouched (#14147), and `readonlyWhen` locks are deliberately still
299+
hook-writable (#9107).
300+
</Callout>
274301

275302
<Callout type="warn">
276303
**`preserveAudit` is an UPDATE-path exemption. It does not apply on INSERT (#6640).**

content/docs/references/data/hook.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
9393
| **input** | `Record<string, any>` || Mutable input parameters |
9494
| **result** | `any` | optional | Operation result (After hooks only) |
9595
| **previous** | `Record<string, any>` | optional | Record state before operation |
96+
| **submitted** | `Record<string, any>` | optional | What the caller submitted, as sent (update only) — diagnostics only, never the persist image |
9697
| **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record<string, any> }` | optional | How this hook call relates to the caller's write (engine-produced) |
9798
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
9899
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |

0 commit comments

Comments
 (0)