From b7a6d944917b8930198a26d6b8b983f6d4dbfd2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 05:04:01 +0000 Subject: [PATCH] docs(data-modeling,kernel): a value a stored expression replaces is reported under stored_expression and refused under strictReadonlyWrites validation-rules.mdx and field-types.mdx said a client value for a stored field that declares `expression` is neither refused nor reported as dropped. The engine reports it through `onFieldsDropped` under `reason: 'stored_expression'` (the data API's `droppedFields`), and an in-process `strictReadonlyWrites: true` write is refused with `ERR_READONLY_FIELD_REJECTED`. data-engine.mdx gains the `stored_expression` row in its strip table and in the `DroppedFieldsEvent` reason union; system-context.mdx row 22 names the second strict exception that holds under `isSystem`. Claude-Session: https://claude.ai/code/session_01T1Y1g92DfSEz93iuCisju4 Co-authored-by: Claude --- content/docs/data-modeling/field-types.mdx | 2 +- content/docs/data-modeling/validation-rules.mdx | 2 +- content/docs/kernel/contracts/data-engine.mdx | 9 +++++++-- content/docs/permissions/system-context.mdx | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 321d5fc400d..522831f1b8f 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -421,7 +421,7 @@ Calculated field using a CEL expression (see [Expressions](/docs/data-modeling/f #### `expression` on a stored field -`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). +`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). ```typescript // Stored, so a parent `summary` can sum it. A client-sent amount is overwritten. diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index e82ddbd7be8..8ae639c2a56 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -365,7 +365,7 @@ application-level emptiness check is needed. } ``` -**`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). +**`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). ### `summary` diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index 88bad477736..b2db312eaca 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -346,6 +346,11 @@ The strips these two options cover are the engine's legal ones: | 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 | | 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 | +| 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 | + +The `stored_expression` row is a replacement, not a removal: the field is written, +with the computed value in place of the caller's. A caller that sends the computed +value, or no value for the field, is neither reported nor refused. 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 @@ -361,10 +366,10 @@ interface WriteObservabilityOptions { interface DroppedFieldsEvent { object: string; // resolved object name - fields: string[]; // caller-supplied fields that were dropped + fields: string[]; // caller-supplied fields whose value was not stored // why they were dropped — an OPEN vocabulary that grows with the write // path's legal strips; branch on it exhaustively, never with a binary test - reason: 'readonly' | 'readonly_when' | 'primary_key' | 'computed'; + reason: 'readonly' | 'readonly_when' | 'primary_key' | 'computed' | 'stored_expression'; } interface ValidationAdvisoryEvent { diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 8c25d831558..13e16cd89f5 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -125,7 +125,7 @@ that silently does not happen. | 20 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `packages/objectql/src/engine.ts#update` | | 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` | | 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` | -| 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` | +| 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` | | 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` | | 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` | | 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` |