Skip to content

Commit 82cfaf7

Browse files
docs(data-modeling,kernel): a value a stored expression replaces is reported under stored_expression and refused under strictReadonlyWrites (#25172)
Fixes #25089 Clause-②: no ## What changed Hand-written docs only. Two pages said that a value a client sends for a stored field that declares `expression` is neither refused nor reported as dropped. Both halves are false on `main` (`b93c750c7`): - **Reported.** objectql reports the replaced value through the engine's `onFieldsDropped` listener under `reason: 'stored_expression'`. The data API answers that as `droppedFields`. This landed with PR #24778 (`7532ea3c08`, an ancestor of the base: `merge-base --is-ancestor` exit 0). - **Refused under strict.** Since PR #25086 (`03c12a52ef`, an ancestor: exit 0), an in-process write that passes `strictReadonlyWrites: true` is refused with `ERR_READONLY_FIELD_REJECTED`, and nothing is written. | Page | Before | After | |:---|:---|:---| | `content/docs/data-modeling/validation-rules.mdx` (`:368`) | "is not refused and not reported as dropped" | The value never stands. A value that differs from the computed one is reported under `stored_expression`. Sending the computed value, or no value, reports nothing. Under the in-process `strictReadonlyWrites` option the write is refused with `ERR_READONLY_FIELD_REJECTED`, and a request body cannot set that option. "The one refusal" (the predicate update) now reads "Without it, the one refusal". | | `content/docs/data-modeling/field-types.mdx` (`:424`, the `expression` on a stored field section) | "with no refusal and no dropped-field report" | The same correction. This page carried the same stale claim as `validation-rules.mdx`; both were written by `4a2ca57e7`. | | `content/docs/kernel/contracts/data-engine.mdx` | The strip table had no `stored_expression` row, and the `DroppedFieldsEvent.reason` union listed four arms | A `stored_expression` row in the existing columns, with Verbs `insert` and `update` and Writers it skips "none — every context, `isSystem` included". One sentence follows the table: the row is a replacement, not a removal. `'stored_expression'` is added to the `reason` union in the marked (type-checked) block, matching `DroppedFieldsEvent.reason` in `packages/spec/src/data/data-engine.zod.ts`. | | `content/docs/permissions/system-context.mdx` (row 22, "Strict-drop refusal never fires" under `isSystem`) | "One exception: a `formula` value ... so strict still refuses it" | "Two exceptions": the `formula` value and the `stored_expression` replacement. Under `isSystem`, strict also refuses the replacement, so "one exception" is the same stale "not refused" claim in another place. | ## Evidence: source reads at `b93c750c7` - **Report.** `packages/objectql/src/engine.ts` `droppedFieldEvents` pushes `reason: 'stored_expression'`. `replacedStoredExpressionFields` reports a field only when the caller sent it and the stored value differs from it, so sending the computed value reports nothing. The update by id and the predicate update both call `emitDrop(..., 'stored_expression')`. `packages/metadata-protocol/src/protocol.ts` forwards every `onFieldsDropped` event as `droppedFields` from `createData`, `cloneData`, `updateData`, `createManyData`, `insertManyData` and `updateManyData`. `packages/rest/src/rest-stored-expression-dropped-fields.test.ts` pins the PATCH and `/batch` doors. - **Refusal.** The insert path throws `ReadonlyFieldRejectedError` when `insertDrops`, which include the replacement, is non-empty and `options?.strictReadonlyWrites === true`. On update, `emitDrop` pushes into `strictDrops` and `assertNoStrictDrops` throws. Neither path checks `isSystem`, and `engine-stored-expression-dropped-fields.test.ts` pins the `isSystem` report. The code is `READONLY_FIELD_REJECTED_CODE = 'ERR_READONLY_FIELD_REJECTED'` (`packages/objectql/src/readonly-strict-errors.ts`), registered in `ERROR_CODE_LEDGER`. - **No HTTP status.** No package source passes `strictReadonlyWrites: true`, and the option is not in the serializable options bag. The `packages/spec/src/contracts/data-engine.ts` section "In-process only — what a REMOTE caller observes" says so. For that reason the pages give no HTTP status for this refusal. ## Sites checked and left unchanged - `git grep stored_expression` over hand-written `content/docs` (not `references/`, not `releases/`) has zero hits before this change. As a control, `strictReadonlyWrites` has 4 hits on `data-engine.mdx`. - A whole-tree `git grep` for the stale phrasings ("not reported as dropped", "no dropped-field report", "not refused and not", "no refusal and no") hits only the two pages above, plus unrelated subjects: hook-stamped fields, nested views and similar. - `FieldSchema.expression` (`packages/spec/src/data/field.zod.ts`) says "a client-sent value never stands", which is accurate. The `engine.ts:3036` docblock is accurate too. - `data-engine.mdx` already says, in its strict section, that every row in the table is refused and that a new `reason` adds a refusal. With the new row that sentence is true as written, so it is not changed. ## Gates These were run locally after the last commit, at head `b7a6d9449` (merge base `b93c750c7`). The gate list comes from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths, which derives 44 commands. - All 44 exited 0. Exit codes were captured before any pipe. - Reconciliation reads: `dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED (a DERIVED zero — all 44 recorded an exit code and none of them is 3)`. - The first pass of four gates exited 3 (`PREREQUISITE NOT MET`) because the packages they read were not built: `check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift` and `check:skill-examples`. No measurement was taken in those runs. After building `@objectstack/spec`, the `@objectstack/lint` closure, `@objectstack/formula` and the `@objectstack/client-react` closure, all four were re-run and exited 0. - Verdict lines include: - `check-doc-anchors: 570 internal #fragment link(s) ... all resolve to a real heading`, which covers the two new `#writeobservabilityoptions` links. - `check:skill-examples: 262 prose examples type-check across 3 surface(s)`, which covers the edited marked block on `data-engine.mdx`. - `check:docs: 227 generated files in sync`. - `check-system-context-census: OK`. - `check-nul-bytes: OK`. - No package build is owed by the diff itself; the builds above were only for gates that read `dist/`. ## Changeset `skip-changeset`. The diff touches only `content/docs/**` (not `releases/`). No published package's `files[]` includes a `content/` path or a path outside the package. Every non-private `package.json` was read, and `@objectstack/objectql` ships `dist`, `README.md` and `CHANGELOG.md`. The docs site `@objectstack/docs` is `private: true`. The generators that name `content/docs` write into it and read nothing from these pages. ## Acceptance notes - **Out of scope, reported to the seat to file** (published skill text, a governed surface). `skills/objectstack-data/rules/relationships.md:296` says the server treats only `type: 'formula'` as computed. It goes on: on a stored field the `expression` is a client compute hint and the sent value is stored verbatim. That contradicts `FieldSchema.expression` ("a client-sent value never stands") and the engine, which computes and stores the value on every write. The text ships in `@objectstack/skills`. It is not changed here because `skills/**` is Tier H. --- _Generated by [Claude Code](https://claude.ai/code/session_01T1Y1g92DfSEz93iuCisju4)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c24203f commit 82cfaf7

4 files changed

Lines changed: 10 additions & 5 deletions

File tree

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ Calculated field using a CEL expression (see [Expressions](/docs/data-modeling/f
421421

422422
#### `expression` on a stored field
423423

424-
`expression` is not only a `formula` key. On a field of any other type, the engine computes it from the row each write stores and **stores** the result in the field's column, so a `summary` can roll the column up. It is computed on every insert and every update by id (the stored row merged with the patch). On a predicate update it is computed only when the patch moves an input: one value is stored for every matched row, and the update is refused (`VALIDATION_FAILED`, an `invalid_value` error on the field) when the rows would compute different values. Update those rows by id instead. A value a client sends for the field never stands: it is overwritten by the computed one, with no refusal and no dropped-field report. The full contract is the `expression` row of [`FieldSchema`](/docs/references/data/field).
424+
`expression` is not only a `formula` key. On a field of any other type, the engine computes it from the row each write stores and **stores** the result in the field's column, so a `summary` can roll the column up. It is computed on every insert and every update by id (the stored row merged with the patch). On a predicate update it is computed only when the patch moves an input: one value is stored for every matched row, and the update is refused (`VALIDATION_FAILED`, an `invalid_value` error on the field) when the rows would compute different values. Update those rows by id instead. A value a client sends for the field never stands: it is overwritten by the computed one. When the value sent differs from the computed one, the write still succeeds and reports the field as dropped, under `reason: 'stored_expression'` (the data API's `droppedFields`); sending the computed value, or no value, reports nothing. An in-process caller that passes the engine write option `strictReadonlyWrites: true` is refused instead, with `ERR_READONLY_FIELD_REJECTED`, and nothing is written; a request body cannot set that option (see [`WriteObservabilityOptions`](/docs/kernel/contracts/data-engine#writeobservabilityoptions)). The full contract is the `expression` row of [`FieldSchema`](/docs/references/data/field).
425425

426426
```typescript
427427
// Stored, so a parent `summary` can sum it. A client-sent amount is overwritten.

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ application-level emptiness check is needed.
365365
}
366366
```
367367

368-
**`expression` on a stored field.** A field of any other type may declare `expression` too, and it validates differently from a `formula`. The engine computes the value on every insert and every update by id, and stores it. A value a client sends for the field is not refused and not reported as dropped: it is overwritten by the computed one. The one refusal is a predicate update that moves an input: when the matched rows would compute different values, the update is refused with `VALIDATION_FAILED` and an `invalid_value` error naming the field, and nothing is written. See [`expression` on a stored field](/docs/data-modeling/field-types#expression-on-a-stored-field) and the `expression` row of [`FieldSchema`](/docs/references/data/field).
368+
**`expression` on a stored field.** A field of any other type may declare `expression` too, and it validates differently from a `formula`. The engine computes the value on every insert and every update by id, and stores it. A value a client sends for the field never stands: it is overwritten by the computed one, and the write still succeeds. When the value sent differs from the computed one, the write reports the field as dropped, under `reason: 'stored_expression'`: the engine's `onFieldsDropped` listener hears it, and the data API answers it as `droppedFields`. A client that sends the computed value, or no value, is told nothing. An in-process caller that passes the engine write option `strictReadonlyWrites: true` is refused instead, with `ERR_READONLY_FIELD_REJECTED`, and nothing is written; a request body cannot set that option (see [`WriteObservabilityOptions`](/docs/kernel/contracts/data-engine#writeobservabilityoptions)). Without it, the one refusal is a predicate update that moves an input: when the matched rows would compute different values, the update is refused with `VALIDATION_FAILED` and an `invalid_value` error naming the field, and nothing is written. See [`expression` on a stored field](/docs/data-modeling/field-types#expression-on-a-stored-field) and the `expression` row of [`FieldSchema`](/docs/references/data/field).
369369

370370
### `summary`
371371

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,11 @@ The strips these two options cover are the engine's legal ones:
346346
| Implicitly-readonly runtime-owned type (#5503 — `RUNTIME_OWNED_FIELD_TYPES`, today `autonumber`) | `readonly` | `insert` **and** `update` | `isSystem`, `preserveAudit` (#3493) |
347347
| Primary-key strip of a payload `id` the update dispatch already ruled is not an identifier (#6437) | `primary_key` | `update` | none |
348348
| 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 |
349+
| A value for a stored field (any type but `formula`) that declares `expression`, when it differs from the value the engine computes on every write and stores in its place | `stored_expression` | `insert` **and** `update` | none — every context, `isSystem` included |
350+
351+
The `stored_expression` row is a replacement, not a removal: the field is written,
352+
with the computed value in place of the caller's. A caller that sends the computed
353+
value, or no value for the field, is neither reported nor refused.
349354

350355
Of the two AUTHOR-DECLARED strips only `readonlyWhen` is insert-exempt at this seam
351356
(a conditional lock has no prior record on a create); the static `readonly` strip runs
@@ -361,10 +366,10 @@ interface WriteObservabilityOptions {
361366

362367
interface DroppedFieldsEvent {
363368
object: string; // resolved object name
364-
fields: string[]; // caller-supplied fields that were dropped
369+
fields: string[]; // caller-supplied fields whose value was not stored
365370
// why they were dropped — an OPEN vocabulary that grows with the write
366371
// path's legal strips; branch on it exhaustively, never with a binary test
367-
reason: 'readonly' | 'readonly_when' | 'primary_key' | 'computed';
372+
reason: 'readonly' | 'readonly_when' | 'primary_key' | 'computed' | 'stored_expression';
368373
}
369374

370375
interface ValidationAdvisoryEvent {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ that silently does not happen.
125125
| 20 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `packages/objectql/src/engine.ts#update` |
126126
| 21 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped. A second read in the same symbol gates the pass that withholds those caller values from `beforeInsert` before the strip, the same exemption, so a system caller's hook still sees what it sent | `packages/objectql/src/engine.ts#insert` |
127127
| 21b | **`readonly` strip bypassed — the write PREVIEW (`ObjectQL.validate`)** | objectql | Same as 19–21, in the preview: it runs the write's caller-write strips under the write's own gate, so a system preview reports no `readonly` drop and judges the value a system write would store. The `computed` strip of a `formula` value is NOT gated here or on either write verb — no driver has a column for it, so it runs for every caller | `packages/objectql/src/engine.ts#validate` |
128-
| 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` |
128+
| 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. Two exceptions, both judged in every context: a `formula` value, which the `computed` strip takes from every caller, and a value for a stored field that declares `expression` when it differs from the value the engine computes in its place (`stored_expression`), so strict still refuses both | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
129129
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
130130
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. 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 the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
131131
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `#onUpdate` |

0 commit comments

Comments
 (0)