Skip to content

Commit b398ad2

Browse files
claude[bot]claude
andauthored
fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted (#15395)
* wip(objectql,metadata-protocol): move the create-side static readonly strip into engine.insert Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(objectql,metadata-protocol): pin the in-engine create-side readonly strip and the ingress delegation Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(objectql): the referential-integrity narrowing, split into its platform and author halves Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(lint,metadata-protocol): drop the superseded INSERT-exempt premise; pin the reasoned refusal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(objectql,metadata-protocol,spec,lint): retire the ingress-strip prose; changeset for the create-side move Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(objectql,rest): the create-path strip line states only what is true of a create Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * wip: suite runs * chore(objectql,lint,scripts): self-contained prose, engine-double pin, doc-authoring baseline burn-down Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(permissions): the system-context census loses the ingress row it no longer has Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(objectql): pin the create-path preserveAudit line by its remedy, not by an issue id Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore: regenerate the system-context census line anchors after merging origin/main Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(changeset): answer the ADR-0087 disposition and re-derive the released package list Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(example-todo): bind the app's own hook in the recurrence harness and drop the stale completed_date create-seed The harness claimed to boot the same stack as task-completion-trigger.test.ts while binding no hooks, so task.hook.ts's beforeUpdate completion stamp never ran in this file -- which is why it still carried a completed_date CREATE-seed its sibling deleted when that stamp shipped. The seed was also a non-system caller writing a readonly, server-owned column on create, which the engine now strips. Binding the app's hook lets both completion cases travel the app's real user path; the one fixture that must START completed seeds under isSystem, the documented remedy. No assertion changed, nothing skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(example-todo): the sibling's measurement table is history, not a live create-side escape Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(docs): re-anchor the system-context census after merging origin/main The `os-regen` merge driver declined to text-merge `content/docs/permissions/system-context.mdx` and kept the branch's side, so main's three re-anchorings of `domains/actions.ts` were dropped by the merge commit. Regenerated from the merged tree with `pnpm gen:system-context-census`, which re-derived exactly those three anchors (404 -> 414 twice, 411 -> 421). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(metadata-protocol,lint,docs): patch round — wire batchData upsert-create, retire every surviving "INSERT is exempt" statement, fix five census cross-references Contract review 5548671173 (FAIL, patch round), items 1-4: 1. Every surviving statement of the superseded "INSERT is engine-exempt / the ingress strips on create" premise is corrected to the 2026-09-03 ruling (option C): the verdict's five sites, plus the same statement found by sweep in validate-flow-node-writes.test.ts, the kernel contracts page, fields.mdx, the strictReadonlyWrites contract docblock, the readonly liveness verdict (evidence pointed at the deleted ingress strip), the authz conformance matrix row and two test headers. The verdicts of the green control cases are unchanged; their justifications now name the scan gap (#15394) instead of an exemption. 2. system-context.mdx: the five prose row references the 22→21 … 65→64 renumbering left behind (50→49, 30→29 twice, 22→21, 34→33), each checked by eye against the renumbered table; the census gate reads none of them. 3. batchData's two upsert-create engine.insert calls forward onFieldsDropped and hang the merged events on the row result exactly as case 'create' does; pinned for both arms in protocol.readonly-insert.test.ts, the firing control enumerates six calls, and cloneData's deliberate absence is pinned against CloneDataResponseSchema (no droppedFields member). 4. Changeset body: the metadata-protocol bullet names which faces report droppedFields and why cloneData does not; the lint bullet is true of all three rules. Levels, BREAKING banner and ADR-0087 disposition unchanged. origin/main is deliberately not merged this round; the p0 on engine.ts (#15225) lands first and a separate merge round follows it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(objectql): rule-validator pin no longer justifies itself with the superseded insert exemption The runtime-owned helper's "leaves author-declared readonly fields alone" case kept its verdict but stated as its reason that the engine is not where the static-readonly insert strip lives. Since the 2026-09-03 ruling it is; the case now says why the verdict still holds (a separate pass with its own gate, and preserveAudit must not leak across). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(docs,qa): re-anchor the system-context census after the engine.ts note grew; close a string literal in the authz matrix check-system-context-census --fix rewrote 9 line anchors (pure line rot: the insert() standing note gained seven lines); the gate reads OK — 105 / 19 / 44 afterwards. The authz conformance matrix row gained an apostrophe inside a single-quoted string in the previous commit; rephrased. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(spec,rest,objectql,mcp,qa,scripts): the deleted ingress strip is no longer stated as live in schema text, code notes, test titles, the census gate's row annotations or the authz matrix Patch round R3 on the contract re-review (5549250610), items 1-3 and 5. Prose only; no behaviour change, no test deleted or skipped. - N2 (a)-(e): the `preserveAudit`, `CreateDataResponseSchema.droppedFields`, `CreateManyDataResponseSchema.droppedFields` and `BatchOperationResultSchema.droppedFields` `.describe()` strings and the `CloneDataResponseSchema` TSDoc now state the ruled state: the static `readonly` strip runs inside `engine.insert`, after `beforeInsert`, `isSystem`-gated; the DataProtocol ingress copy is deleted. The generated `content/docs/references/**` rows follow in the next commit via `check:generated --fix`. - N2 (f)-(k): `rest-server.ts` batch-route note keeps the routing reason on what the ingress still owns (object-existence gate, #7823 response strip, `droppedFields` relay) and attributes the platform-object carve-out to the engine, where it lives (`staticReadonlyInsertSubject`); the #3431 header note; `rule-validator.ts`'s second-consumer sentence names `staticReadonlyInsertSubject`; the mcp stdio bridge's divergence list drops the readonly strip and says why it is closed; two test comments/titles in `engine-autonumber-runtime-owned.test.ts` and one title in `rest-dropped-fields.test.ts`. - N3: the seven `why` rows in `check-system-context-census.mjs` re-derived against the head table (24->23 x2, 22->21 x2, 25->24, 34->33, 60->59). The gate never parses them. - N4: the authz matrix row states the two mechanisms - a system context for identity provisioning; the `sys_`/`managedBy` carve-out for the metadata repository's `sys_metadata_history.recorded_by` provenance row - and re-derives "event-log cursors" by name as `sys_metadata_history.event_seq` (same row, same mechanism). Issue-id multiset of the row is byte-identical for the prose-id ledger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(docs): regenerate the reference rows the describe edits moved; re-anchor the system-context census after the mcp bridge note grew `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated --fix` reported exactly one stale artifact (`content/docs/references/**`) and regenerated it with `gen:docs`: 12 rows of `references/data/data-engine.mdx`, one of `references/kernel/execution-context.mdx`, and the `droppedFields` rows of `references/api/protocol.mdx` and `references/api/batch.mdx`. A second `check:generated` answers "All 15 generated artifacts are up to date" (fixed point); `check:api-surface` answers "public API surface + factory signatures unchanged". `check-system-context-census --fix` re-anchored one line (`stdio-data-bridge.ts:246` -> `:250`, the header note in that file grew by four lines); the gate is 0 and a second `--fix` rewrites nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(spec,objectql,service-settings): the three PUBLISHED sites that still stated the superseded readonly-on-INSERT contract now state the ruled one Maintainer ruling on E3 (2026-09-05, 「已发布必修,其余立卡」): published surfaces are fixed in the round, non-published residue is carded. Measured against the built dist, three of the third verdict's nine N5 sites reach a published `.d.ts` and are corrected here: - packages/spec/src/security/public-form.ts — TSDoc on the exported PUBLIC_FORM_SERVER_MANAGED_FIELDS (dist/security/index.d.ts): the anonymous surface no longer rests on "the static-readonly strip only covers UPDATE", and the authenticated-write example no longer says an insert may seed readonly columns; a non-system insert is stripped inside engine.insert since the 2026-09-03 ruling, an import seeds read-only columns only under a system context (preserveAudit is UPDATE-only). - packages/objectql/src/readonly-strict-errors.ts — the `operation` property TSDoc of the exported ReadonlyFieldRejectedError (dist/index.d.ts): an INSERT refusal is about a runtime-owned value OR, since the ruling, a static readonly value from a non-system caller. buildRefusalMessage is untouched; the module docblock at the top of the file reaches no published artefact and is carded. - packages/services/service-settings/src/settings-service.ts — the upsertRow docblock (dist/index.d.ts and dist/index.js): a sys_setting insert stays outside the strip because the object is sys_-prefixed and engine-owned (staticReadonlyInsertSubject's carve-out, #15719), not by the superseded row. The changeset adds '@objectstack/service-settings': patch so the corrected .d.ts ships. No behaviour change; no test touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(permissions): re-anchor the system-context census on the merged tree `pnpm gen:system-context-census` on the merge commit. The first run refused engine.ts (16 distinct page anchors vs 15 anchorable lines) because row 20 carried the branch tree's coordinate for the INSERT `isSystem` site while the validation-row range carried main's coordinate for the same line; putting row 20 on main's coordinate (the driver's "take either side") made the page one coordinate system, after which the rerun rewrote 11 engine.ts anchors and refused zero files. `pnpm check:system-context-census` is green: 106 sites in 19 packages across 44 files, 140 anchors resolve, 27 declared non-read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c7b239 commit b398ad2

52 files changed

Lines changed: 1840 additions & 894 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/tidy-cups-smile.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/service-automation': patch
5+
'@objectstack/lint': patch
6+
'@objectstack/spec': patch
7+
'@objectstack/service-settings': patch
8+
---
9+
10+
**BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed.
11+
12+
Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event.
13+
14+
- `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one.
15+
- `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not.
16+
- Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL.
17+
- `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip.
18+
- `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip.
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) The BREAKING here is a WRITE-PATH BEHAVIOUR change, not a retirement of an authorable or published surface, so there is nothing for the ledger to carry to `objectstack migrate meta`, `spec-changes.json` or the upgrade guide: no spec property, metadata key, accepted value or exported symbol disappears, and this body prescribes no FROM/TO migration. The remedy for an affected caller is to declare the write trusted (`context.isSystem` / `runAs: 'system'`), which is application code, not a metadata migration. The obvious retirement candidate is a non-question in the same direction: `stripReadonlyForInsert` was a bare module-private `function` in `packages/metadata-protocol/src/protocol.ts`, absent from that package's `index.ts` (the only path its `exports` map offers), so no consumer could name it. -->

content/docs/automation/hook-bodies.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,23 +252,23 @@ A body may write *other* objects — e.g. `await ctx.api.object('parent').update
252252

253253
### Writing a `readonly` field
254254

255-
There is an asymmetry here that costs data if you learn it the hard way, so learn it here. A field declared `readonly: true` can still be **maintained by automation** from a body — but through exactly **one** channel (the own-hook stamp, plus INSERT), and a nested `ctx.api` write is **not** one of them.
255+
There is an asymmetry here that costs data if you learn it the hard way, so learn it here. A field declared `readonly: true` can still be **maintained by automation** from a body — but only through the own-hook stamp, or under a **system** context. A nested `ctx.api` write on a non-system trigger is **not** a channel, and since the maintainer ruling of 2026-09-03 ([#14147](https://github.com/objectstack-ai/objectstack/issues/14147)) that is as true of `insert` as of `update`.
256256

257-
`readonly` governs the *caller* surface. On UPDATE the engine strips read-only keys from the payload, but only the ones the **caller supplied** and only when the value is still the caller's. So:
257+
`readonly` governs the *caller* surface. On every non-system write — UPDATE, and INSERT too since that ruling moved the create-side strip into `engine.insert` the engine strips read-only keys from the payload, but only the ones the **caller supplied** and only when the value is still the caller's. So:
258258

259259
| How the body writes it | What happens |
260260
|:---|:---|
261261
| `ctx.input.<field> = …` in `beforeInsert`/`beforeUpdate` | **Lands.** The stamp is a *server* value, not a caller-supplied one, so the strip leaves it alone. This is the recommended shape. |
262262
| `ctx.api.object('x').update({ <field> })` | **Silently dropped** — unless the hook declares `runAs: 'system'`. `ctx.api` is scoped to the *triggering* operation's context, so on any non-system trigger the payload is an ordinary caller payload and the key is stripped, and the call still returns success. Declaring [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) gives that `ctx.api` a system context, which the strip skips, so the write lands. |
263263
| `ctx.api.sudo().object('x').update({ <field> })` | **`TypeError` — not available here.** `sudo()` is a member of the *in-process* `ScopedContext`; the VM's `ctx.api` carries `object()` and `transaction()` and nothing else, so a **body** cannot reach it. Worse than unavailable: the same source *works* when the handler runs in-process, so it passes a native `hook.handler(ctx)` test and throws only once the build lowers it into a body — aborting the triggering write under the default `onError: 'abort'`. `objectstack build` now refuses to lower such a handler and keeps it bundled instead. The knob to reach for is [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) on the hook itself, which is declarative and works on **both** surfaces. |
264-
| `ctx.api.object('x').insert({ <field> })` | **Lands.** INSERT is exempt — a create may legitimately seed read-only columns. |
264+
| `ctx.api.object('x').insert({ <field> })` | **Silently dropped** — unless the hook declares `runAs: 'system'`, exactly as the `update` row. `engine.insert` runs the same static strip under the same `isSystem` gate, so a non-system create no longer seeds a read-only column: the key is removed, the field falls back to its `defaultValue`, and the call still returns success. Seeding a read-only column at create time is a **system** act — `runAs: 'system'` here, or a `beforeInsert` stamp on the target object. (Before the 2026-09-03 ruling this row read "Lands — INSERT is exempt"; that row is superseded.) |
265265

266266
The dropped case is the dangerous one: nothing fails, the step reports success, and the column is simply always null. Because both halves of that judgement are declared in your own stack, it is checked at author time and **gates the build**:
267267

268268
- `hook-api-update-readonly-field`**error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` writes a field the named object declares `readonly: true`.
269269
- `hook-api-update-readonly-when-field`**warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: unlike the static strip, the conditional lock is **not** waived by a system context, so neither `runAs: 'system'` nor the `sudo()` a body cannot reach makes a caller-supplied value survive. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.
270270

271-
Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name, an object this stack does not declare, and `insert`/`create` are all skipped, so the rule has no opinion on them. The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425).
271+
Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name and an object this stack does not declare are all skipped, so the rule has no opinion on them. `insert`/`create` are skipped too — but since the 2026-09-03 ruling that is a **scan gap**, not an exemption: the write is dropped exactly as the table says, and nothing reports it at build time yet ([#15394](https://github.com/objectstack-ai/objectstack/issues/15394)). The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425), with the same gap on `create_record`.
272272

273273
The table above is about a **hook** body. An **action** body is the one surface where the answer changes, so read this before you move a body from one to the other: an action body runs **elevated** — its `ctx.api` is built over the caller's envelope with `isSystem` set, which is the same trusted posture that lets an action bypass row and field permissions — and the static strip applies only to non-system callers. So `ctx.api.object('x').update({ someReadonlyField })` **lands** in an action, and there is no finding for it. Elevation does not waive the *conditional* lock, though, so that half does carry across: `action-api-update-readonly-when-field` — a **warning** — on an action body's literal `ctx.api` update to a `readonlyWhen` field ([#13770](https://github.com/objectstack-ai/objectstack/issues/13770)). Net effect when you move a body: a `readonly` write changes behaviour, a `readonlyWhen` write does not.
274274

content/docs/data-modeling/fields.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ These properties are available on all field types:
316316
| `description` | `string` || Developer documentation |
317317
| `inlineHelpText` | `string` || Help text shown in UI |
318318
| `hidden` | `boolean` | `false` | Hide from default views |
319-
| `readonly` | `boolean` | `false` | Prevent editing — hidden from create/edit forms AND server-enforced on both write paths: a non-system write to the field is silently dropped on `UPDATE` (in the engine) and on `INSERT` through the data API (REST/MCP/import, at the DataProtocol ingress). A stripped field still falls back to its `defaultValue`; **seeding a `readonly` column at create requires a system context** (import/migration/programmatic seed). Platform (`sys_`/`managedBy`) objects are governed by their own write policy instead — the resolved-affordance write guard keyed off the object's [lifecycle bucket](/docs/data-modeling/objects#lifecycle-bucket-managedby) (ADR-0103), not this field-level flag. |
319+
| `readonly` | `boolean` | `false` | Prevent editing — hidden from create/edit forms AND server-enforced on both write paths: a non-system write to the field is silently dropped on `UPDATE` and, since the maintainer ruling of 2026-09-03, on `INSERT` — both in the engine, so a direct `engine.insert` caller (a flow's `create_record`, a hook body's `ctx.api`) is covered exactly like the data API's REST/MCP/import faces. A stripped field still falls back to its `defaultValue`; **seeding a `readonly` column at create requires a system context** (import/migration/programmatic seed). Platform (`sys_`/`managedBy`) objects are governed by their own write policy instead — the resolved-affordance write guard keyed off the object's [lifecycle bucket](/docs/data-modeling/objects#lifecycle-bucket-managedby) (ADR-0103), not this field-level flag. |
320320
| `sortable` | `boolean` | `true` | Allow sorting by this field |
321321
| `group` | `string` || Group name for organizing in forms (e.g. `'billing'`) |
322322

content/docs/kernel/contracts/data-engine.mdx

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -303,13 +303,14 @@ The strips these two options cover are the engine's legal ones:
303303

304304
| Strip | `reason` | Verbs | Writers it skips |
305305
|:---|:---|:---|:---|
306-
| Static `readonly: true` (#2948) | `readonly` | `update` | `isSystem` |
306+
| Static `readonly: true` (#2948; on `insert` too since the 2026-09-03 ruling, #14147) | `readonly` | `insert` **and** `update` | `isSystem` |
307307
| A TRUE `readonlyWhen` predicate (#3042) | `readonly_when` | `update` | none at the API boundary — every caller, `isSystem` included; a value a `beforeUpdate` hook derived or overwrote is not a caller write and is never stripped (#9107) |
308308
| Implicitly-readonly runtime-owned type (#5503`RUNTIME_OWNED_FIELD_TYPES`, today `autonumber`) | `readonly` | `insert` **and** `update` | `isSystem`, `preserveAudit` (#3493) |
309309
| Primary-key strip of a payload `id` the update dispatch already ruled is not an identifier (#6437) | `primary_key` | `update` | none |
310310

311-
The two AUTHOR-DECLARED strips are insert-exempt at this seam by design (#3413) —
312-
see **On `insert`** below.
311+
Of the two AUTHOR-DECLARED strips only `readonlyWhen` is insert-exempt at this seam
312+
(a conditional lock has no prior record on a create); the static `readonly` strip runs
313+
on `insert` too since the 2026-09-03 ruling — see **On `insert`** below.
313314

314315
{/* os:check */}
315316
```typescript
@@ -366,23 +367,33 @@ alternative outputs of one seam, not a sequence: `DroppedFieldsEvent` means
366367
"fields dropped and the write completed without them", and under strict the write
367368
does not complete. Quiet-and-observable or loud — pick one per call.
368369

369-
**On `insert`.** The two AUTHOR-DECLARED strips are deliberately insert-exempt at
370-
this seam (#3413: an in-process create may seed a `readonly: true` field's initial
371-
value, and `readonlyWhen` cannot lock anything on a create at all), so an insert
372-
refusal can only ever be about a runtime-owned value — a caller-supplied record
373-
number. With the option `true` that insert throws (`operation: 'insert'`) and
374-
nothing is written; without it the value is stripped, the write completes, and
375-
`onFieldsDropped` fires with `reason: 'readonly'`. The engine-level writers exempt
376-
from that strip — and therefore never refused — are the two the error message
377-
itself names: `isSystem`, and the `preserveAudit` historical import reinstating
378-
legacy record numbers (#3493).
370+
**On `insert`.** Until the maintainer ruling of 2026-09-03 (option C, #14147) this
371+
paragraph said the two AUTHOR-DECLARED strips were insert-exempt at this seam
372+
(#3413: an in-process create may seed a `readonly: true` field's initial value).
373+
That row is superseded: `engine.insert` now runs the static `readonly` strip for a
374+
non-system caller — the same `stripReadonlyFields`, under the same `isSystem` gate,
375+
as `engine.update` — and the DataProtocol ingress copy that used to cover external
376+
callers only is deleted. One semantics, one enforcement point. `readonlyWhen` alone
377+
stays insert-exempt (a conditional lock has no prior record on a create). So an
378+
insert refusal is about a runtime-owned value — a caller-supplied record number —
379+
**or** a static `readonly` value from a non-system caller. With the option `true`
380+
that insert throws (`operation: 'insert'`, every taken field in one list) and
381+
nothing is written; without it the values are stripped, the write completes, a
382+
stripped `readonly` field falls back to its `defaultValue`, and `onFieldsDropped`
383+
fires once with `reason: 'readonly'`. The writers exempt — and therefore never
384+
refused — differ per strip, and that difference is the 2026-08-08 ruling, not this
385+
one: `isSystem` exempts both; the `preserveAudit` historical import reinstating
386+
legacy record numbers (#3493) exempts the runtime-owned strip only, and a
387+
non-system create that asks for it still has its static `readonly` fields stripped,
388+
with a `warn` saying the exemption is UPDATE-only (#6640).
379389

380390
<Callout type="warn">
381-
**Layering — this is the engine seam.** The exemption pair above is *this*
382-
in-process seam's. The DataProtocol ingress enforces its own author-declared
383-
`readonly` policy on create (#3043), where `preserveAudit` is UPDATE-only (#6640)
384-
and runtime-owned types are left to the engine strip — see `FieldSchema.readonly`.
385-
Nothing on this page widens or narrows that ingress policy.
391+
**Layering — this is the engine seam, and since the 2026-09-03 ruling the only
392+
one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`,
393+
`insertManyData`, `batchData`) forward the caller's payload whole and surface this
394+
seam's `onFieldsDropped` as their response `droppedFields` wherever their contract
395+
declares one; none carries a `readonly` policy of its own any more — see
396+
`FieldSchema.readonly`.
386397
</Callout>
387398

388399
<Callout type="warn">

0 commit comments

Comments
 (0)