Skip to content

Commit 028071c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14378-translate-meta-item-comment
Conflict: content/docs/permissions/system-context.mdx. main renumbered the elevation table (rows 49..61 became 48..60) and rewrote the cross-reference prose with it ("row 51's doors" became "row 50's doors"), while this branch had rewritten five absolute line anchors in the row that moved. The `os-regen` merge driver declined to defer -- the file is MIXED, a generated half plus hand-written prose -- and text-merged, which conflicted. Resolved by taking main's side of the file whole (byte-identical to origin/main), which keeps its renumbering and its prose intact and discards only the five anchor numbers, then re-deriving those from the merged tree with `node scripts/check-system-context-census.mjs --fix`. No line number was hand-edited and no prose was hand-merged. The conflicting path was identified with a driver-free instrument -- a `--bare --shared` clone with no `merge.os-regen.driver` configured, which is GitHub's condition -- because setting the driver to empty in the real checkout makes git fail to execute it and report a conflict on every path routed to it. The probe named exactly one conflicting path, and its control (the same probe against the branch's own merge base) returned clean, so the instrument discriminates. Structurally re-verified at this head rather than reusing the previous reading, because main's own numbers moved (107 sites became 106, 141 anchors became 140, and that is main's change, not this branch's). Comparing the merged tree against the merged tree carrying main's `rest-server.ts`: sites 106 = 106, identifierAppearances 885 = 885, classified 462 = 462, roleCounts.read 112 = 112, staleLedgerRows 0 = 0, and ARRIVED = 0 with VANISHED = 0 on the position-blind multiset of (file, receiver, package, text). Control `scannedFiles` = 293 on both sides, non-zero and re-validated in the same run. Only movement positional: per-site delta 0 -1 -1 -1 -1 -1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
2 parents 09a5012 + b398ad2 commit 028071c

78 files changed

Lines changed: 2386 additions & 980 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.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: an evaluated expression slot requires a non-blank `source``EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `source` keeps its name and meaning, and every envelope that carried a non-blank `source` parses byte-identically. The two newly refused spellings — an envelope carrying only `ast`, and a `source` that is blank after trimming — never evaluated on any release (the `ast`-only one faulted at run time with the engine's own "persist `source`" prescription, the blank one with a parse error), and a repo-wide census found no in-repo instance of either, so `objectstack migrate meta` has nothing to rewrite and the remedy is authoring a `source`, which the refusal itself prescribes. -->
8+
9+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
10+
(the lockstep convention): on the schemas that type an EVALUATED expression
11+
slot — today the `assignment` node's value envelope,
12+
`AssignmentExpressionValueSchema` — an envelope with no `source` the engine can
13+
evaluate is now **refused at authoring**, where it used to parse, register,
14+
pass `objectstack validate`, and then fault at run time.
15+
16+
Two spellings of one seam, refused by ONE rule with one message at `source`
17+
(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`):
18+
19+
```yaml
20+
assignments:
21+
digest: { dialect: cel, ast: { kind: const } } # `ast` only — no engine evaluates it
22+
greeting: { dialect: cel, source: ' ' } # blank after trimming — parses to EOF
23+
```
24+
25+
> An expression in an evaluated slot needs a non-blank `source`: the expression
26+
> engine evaluates `source` (the canonical persisted form of phase M9.1) and
27+
> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source`
28+
> that is blank after trimming, would validate and register and then fault at
29+
> run time. Write `{ dialect: 'cel', source: '…' }`.
30+
31+
- **`ExpressionSchema` is NOT narrowed.** It is the persistence contract —
32+
`source` OR `ast` — and its docblock declares that `ast` becomes required in
33+
build output at phase M9.2. The new export `EvaluatedExpressionSchema` (and
34+
its type `EvaluatedExpression`) is a sibling: the same envelope with `source`
35+
required and non-blank, spelled once and composed by every evaluated slot, so
36+
when AST-only evaluation lands the flip is one edit there rather than a
37+
per-slot unwinding. The rule is worded as "an evaluated slot requires whatever
38+
the engine can actually evaluate"; what that is today is `source`.
39+
- **The notion of blank is the engine's own** — `.trim()`, which
40+
`cel-engine.ts`'s helpers already apply — not a third one beside the shape
41+
rule's `min(1)` and `validateExpression`'s trim.
42+
- **Three doors agree.** `registerFlow` refuses the flow, `objectstack validate`
43+
and the runtime publish gate report a located `error` at the author's own
44+
variable (`config.assignments.<name>.source`), and the executor's own shape
45+
pass refuses the same set — all through the spec schema, so none of them
46+
grew a rule of its own.
47+
48+
**What an author does with a refused envelope.** An assignment value that
49+
carried only `ast` has no evaluable form under M9.1: author its `source`. A
50+
whitespace-only `source` was never an expression: delete the entry, or write
51+
the expression. Every envelope with a non-blank `source` is unchanged, and
52+
nothing is renamed, retired or rewritten — the refusal itself carries the
53+
prescription.
54+
55+
Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s
56+
silent `false` on an envelope without a `source` — is a behaviour change on a
57+
live path with its own card, and the edge-condition schema that carries that
58+
envelope is narrowed in a follow-up once the in-flight change to
59+
`automation/flow.zod.ts` lands.

.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

0 commit comments

Comments
 (0)