Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
025a801
wip(objectql,metadata-protocol): move the create-side static readonly…
claude Sep 4, 2026
849f4e8
test(objectql,metadata-protocol): pin the in-engine create-side reado…
claude Sep 4, 2026
a45ae31
test(objectql): the referential-integrity narrowing, split into its p…
claude Sep 4, 2026
9178f36
fix(lint,metadata-protocol): drop the superseded INSERT-exempt premis…
claude Sep 4, 2026
3ffcf3d
docs(objectql,metadata-protocol,spec,lint): retire the ingress-strip …
claude Sep 4, 2026
2b39370
fix(objectql,rest): the create-path strip line states only what is tr…
claude Sep 4, 2026
95fb4c4
wip: suite runs
claude Sep 4, 2026
4935e0e
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
2159506
chore(objectql,lint,scripts): self-contained prose, engine-double pin…
claude Sep 4, 2026
2921eef
docs(permissions): the system-context census loses the ingress row it…
claude Sep 4, 2026
341baaf
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
7997126
test(objectql): pin the create-path preserveAudit line by its remedy,…
claude Sep 4, 2026
b6ce61b
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
3120aa6
chore: regenerate the system-context census line anchors after mergin…
claude Sep 4, 2026
0c52155
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
484cec1
chore(changeset): answer the ADR-0087 disposition and re-derive the r…
claude Sep 4, 2026
e885c44
test(example-todo): bind the app's own hook in the recurrence harness…
claude Sep 4, 2026
67d6144
docs(example-todo): the sibling's measurement table is history, not a…
claude Sep 4, 2026
7dbfaaf
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
bd598e8
chore(docs): re-anchor the system-context census after merging origin…
claude Sep 4, 2026
b5fb6d7
fix(metadata-protocol,lint,docs): patch round — wire batchData upsert…
claude Sep 5, 2026
da62e0e
test(objectql): rule-validator pin no longer justifies itself with th…
claude Sep 5, 2026
66c580b
chore(docs,qa): re-anchor the system-context census after the engine.…
claude Sep 5, 2026
e78c95c
docs(spec,rest,objectql,mcp,qa,scripts): the deleted ingress strip is…
claude Sep 5, 2026
485a2d5
chore(docs): regenerate the reference rows the describe edits moved; …
claude Sep 5, 2026
a73dd85
docs(spec,objectql,service-settings): the three PUBLISHED sites that …
claude Sep 5, 2026
b88b8f7
Merge origin/main into claude/issue-14147-engine-insert-readonly-strip
claude Sep 5, 2026
2f0262c
docs(permissions): re-anchor the system-context census on the merged …
claude Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/tidy-cups-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/objectql': minor
'@objectstack/metadata-protocol': minor
'@objectstack/service-automation': patch
'@objectstack/lint': patch
'@objectstack/spec': patch
'@objectstack/service-settings': patch
---

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

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.

- `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.
- `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.
- 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.
- `@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.
- `@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.

<!-- 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. -->
8 changes: 4 additions & 4 deletions content/docs/automation/hook-bodies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -252,23 +252,23 @@ A body may write *other* objects — e.g. `await ctx.api.object('parent').update

### Writing a `readonly` field

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.
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`.

`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:
`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:

| How the body writes it | What happens |
|:---|:---|
| `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. |
| `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. |
| `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. |
| `ctx.api.object('x').insert({ <field> })` | **Lands.** INSERT is exempt — a create may legitimately seed read-only columns. |
| `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.) |

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**:

- `hook-api-update-readonly-field` — **error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` writes a field the named object declares `readonly: true`.
- `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.

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).
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`.

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.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ These properties are available on all field types:
| `description` | `string` | — | Developer documentation |
| `inlineHelpText` | `string` | — | Help text shown in UI |
| `hidden` | `boolean` | `false` | Hide from default views |
| `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. |
| `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. |
| `sortable` | `boolean` | `true` | Allow sorting by this field |
| `group` | `string` | — | Group name for organizing in forms (e.g. `'billing'`) |

Expand Down
47 changes: 29 additions & 18 deletions content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,14 @@ The strips these two options cover are the engine's legal ones:

| Strip | `reason` | Verbs | Writers it skips |
|:---|:---|:---|:---|
| Static `readonly: true` (#2948) | `readonly` | `update` | `isSystem` |
| Static `readonly: true` (#2948; on `insert` too since the 2026-09-03 ruling, #14147) | `readonly` | `insert` **and** `update` | `isSystem` |
| 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) |
| Implicitly-readonly runtime-owned type (#5503 — `RUNTIME_OWNED_FIELD_TYPES`, today `autonumber`) | `readonly` | `insert` **and** `update` | `isSystem`, `preserveAudit` (#3493) |
| Primary-key strip of a payload `id` the update dispatch already ruled is not an identifier (#6437) | `primary_key` | `update` | none |

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

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

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

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

<Callout type="warn">
Expand Down
Loading
Loading