Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
9 changes: 7 additions & 2 deletions content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading