Skip to content

Commit b280546

Browse files
fix(objectql)!: a caller-supplied formula value is stripped on every write path and reported as computed, and engine.validate runs the write doors (#20805) (#20834)
Fixes #20805 Clause-②: yes (narrowing) A caller-supplied value for a `formula` field is now stripped by the engine on every write path, in every context, and reported through `droppedFields` / `onFieldsDropped` under a new `reason` arm, `computed`. `ObjectQL.validate` now runs the write's own field doors, so a dry run built on it predicts what the write does. The REST half (the import runner's row report) stays on #20701; #20701 remains open. ## Reproduction first (base `5bed1f6caf`, real ObjectQL + `ObjectStackProtocolImplementation` + `RestServer` routes, SQLite `:memory:` and the in-memory driver) A throwaway probe (never committed) wrote to an object with `n: number`, `doubled: formula (record.n * 2)`, `ro: readonly text`, `task_count: summary`, as a non-system caller unless noted. The raw row was read from the driver, below the engine's formula hydration. | door | SQLite, before | memory, before | both drivers, after (`b21d13b05b`) | |:--|:--|:--|:--| | `protocol.createData` with `doubled: 5` | throws `SqliteError` `SQLITE_ERROR`, no status, no field | ok, `droppedFields` null, raw row stores `doubled: 5` | ok, `droppedFields: [{ fields: ['doubled'], reason: 'computed' }]`, raw row has no `doubled` | | `POST /api/v1/data/:object` with `doubled: 5` | `400 INVALID_FIELD` "Unknown field 'doubled'" (REST's driver-string branch, for a declared field) | `201`, raw row stores `doubled: 5` | `201`, the same `droppedFields`, header `doubled;reason=computed` | | `engine.update` by id with `doubled: 99` | throws `SqliteError` | ok, no drop, raw row stores `doubled: 99` | ok, one `computed` event, raw row has no `doubled`, read returns `doubled: 8` from `n: 4` | | `PATCH /api/v1/data/:object/:id` with `doubled: 7` | `400 INVALID_FIELD` | `200`, raw row stores `doubled: 7` | `200`, the same `droppedFields` | | `engine.insert`, `isSystem`, `doubled: 5` | throws `SqliteError` | ok, raw row stores `doubled: 5` | ok, one `computed` event, raw row has no `doubled` | | full read of a row, written back (`GET` then `PUT`) | throws `SqliteError` | ok | ok, `computed` for `doubled` beside the existing `readonly` drops | | `engine.validate` with `doubled: 5` | `valid: true` | `valid: true` | `valid: true`, and the listener receives the `computed` event | | `engine.validate` with `nope: 5` | `valid: true` while `insert` refused it | same | throws `INVALID_FIELD` / 400 / `field: 'nope'`, the envelope `insert` throws | | `engine.insert` / `update` with `task_count: 42` / `7` (summary, H4) | stored 42, then 7, then recomputed to 1 by a child write, no drop | same | unchanged: summary is persisted by the engine (see Acceptance notes) | ## What changed - **`packages/objectql/src/engine.ts`** - `stripComputedWriteFields` — a new module function beside `undeclaredWriteFieldErrors`: takes every own key naming a declared `formula` field out of the payload (copy, never mutating the caller's object) and returns the union of what it took. Keyed on `type === 'formula'` literally, the test `fieldHasColumn` and the read projection use. - `insert()` — runs it right after the declared-field door, over the rows that door did not refuse, with no `isSystem` gate; `opCtx.data` carries the stripped rows, so the snapshot, the defaults and the hooks all see the payload that is stored. The report joins the existing site (`insertDrops`: one `computed` event, then the existing `readonly` event), so `strictReadonlyWrites` refuses it by the derived coverage its contract states. - `update()` — the same door after the pre-hook declared-field door (before the read-only hide pass and the hook recording); reported with `reportDroppedFields(..., 'computed')` at the confluence after the post-hook door, beside the other strips' reports. `ctx.submitted` still carries the submission as sent. - `validate()` — runs, by the same functions and in the write's order: the declared-field door (throws, as `insert` does), the computed strip, and the caller-write strips under the write's `isSystem` gate (insert mode: `stripRuntimeOwnedFields`, then the static-`readonly` strip; update mode: `stripReadonlyFields`, with a supplied `id` read as the address). Drops go to a new optional `onFieldsDropped` option. The docblock's "second named limit" (a readonly reference field) is closed and rewritten. - `staticReadonlyCreateStrip` — the create path's static-`readonly` strip with its re-default, lifted out of `insert()`'s loop unchanged in behaviour so `validate` calls the same door rather than a copy (one function per door). - **`packages/spec/src/data/data-engine.zod.ts`** — the `computed` arm on `DroppedFieldsEventSchema.reason`, its docblock bullet and describe string. **`packages/spec/src/contracts/data-engine.ts`** — the `WriteObservabilityOptions` docblock named "all three arms"; it now names four (a comment-only edit, outside the claim's one-arm surface, because the sentence would otherwise be false). - **Consumers of `reason` (H3)** — the two exhaustive maps tsc re-checks: `REASON_PHRASE` in `packages/objectql/src/readonly-strict-errors.ts` (with a remedy clause spoken only when a `computed` drop is present, so every existing message is byte-identical; `computed` is deliberately not in `READONLY_CLASS_REASONS`) and `DROPPED_REASON_LABEL` in `packages/services/service-automation/src/builtin/crud-nodes.ts`. Reason-agnostic pass-throughs need no change: REST's `X-ObjectStack-Dropped-Fields` echo, metadata-protocol's `mergeDroppedFieldEvents`, the client SDK types. objectui is NOT MEASURED (not checked out in this container). - **Docs** — `content/docs/references/**` regenerated by `check:generated --fix` (the enum and describe string only); the two hand-written pages that enumerate the arms (`kernel/contracts/data-engine.mdx`, `protocol/objectql/security.mdx`); `permissions/system-context.mdx` gains row 21b for the preview's `isSystem` gate and amends row 22 (strict still refuses a formula value under `isSystem`), with the census counts regenerated by `gen:system-context-census`. - **Changeset** `.changeset/20805-formula-write-strip.md`: `@objectstack/spec` minor, `@objectstack/objectql` minor with the BREAKING banner, `@objectstack/service-automation` patch, one ADR-0087 disposition (`not-required (no-migration-prescription)`). ## Zone 2 hypotheses, as measured - **H1 — falsified in its "one place" half.** The existing strips live in several places: `insert()` has two strip passes (`stripRuntimeOwnedFields`, and the static strip over `staticReadonlyInsertSubject`) behind one `isSystem` gate, reporting at one site (`insertDropped`, reason `readonly`); `update()` has per-branch passes (primary key, `readonlyWhen`, static) on by-id and multi, reporting through the `reportDroppedFields` closure. So there are two report sites. The new strip's REPORT joins both; its STRIP sits at the declared-field door instead, because those passes are post-hook and `isSystem`-gated, while this one must run for system writers, must not hand hooks a value that will not be stored, and must be callable by `validate`, which runs no hooks. The function is `stripComputedWriteFields`. - **H2 — held**: `undeclaredWriteFieldErrors` judges undeclared keys only and `validate` called no door. Served as one function per door (`undeclaredWriteFieldErrors`, `stripComputedWriteFields`, `stripRuntimeOwnedFields`, `staticReadonlyCreateStrip`, `stripReadonlyFields`), each called by the write and by `validate`, not one combined judge. - **H3** — listed above; both exhaustive maps updated, and tsc reddens when one is not (reverse check below). - **H4 — summary is persisted by the engine**, measured on both drivers (table above), so it takes no arm and no pin here. ## Tests - New `packages/objectql/src/engine-formula-write-strip.test.ts` (19 cases, recording driver): insert single / batch / `insertMany` (a refused row counts toward nothing) / `isSystem`; update by id / multi / `isSystem`; a formula also declared `readonly` reported once as `computed`; controls (static `readonly` still `readonly`, writable fields land); the hooks' view and `ctx.submitted`; strict refusal on both verbs; `validate` in both modes, under `isSystem`, the unknown-key envelope equal on `validate` and `insert`, the caller-write strips' report, and the update-mode `id` address rule on an object whose `id` is declared readonly. - `packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts` gains a `#20805` block, one assertion run on both families (SQLite on a real table, in-memory): insert and update succeed, report `computed`, store no such key, `isSystem` included; `validate` reports the same and refuses an unknown key as `insert` does; controls. The memory arm lives in this file because it is a ruled `driver-memory` consumer, and the census counts declarations, so no ledger row moves. PostgreSQL is not a cell here: the one live-PostgreSQL job for `@objectstack/runtime` (Temporal Conformance, step "Run the runtime cascade-delete matrix against live PostgreSQL") runs only `cascade-delete-multivalue-lookup-real-driver`, and this file is not in it (sentence corrected by the seat after review 5909189785). - Runs (all `VERDICT command-exit 0` under the shared lock): objectql `local` 344 files / 6769 tests at `55c81da438` (objectql unchanged to `b21d13b05b`); spec `local` 578 files / 17065 tests and service-automation 157 files / 1974 tests at `06e16e23f0` (both unchanged to `b21d13b05b`); consumer suites at `c8b823cf16` — metadata-protocol `validate-data` + dropped-fields 4 files / 33, REST every `import-*` file + `rest-dropped-fields` + `rest-batch-endpoint` 22 files / 529, verify `handle.test.ts` 24; the runtime file 28 / 28 at `b21d13b05b`; typecheck (tsc + test layer) green for spec, objectql, service-automation and runtime. - **Reverse verification** (fix committed first; `scripts/ablation-replace.mjs` in wrap mode, restore trap armed): `stripComputedWriteFields` made to return before stripping (anchor 1 to 0, blob `2719ac6bc184` to `15ace97894c2`), objectql rebuilt, `ablation-dist-preflight` found the marker in 4 built files. Result: objectql pins 17 of 19 red (the two strip-independent cases — unknown key, nothing to strip — stayed green), runtime 4 of 28 red (both families' write and `validate` cells; the controls and every pre-existing case green). Restore: blob equal to HEAD, `git diff HEAD` empty, rebuilt, marker absent from all 14 built files, whole tree clean, pins 19 / 19 and 28 / 28. - **Type reverse check**: deleting `computed` from `DROPPED_REASON_LABEL` made `service-automation` tsc exit 2 with `TS2741: Property 'computed' is missing` — it reads the rebuilt spec declarations; restored byte-identical. - **Gates** at `b21d13b05b`: `node scripts/pm/dispatch-gates.mjs --commands` derived 116; all 116 run, each exit captured before any pipe, all exit 0; `--ran` reconciliation: 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN. Three that first answered `PREREQUISITE NOT MET` (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:type-check-debt`) were re-run green after `turbo run build --filter='./packages/*' --filter='./packages/*/*'`; `check:system-context-census` first reddened on the new `isSystem` read and is green with row 21b. - **Lint, narrowed**: `eslint --no-inline-config --format json` over the 7 TS files this diff touches reports 7 files, 0 errors, 0 warnings; `--print-config` shows no `parserOptions.project` / `projectService` on any of them (the config enables no type-aware linting), so the diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **`summary` (H4)**: a caller-supplied roll-up value is stored as sent and overwritten by the next child write, on both drivers, with no drop. That is the contract `RUNTIME_OWNED_FIELD_TYPES` and its docblock in `rule-validator.ts` record on purpose; not changed here. - **FLS runs before the strip**: plugin-security's field-write gate judges `opCtx.data` before the engine body, so a permission set that marks a `formula` field not editable would 403 a round trip before the strip runs — the same order a `readonly` field already has. Read, not measured. - **A hook-written formula key**: the door strips the caller's value before the hooks; a `before*` hook that writes a formula key itself passes the post-hook declared-field door (the field is declared) and still reaches the driver. Read, not measured; no producer found. - **Neighbouring describe strings**: the `droppedFields` describe strings in `api/batch.zod.ts` / `api/protocol.zod.ts` name only the read-only strips (already incomplete since `primary_key`); left as they are, outside this surface. - REST's driver-string branch still labels a missing column "Unknown field" for schema drift; the import runner's use of the new listener and its row report are #20701's. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fe463b4 commit b280546

14 files changed

Lines changed: 983 additions & 109 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/service-automation': patch
5+
---
6+
7+
A caller-supplied value for a `formula` field is stripped on every engine write path, in every context, and reported through `droppedFields` / `onFieldsDropped` under a new `reason`, `computed`; and `ObjectQL.validate` runs the write's own field doors, so a dry run built on it predicts what the write will do (#20805).
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) a change of runtime behaviour at the engine's write doors and its validate-only preview, plus one new arm on an OUTPUT enum. No authorable key, spelling, export or stored shape moves: `DroppedFieldsEvent` is an event the engine emits, never metadata an author writes, so widening its `reason` enum leaves every stored row and every authored file valid as it stands; `ObjectQL.validate` gains an optional listener and keeps its signature otherwise. The narrowing refuses, in the preview, a key the write already refused, so there is nothing for a ledger entry to rewrite: the payload was never writable. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a write payload's keys or a strip's report (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
12+
13+
**BREAKING**: `ObjectQL.validate` now refuses a row that carries a key the object does not declare, exactly as `insert` and `update` refuse it: the call throws `INVALID_FIELD` / 400 naming the field. It used to answer `valid: true` for that row while the write it previews refused it, so the protocol's `validateData` and the import dry run built on it said "ok" for rows the commit then failed. It ships as `minor` under the launch-window convention; the widening half is the new `reason` arm.
14+
15+
**A formula value is stripped, never refused.** A `formula` field is computed on every read and no driver has a column for it, so a full read returns the key and a record written back carries it: a form save, a flow's `update_record`, a `GET` then `PUT`. The key used to reach the driver, and the driver decided. On SQL drivers the whole write failed with the driver's own error (`SqliteError` "table … has no column named …", with no `status` and no `field`; the REST door relabelled it `400 INVALID_FIELD` "Unknown field" for a field the object declares). On the in-memory driver the value was stored as a shadow column nothing reads. Now the engine takes the value out before the defaults, the hooks and the other strips, completes the write, and reports one `{ reason: 'computed' }` event per call. That holds on `insert` (one row or a batch), `insertMany`, and `update` by id and by predicate, on every driver, and in every context, `isSystem` included: there is no column for any caller's value to land in. Measured on SQLite and the in-memory driver through `protocol.createData`, `POST /api/v1/data/:object`, `PATCH /api/v1/data/:object/:id` and `engine.update`: each now answers success with `droppedFields: [{ fields: ['doubled'], reason: 'computed' }]`, the stored row carries no such key, and the read still returns the computed value.
16+
17+
- **`computed` is not `readonly`.** `isSystem` exempts the static `readonly` strip and does not exempt this one. A `formula` field also declared `readonly: true` is reported once, as `computed`.
18+
- **`strictReadonlyWrites` refuses it.** That option's coverage is derived from what `onFieldsDropped` reports, so a caller that passes it and sends a formula value now gets `ERR_READONLY_FIELD_REJECTED` with a `computed` drop in `drops`, and nothing is written, `isSystem` included. The refusal message names the reason and its remedy; a refusal without a `computed` drop reads exactly as before.
19+
- **Hooks are handed the payload that will be stored.** A `beforeInsert` / `beforeUpdate` hook no longer sees the formula key in `ctx.input.data`; `ctx.submitted` on update still carries the caller's submission as sent.
20+
- **Consumers of `DroppedFieldsEvent['reason']` must handle `computed`.** The contract requires a branch on `reason` to be exhaustive. In this release the strict refusal message (`@objectstack/objectql`) and the flow `create_record` / `update_record` step warning (`@objectstack/service-automation`) word it.
21+
22+
**What `validate` runs now.** Before judging a row, `ObjectQL.validate` runs the write's own doors, by the same functions the write calls: the declared-field door (the refusal above), the computed-field strip, and the caller-write strips, under the write's `isSystem` gate (on `insert` mode the runtime-owned strip and the static `readonly` strip with its re-default; on `update` mode the static `readonly` strip, where a supplied `id` is the address the write binds and is never judged). What the write would drop is reported through a new optional `onFieldsDropped` listener on `validate`'s options, in the same events the write emits. One consequence for verdicts: a reference field declared static `readonly` is now stripped in the preview as it is on the write, so a validation rule that reads through it answers the same on both.
23+
24+
**Unchanged.** A `summary` field keeps its column: a caller-supplied roll-up value is still stored as sent and overwritten by the next write of a child record. The REST layer's own handling of a missing column (schema drift) is unchanged.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ The strips these two options cover are the engine's legal ones:
315315
| 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) |
316316
| Implicitly-readonly runtime-owned type (#5503 — `RUNTIME_OWNED_FIELD_TYPES`, today `autonumber`) | `readonly` | `insert` **and** `update` | `isSystem`, `preserveAudit` (#3493) |
317317
| Primary-key strip of a payload `id` the update dispatch already ruled is not an identifier (#6437) | `primary_key` | `update` | none |
318+
| A value for a `formula` field, which the engine computes on read and no driver has a column for (#20805) | `computed` | `insert` **and** `update` | none — every context, `isSystem` included |
318319

319320
Of the two AUTHOR-DECLARED strips only `readonlyWhen` is insert-exempt at this seam
320321
(a conditional lock has no prior record on a create); the static `readonly` strip runs
@@ -332,7 +333,7 @@ interface DroppedFieldsEvent {
332333
fields: string[]; // caller-supplied fields that were dropped
333334
// why they were dropped — an OPEN vocabulary that grows with the write
334335
// path's legal strips; branch on it exhaustively, never with a binary test
335-
reason: 'readonly' | 'readonly_when' | 'primary_key';
336+
reason: 'readonly' | 'readonly_when' | 'primary_key' | 'computed';
336337
}
337338
```
338339

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

Lines changed: 9 additions & 8 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 **106
13+
because the flag is not one concept: it is a single boolean read at **107
1414
distinct sites across 19 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,8 @@ that silently does not happen.
123123
| 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` |
124124
| 20 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `packages/objectql/src/engine.ts#update` |
125125
| 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-
| 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 | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
126+
| 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` |
127+
| 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` |
127128
| 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` |
128129
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no `sys_position` row is not refused | plugin-security | Get: a system writer can store an assignment naming no catalog row — the seed loader writes `stack.data` from `AppPlugin.start()`, before `kernel:ready` seeds the declared position catalog, so a refusal there would fail every authored assignment seed on a fresh boot. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when no catalog row the writer's context reaches carries the name: its organization's rows plus the organization-less ones, read as `{ ...context, isSystem: true }`, never a bare `{ isSystem: true }`, so a name only another organization carries is refused like one nobody carries. It is the same stand-down, and the same tenant scope, as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
129130
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` |
@@ -137,7 +138,7 @@ that silently does not happen.
137138

138139
### 3. Sharing (`plugin-sharing`)
139140

140-
The largest single consumer — **17 of the 106 sites**.
141+
The largest single consumer — **17 of the 107 sites**.
141142

142143
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
143144
|:--|:---|:---|:---|
@@ -280,7 +281,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
280281
independent decisions, and a seed loader plausibly wants the first two but not
281282
the third. The concept is nevertheless **staying as one boolean**:
282283

283-
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
284+
- **Shipped semantics.** `isSystem` is a published contract with 107 read sites
284285
in 19 packages. Splitting it is a breaking contract change across all of them.
285286
(The ruling was taken when the census read 80 sites in 18 packages; the count
286287
has grown, which strengthens rather than weakens the argument.)
@@ -354,16 +355,16 @@ still holds equal to the census on every pull request:
354355
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
355356
| — parsed as a declaration | 22 | ✅ |
356357
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
357-
| — parsed as a property **read** | 112 | ✅ |
358+
| — parsed as a property **read** | 113 | ✅ |
358359
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
359360
| — the remainder: text inside comments and string literals | 358 | — |
360361
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
361-
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
362-
| — behaviour-bearing (rows 1–61 above) | 103 | ✅ |
362+
| Of those reads: reads of `ExecutionContext.isSystem` | **107** | ✅ |
363+
| — behaviour-bearing (rows 1–61 above) | 104 | ✅ |
363364
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
364365
| Packages containing at least one elevation read | **19** | ✅ |
365366
| Files containing at least one elevation read | 45 | ✅ |
366-
| — the distinct symbols those reads live in — what this page anchors | 89 | ✅ |
367+
| — the distinct symbols those reads live in — what this page anchors | 90 | ✅ |
367368
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
368369

369370
The six rows marked — are a **dated decomposition, not a live claim**: they were

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,10 +366,12 @@ await data.update('attendance', { id, work_duration: 480 }, {
366366
```
367367

368368
`reason` is `'readonly'` for this static lock and `'readonly_when'` for a conditional
369-
[`readonlyWhen`](/docs/references/data/field) predicate. A third value,
370-
`'primary_key'`, reports the one legal strip that is **not** a read-only lock: an
371-
`update` payload whose `id` the engine has already ruled is not an identifier is
372-
dropped rather than written over the targeted row's primary key. The vocabulary is
369+
[`readonlyWhen`](/docs/references/data/field) predicate. Two more values report legal
370+
strips that are **not** read-only locks: `'primary_key'` — an `update` payload whose
371+
`id` the engine has already ruled is not an identifier is dropped rather than written
372+
over the targeted row's primary key — and `'computed'` — a value for a `formula` field,
373+
which the engine computes on every read and no driver has a column for, is dropped on
374+
every write in every context, `isSystem` included. The vocabulary is
373375
open — it grows as the write path gains legal strips — so branch on `reason`
374376
exhaustively rather than treating "not `readonly_when`" as "read-only". The listener is an in-process
375377
callback: it is delivered by the local engine, and does **not** cross the RPC / Virtual

0 commit comments

Comments
 (0)