Skip to content

Commit f05649f

Browse files
fix(objectql): withhold caller-supplied readonly values from beforeInsert (#22306) (#22413)
Fixes #22306 Clause-②: no (narrowing) ## What was wrong On a non-system create, `ObjectQL.insert` dispatched `beforeInsert` on the caller's payload. It ran the static `readonly` strip (`staticReadonlyCreateStrip`) only after the hooks. Take a hook that stamps a read-only column only when the column is absent, the hotcrm `if (!input.stage_entry_date)` shape. It saw the caller's value and did nothing, then the strip dropped that value, so the row stored NULL where the hook would have stamped today. The update path has withheld these values from its hooks since #16344. The insert path did not. ## Base and head The same object and hook as hotcrm: `stage_entry_date` (date, `readonly`), `days_in_stage` (number, `readonly`), and a `beforeInsert` hook that stamps both when `stage_entry_date` is absent. | case | base: hook saw `stage_entry_date` | base: stored | head: hook saw | head: stored | |---|---|---|---|---| | caller sends the readonly key (non-system) | `'2020-01-01'` | NULL / NULL | absent | `'2026-10-09'` / 0 | | key absent (non-system) | absent | `'2026-10-09'` / 0 | absent | `'2026-10-09'` / 0 | | system writer (`isSystem`) sends the key | `'2020-01-01'` | `'2020-01-01'` / NULL | `'2020-01-01'` | `'2020-01-01'` / NULL | | a hook stamps the key unconditionally, and the caller sent it | `'2020-01-01'` | `'2026-10-09'` | absent | `'2026-10-09'` | How each row was measured: - **Rows 1 to 3, real engine over `driver-sql` (better-sqlite3):** base is `c8c803c293` with objectql built from that commit. Head is this branch. - **Row 1, REST door:** `POST /api/v1/data/rro_opp` through `bootStack` with a sandboxed `body` hook. The base call answered 201 and stored NULL / NULL. The head call answered 201 and stored `'2026-10-09'` / 0. - The caller was the seeded admin, a non-system principal. The strip ran on the base call, which shows this: the forged value was not stored. - A freshly signed-up user had no create permission on the fixture object (403), so that user could not be used. - **Row 4:** the engine over a recording driver, at head and with the hide ablated. The stored value is unchanged. - **Batch:** the same on both paths. At base, row 0 of a batch (forged) stored NULL and row 1 stored the stamp. At head, both rows store the stamp. ## The change (`packages/objectql/src/engine.ts`) - **Withhold, not strip, as on update.** Before the defaults and before `beforeInsert`, `withholdInsertReadonlyFromHooks` takes out every value that the post-hook static strip will take. It uses the same function (`stripReadonlyFields`), the same subject (`staticReadonlyInsertSubject`) and the same options (no `preserveAudit`), so the hide and the strip take the same caller keys. There is one named exception. Under `preserveAudit` the hide leaves the audit timeline visible, while the strip takes it unless the audit binder reinstates it. On a bare engine without `ObjectQLPlugin`, or for `created_by` / `updated_by` with no session user, such a value is shown to the hook and then stripped. The defaults then fill a withheld key the way they fill any absent key. So a forged `approval_status: 'approved'` is shown to the hook as its default `'draft'`, exactly what an honest caller's hook sees. - **The strip does not move.** After the seal, `handBackWithheldInsertReadonly` puts the caller's values back wherever no hook wrote the key. The strip, its re-default, the WARN, `onFieldsDropped` and `strictReadonlyWrites` then judge the same payload as before. - A hook's own write is kept, and is never overwritten by a hand-back. - A `data.x = data.x` self-assignment of a withheld key follows the engine's rule: - with no `defaultValue` the hook reads `undefined`, the write is undone as on update, and the caller's value is stripped and reported. `undefined` is never stored. - with a `defaultValue` the hook re-assigns the default, which counts as the hook's write. The default is stored and nothing is reported. - **Faults are explained.** The dispatch goes through `dispatchHooksExplainingWithheldReadonly`, the update path's wrapper. A hook that faults reaching through a withheld key gets a 400 that names the key. - `hook-withheld-readonly-fault.ts` gets a `beforeInsert` prescription, because a create has no `ctx.previous`. - The update message is byte-identical. What update withholds (M2), measured on `origin/main`: - **The set:** `stripReadonlyFields` over the full object schema. That covers static `readonly` and runtime-owned `autonumber`, but not `readonlyWhen`. - **Exemptions:** `isSystem` (the whole pass) and the `preserveAudit` whitelist. A key the caller did not send (own property of the entry snapshot) is never a candidate, and `id` is excluded. - **After the hooks:** seal, then hand back what the payload does not hold, undo a set-to-undefined, and narrow `hookWrittenKeys`. The caller's submission travels on `ctx.submitted`. The insert set differs from that in three measured places: - **Runtime-owned `autonumber` stays visible.** I first included it, and three existing pins went red. One is "the hook can still SEE the caller-submitted record number" (`engine-insert-runtime-owned-strip.test.ts`, #6339). The other two are the insert half of #14259's provenance seam (`engine-hook-provenance-sibling-seams.test.ts`). Moving that decision is not this card's, so the hide covers the static strip only. - **The audit timeline stays visible under `preserveAudit`.** On a create the static strip takes `created_at`, `created_by`, `updated_at` and `updated_by` whatever the flag says. But the historical-import channel that the #15964 ruling kept working reinstates them through the audit binder's `preserveAudit` branch (`record.created_at ?? now`). Withholding them would erase every historical `created_at`. A business `readonly` column under `preserveAudit` is still withheld. - **`sys_` and platform-internal (`managedBy`) objects keep their own guards.** The create strip's subject excludes them already, so nothing is withheld there. There is no `ctx.submitted` on a create. The one in-repo `beforeInsert` reader that needs a caller's read-only value is the audit binder's `preserveAudit` branch, and it keeps seeing it. Entry points (M3): `ObjectQL.insert` is the only `beforeInsert` dispatch site in the repo. Single insert, batch insert and `insertMany` all go through it. The engine has no upsert of its own. The protocol batch `upsert` create arm, REST `POST`, import and flows `create_record` all call `engine.insert`. Seeds write with `SEED_WRITE_EXECUTION_CONTEXT` (`isSystem: true`), so nothing is withheld for them. ## Docs touched - `packages/spec/src/data/hook.zod.ts`: TSDoc only. Two sentences said `beforeInsert` "is untouched" and "still receives the caller's own values". The `submitted` describe ("update only") stays true and is unchanged. - `content/docs/protocol/objectql/security.mdx`: rule 5 and the #16344 callout now cover insert. - `content/docs/permissions/system-context.mdx`: regenerated by `pnpm gen:system-context-census`, 118 to 119 elevation read sites. The +1 is the hide pass's non-system gate. ## Tests and gates Head is `aa115f1c60`. The suites ran at `975f4b6690`; the only commit since then is `aa115f1c60`, which regenerates the census page and changes no code. - **`@objectstack/objectql`:** - `--project local`: 390 files, 7676 tests passed; - `--project repo`: 1 file, 5 tests passed; - `typecheck` (tsc plus `check:test-typecheck`): exit 0. - **`@objectstack/rest`:** - `--project local`: 264 files, 4954 passed, 326 skipped; - `--project repo`: 5 files, 191 passed, 1 skipped. - **Dogfood shards** (`OS_TEST_SHARD=k/3`): - 1/3: 77 files, 569 passed; - 2/3: 77 files, 545 passed, 1 skipped; - 3/3: 76 files passed, 1 skipped; 679 tests passed, 8 skipped. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` at `aa115f1c60` derived 113 commands. All 113 ran and exited 0, and `--ran` reconciles them as 113 run, 0 NOT-MEASURED. - `engine-insert-readonly-hook-input.test.ts` (new, 13 cases): - the ordering probe with a writable-key control; - the hotcrm shape; - a default shown in place of a forgery; - controls for `autonumber`, the system writer, strip reporting and the strict refusal; - a self-assignment no-op; - batch and `insertMany`; - `preserveAudit`; - the explained fault; - an update control. - `hook-withheld-readonly-fault.test.ts`: one `beforeInsert` case. - **Reverse verification:** `node scripts/ablation-replace.mjs`, anchor `hookViewSource[i] = hidden.row;` replaced with `void hidden.row;`. Result: 9 failed and 4 passed of 13. The four that stayed green are the controls that do not depend on the hide. The restore was verified: blob equals HEAD and `git diff HEAD` is empty. ## Acceptance notes - **Insert and update still differ on `autonumber`.** `beforeUpdate` does not see a caller-supplied `autonumber`, and `beforeInsert` still does (#6339's pin). A hook that issues its own record number only when the key is absent stands down on a forged one, and the sequence fills it. Not measured through a public door, and nobody has picked this up. - **Changeset level.** Settled by the contract-tier review: `@objectstack/objectql` `minor` with `Clause-②: no (narrowing)`, a BREAKING note and an ADR-0087 disposition, as the update-side twin (#16344) was. See Patch round 1. ## Patch round 1 This round changes text only, after the contract-tier review graded the narrowing BREAKING (FAIL on the semver level only; the engine change passed). - **The changeset** `.changeset/22306-insert-readonly-withheld-from-before-insert.md`: - is now `'@objectstack/objectql': minor` with `Clause-②: no (narrowing)`. That is the launch-window spelling of a breaking change, and pre mode stays on (`.changeset/pre.json`, tag `next`). - carries a BREAKING paragraph. It names what a `beforeInsert` handler reads on `ctx.input.data` on a non-system create, and the withheld set: `staticReadonlyInsertSubject`'s, minus the `autonumber` and `preserveAudit` audit-timeline edges. - names two answers that change at the REST door. Under `strictReadonlyWrites`, a self-assigning hook's launder is now refused (201 to 400). A hook or body that dereferences through a withheld key answers 400 where it answered 201. - carries one ADR-0087 disposition marker, `not-required (no-migration-prescription)`, in the comment form the gate reads. - **Two sentences corrected:** the self-assignment sentence now states the engine's rule for a withheld key with a `defaultValue`, and "the two cannot disagree" now names the `preserveAudit` shown-but-stripped edge. Both corrections are in the changeset and in this body. - **The two docs lines the review named as owed:** `content/docs/automation/hooks.mdx` extends the persist-image sentence to `beforeInsert`, and census row 21 in `content/docs/permissions/system-context.mdx` names the second `isSystem` read. - **`@objectstack/spec` is not added to the changeset.** Its diff is TSDoc that documents objectql's behaviour. Listing it would print objectql's BREAKING note into spec's CHANGELOG under a `patch`, and an upgrader greps objectql's entry. - **Gates:** - `check-adr-0087-registration --base origin/main` reads the changeset as `BREAKING+clause-②-narrowing` with a `not-required (no-migration-prescription)` disposition. - `check-changeset-no-major --base origin/main` exits 0, and its level axis, run against this body as the PR event, is reported in the os-dev report. - `dispatch-gates --commands` was re-derived on the final commit, run and reconciled with `--ran`. The figures are in the os-dev report on the card. - No code or test changed in this round. ## Merge round This round merges `origin/main` `ca135dcc40` into the branch (merge commit `2008591e47`) and regenerates one page in `76bd4fd399`. Head is `76bd4fd399`. - **Census skew.** `main`'s `ca135dcc40` and this branch each took `content/docs/permissions/system-context.mdx` from 118 to 119, for different read sites. - The merge driver deferred the page (os-regen), so the merged page read 119 while the merged tree holds 120 sites. `check:system-context-census` was red on the merge commit with 6 declared-count mismatches (119 vs 120). - `pnpm gen:system-context-census` rewrote the counts, never by hand: 119 to 120 sites, 125 to 126 property reads, 116 to 117 behaviour-bearing. - The gate now exits 0 at 120 sites. Both sides' rows survive: against `main`, the page differs only by the counts and this branch's row-21 clause. - **Readings at `76bd4fd399`:** - `@objectstack/objectql` `--project local`: 390 files, 7676 passed; - `--project repo`: 1 file, 5 passed; - `typecheck` (tsc plus `check:test-typecheck`): exit 0; - the pin files `engine-insert-readonly-hook-input.test.ts` and `hook-withheld-readonly-fault.test.ts`: 2 files, 24 passed. - `dispatch-gates --commands` derived the same 113 commands. All 113 ran and exited 0, and `--ran` reconciles them as 113 run, 0 NOT-MEASURED. - **Nothing else changed:** no code, test or changeset edit. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3054516 commit f05649f

9 files changed

Lines changed: 709 additions & 25 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
A `beforeInsert` hook is no longer shown a caller-supplied value for a `readonly` field, so a hook that stamps an absent read-only column stamps it even when the caller sent one.
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) An enforcement-ORDER change to what a hook is shown. No authorable key, spelling, export or stored shape moves, so a stored `sys_metadata` row needs no conversion and an upgrader has nothing to hand-edit. What changes is the image a `beforeInsert` handler reads on `ctx.input.data` on a non-system create; a handler that depended on seeing a caller's read-only value fixes it in its own code, not in metadata. -->
10+
11+
**BREAKING** — what a `beforeInsert` handler reads on `ctx.input.data` changes on a non-system create. This ships as `minor` under the launch-window convention for breaking changes.
12+
13+
- **What it reads now.** A caller-supplied value for a field the create-side static `readonly` strip takes is not on `ctx.input.data`. The field holds its `defaultValue`, or there is no key, which is exactly what the hook sees when the caller did not send the field.
14+
- **Which fields.** The withheld set is the create strip's own subject (`staticReadonlyInsertSubject`): author-declared `readonly: true` fields that are not runtime-owned, on an object outside the `sys_` namespace and the platform-internal `managedBy` buckets. It has two edges:
15+
- a runtime-owned `autonumber` value stays visible to the hook;
16+
- under `preserveAudit`, the audit timeline (`created_at`, `created_by`, `updated_at`, `updated_by`) stays visible, because the historical-import channel reinstates it through a `beforeInsert` hook.
17+
- **Two answers at the REST door change.**
18+
- A hook that self-assigns a withheld field with no `defaultValue` (`data.x = data.x`) used to turn the caller's forged value into a hook write, which was stored. Under `strictReadonlyWrites` that create now answers 400 where it answered 201.
19+
- A hook or sandboxed `body` that reaches through a withheld field (`ctx.input.meta.who = …`) now faults, and the create answers 400 naming the field where it answered 201.
20+
- A `system`-context create (`isSystem`) is unchanged: the strip does not apply to it, so nothing is withheld.
21+
22+
## The defect
23+
24+
On a non-system create, the engine took a caller's value for a static `readonly` field out of the row only after `beforeInsert` had run. A hook that stamps such a field only when it is absent (`if (!data.stage_entry_date) data.stage_entry_date = today`) saw the caller's value and did nothing, and the strip then dropped that value. The row stored NULL where the hook would have stamped today. Measured on a real engine over the SQL driver: with the key in the payload, the row stored `stage_entry_date` and `days_in_stage` NULL; without it, the hook stamped both. This is the insert-side twin of the `beforeUpdate` rule: a hook is never handed a value that will not be stored.
25+
26+
## What did not move
27+
28+
- **The strip still runs after the hooks.** The caller's values are handed back to it there, wherever no hook wrote the key. So `onFieldsDropped`, the read-only WARN and `strictReadonlyWrites` judge the same payload as before.
29+
- **A hook that assigns a read-only field still has its value stored.**
30+
- **A self-assignment (`data.x = data.x`) of a withheld field** follows the engine's rule:
31+
- with no `defaultValue` the hook reads `undefined`, the write is undone, and the caller's value is stripped and reported. `undefined` is never stored.
32+
- with a `defaultValue` the hook reads and re-assigns the default, which counts as the hook's write. The default is stored, and nothing is reported.
33+
- **Faults are explained.** A hook that faults reaching through a withheld field is answered 400 naming the field, the way `beforeUpdate` already answers.
34+
35+
## Who is affected
36+
37+
A `beforeInsert` handler that reads a `readonly` field from `ctx.input.data` on a non-system create.
38+
39+
- **A handler that derives a value from the field:** the value it used to read was never stored, so it should derive nothing from it.
40+
- **A handler that owns the field:** it assigns the field.
41+
- **A handler that reports what the caller sent:** a create has no `ctx.submitted` to read it from.

‎content/docs/automation/hooks.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,9 @@ record's fields **directly on `ctx.input`** (a flat view over the internal
184184

185185
On **update**, "the incoming record" means *the record the engine intends to
186186
persist* — a caller-supplied value for a `readonly` field is not on it, and the
187-
caller's submission is on `ctx.submitted` instead (diagnostics only). See
187+
caller's submission is on `ctx.submitted` instead (diagnostics only). On a
188+
**create** the same value is withheld from `beforeInsert` too: the field holds
189+
its `defaultValue` or no key, and a create has no `ctx.submitted`. See
188190
[Static `readonly` fields on the write path](/docs/protocol/objectql/security#static-readonly-fields-on-the-write-path)
189191
for the five rules and the migration.
190192

‎content/docs/permissions/system-context.mdx‎

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

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **119
13+
because the flag is not one concept: it is a single boolean read at **120
1414
distinct sites across 20 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -123,7 +123,7 @@ that silently does not happen.
123123
|:--|:---|:---|:---|:---|
124124
| 19 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `packages/objectql/src/engine.ts#update` |
125125
| 20 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `packages/objectql/src/engine.ts#update` |
126-
| 21 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `packages/objectql/src/engine.ts#insert` |
126+
| 21 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped. A second read in the same symbol gates the pass that withholds those caller values from `beforeInsert` before the strip, the same exemption, so a system caller's hook still sees what it sent | `packages/objectql/src/engine.ts#insert` |
127127
| 21b | **`readonly` strip bypassed — the write PREVIEW (`ObjectQL.validate`)** | objectql | Same as 19–21, in the preview: it runs the write's caller-write strips under the write's own gate, so a system preview reports no `readonly` drop and judges the value a system write would store. The `computed` strip of a `formula` value is NOT gated here or on either write verb — no driver has a column for it, so it runs for every caller | `packages/objectql/src/engine.ts#validate` |
128128
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing. One exception: a `formula` value, which the `computed` strip takes from every caller, so strict still refuses it | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
129129
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
@@ -141,7 +141,7 @@ that silently does not happen.
141141

142142
### 3. Sharing (`plugin-sharing`)
143143

144-
The largest single consumer — **17 of the 119 sites**.
144+
The largest single consumer — **17 of the 120 sites**.
145145

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

287-
- **Shipped semantics.** `isSystem` is a published contract with 119 read sites
287+
- **Shipped semantics.** `isSystem` is a published contract with 120 read sites
288288
in 20 packages. Splitting it is a breaking contract change across all of them.
289289
(The ruling was taken when the census read 80 sites in 18 packages; the count
290290
has grown, which strengthens rather than weakens the argument.)
@@ -358,12 +358,12 @@ still holds equal to the census on every pull request:
358358
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
359359
| — parsed as a declaration | 27 | ✅ |
360360
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
361-
| — parsed as a property **read** | 125 | ✅ |
361+
| — parsed as a property **read** | 126 | ✅ |
362362
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
363363
| — the remainder: text inside comments and string literals | 358 | — |
364364
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
365-
| Of those reads: reads of `ExecutionContext.isSystem` | **119** | ✅ |
366-
| — behaviour-bearing (rows 1–61 above) | 116 | ✅ |
365+
| Of those reads: reads of `ExecutionContext.isSystem` | **120** | ✅ |
366+
| — behaviour-bearing (rows 1–61 above) | 117 | ✅ |
367367
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
368368
| Packages containing at least one elevation read | **20** | ✅ |
369369
| Files containing at least one elevation read | 55 | ✅ |

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ Five rules decide whether a given value survives:
276276
| 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. |
277277
| 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`. |
278278
| 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. |
279-
| 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. |
279+
| 5 | **Hooks are shown the persist image, not the submission** (#16344, #22306) | 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. On INSERT the same value is withheld from `beforeInsert`, before the defaults run, so the hook sees what the create stores; a create has no `ctx.submitted`. |
280280

281281
Rule 2 selects the *candidates*; rules 3 and 5 decide what a hook can do about one. A key
282282
the caller never sent is not a candidate at all, which is why the built-in `updated_by` /
@@ -304,8 +304,16 @@ same writes. What moved is the hook's view.
304304
from a read-only column reads `ctx.previous`, the stored row, which is the correct
305305
source either way.
306306

307-
`beforeInsert` is untouched (#14147), and `readonlyWhen` locks are deliberately still
308-
hook-writable (#9107).
307+
`readonlyWhen` locks are deliberately still hook-writable (#9107).
308+
309+
`beforeInsert` follows the same rule since #22306, while the create-side strip itself stays
310+
after the hooks (#14147). A caller-supplied value the strip will take is withheld *before
311+
the defaults run*, so the hook sees exactly what an honest caller's hook sees — the field's
312+
`defaultValue`, or no key — and a hook that stamps an absent read-only column
313+
(`if (!data.x) data.x = …`) stamps it whether or not the caller sent one. Two values stay
314+
visible: a runtime-owned `autonumber` (#6339), and, under `preserveAudit`, the audit
315+
timeline the historical-import channel reinstates through a `beforeInsert` hook. There is
316+
no `ctx.submitted` on a create.
309317
</Callout>
310318

311319
<Callout type="warn">

0 commit comments

Comments
 (0)