Skip to content

Commit bfd686d

Browse files
committed
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
1 parent 2fe0d87 commit bfd686d

4 files changed

Lines changed: 73 additions & 16 deletions

File tree

.changeset/hook-input-is-the-persist-image.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist
88

99
<!-- 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. -->
1010

11-
**BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A statically `readonly` field the caller supplied a value for is no longer there.
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.
1212

1313
## The defect
1414

@@ -25,7 +25,7 @@ The row's own audit trail cites values the row does not hold. No error, no warni
2525

2626
## What changed
2727

28-
**`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for statically `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.
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.
2929

3030
**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.
3131

@@ -40,14 +40,16 @@ Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, deci
4040

4141
## Who is affected
4242

43-
A `beforeUpdate` handler that **reads a statically `readonly` field out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each:
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:
4444

4545
- **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.
4646
- **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 now writes `undefined`, because the key the hook reads is gone. That laundering route closing is intended, and it is re-pinned rather than removed.
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.
4848

4949
⚠️ **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`.
5050

5151
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.
5252

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+
5355
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/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).**

packages/spec/src/data/hook.zod.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -455,12 +455,24 @@ export const HookContextSchema = lazySchema(() => z.object({
455455
* decision batch #87, 2026-09-08)
456456
*
457457
* On the UPDATE verb `input.data` is **the record the engine intends to
458-
* persist**, not the caller's submission. A statically `readonly` field the
459-
* caller supplied a value for is stripped from it BEFORE the before phase is
458+
* persist**, not the caller's submission. A `readonly` field the caller
459+
* supplied a value for is stripped from it BEFORE the before phase is
460460
* dispatched, so no hook can derive a persisted column from a value the row
461461
* will never contain. What a hook that needs the caller's own words reads is
462462
* {@link HookContext.submitted} — the same payload as sent, diagnostics only.
463463
*
464+
* WHICH fields, exactly: the subject set is the update strip's own
465+
* (`stripReadonlyFields`), which is author-declared `readonly: true` AND the
466+
* types whose value the runtime owns end to end — `autonumber` today
467+
* (#5503), implicitly read-only whether or not the author wrote the flag.
468+
* Deliberately the same set rather than a second opinion: a pass that hid a
469+
* different set from the one enforced below would put the two out of step,
470+
* which is the whole failure this ordering exists to remove.
471+
*
472+
* ⛔ NOT `readonlyWhen`. A conditional lock is judged against the prior
473+
* record, per row on the predicate path, and #9107 leaves it hook-writable
474+
* on purpose.
475+
*
464476
* Two things this deliberately does NOT change: a hook's OWN write to a
465477
* read-only column still lands (#5591 / #14088 — the enforcement pass stays
466478
* after the hooks, where provenance is knowable), and `beforeInsert` is
@@ -659,11 +671,21 @@ export const HookContextSchema = lazySchema(() => z.object({
659671
*
660672
* Assigning to this record, or to a key on it, changes NOTHING about the
661673
* write — the engine reads `input.data` and nothing else on the way to the
662-
* driver. The object is FROZEN by the producer, so an assignment throws in
663-
* strict mode rather than silently editing a record of what a caller sent. A
664-
* handler that wants to change what is written writes `input.data`; a
665-
* handler that wants to REFUSE a write throws; a handler that wants to know
666-
* what the caller asked for reads this.
674+
* driver. The object is SHALLOW-frozen by the producer, so an assignment to
675+
* one of ITS OWN keys throws in strict mode rather than silently editing a
676+
* record of what a caller sent. A handler that wants to change what is
677+
* written writes `input.data`; a handler that wants to REFUSE a write
678+
* throws; a handler that wants to know what the caller asked for reads this.
679+
*
680+
* ⚠️ SHALLOW is the honest word and the depth matters. The snapshot is a
681+
* shallow spread of the caller's payload, so a NESTED object reached through
682+
* a key here is the caller's own reference and is mutable — `Object.freeze`
683+
* does not travel. That is not a laundering route (a nested mutation on a
684+
* hidden read-only key is handed back and stripped; the recorder never saw a
685+
* hook write), but it is not a deep guarantee either, and a handler must not
686+
* treat a nested read from here as tamper-proof. Deep-freezing was not
687+
* chosen: it costs a full walk of every payload on every update, to harden a
688+
* face documented as diagnostics-only.
667689
*
668690
* ⚠️ A value HERE and no matching key in `input.data` means precisely one
669691
* thing: the engine refused that field. It does not mean the field is

0 commit comments

Comments
 (0)