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
63 changes: 63 additions & 0 deletions .changeset/dropped-fields-primary-key-reason.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/service-automation": patch
---

feat(spec,objectql): `DroppedFieldsEvent.reason` names the dispatch-ruled id strip (#6437)

The write path's strip-observability seam declared a narrower vocabulary than
the strips it reports on. `DroppedFieldsEvent.reason` was a closed enum over the
two READ-ONLY strips (`readonly` #2948 / `readonly_when` #3042), so the
primary-key strip added by #6262 / PR #6433 (multi branch) and #6435 (by-id
branch) — a `data.id` the update dispatch has ALREADY RULED is not a primary
key, removed from the SET payload before it can overwrite the targeted rows'
identity — was invisible to `onFieldsDropped` and to `strictReadonlyWrites`.
Both PRs were right to refuse the alternative: force-fitting `readonly` would
make `reason` lie, which is worse than silence. This adds the value instead.

**New reason: `primary_key`.** It names the FIELD's role, not the offending
value's shape, so it stays true if the strip ever widens to the same-value
truthy-scalar no-op the engine deliberately leaves alone today —
`not_a_primary_key` would describe the value and become false that day. The
house rule it follows is #5503's, applied in the other direction: a new arm is
warranted exactly when no existing arm is truthful. #5503 reported the
implicitly-readonly runtime-owned strip as plain `readonly` because that *was*
true of it; `readonly` is not true of an `id` (a truthy scalar `id` writes
fine), so this one gets its own value.

**⚠️ Behaviour change, deliberate and measured: `strictReadonlyWrites` gains a
new refusal.** The option's contract says it covers "every drop
`onFieldsDropped` reports" — coverage DERIVED from the reported set, never an
enumeration frozen at #5126, and confirmed by reading `reportDroppedFields` on
`main`, whose `strictDrops.push` applies no reason-class filter. So reporting a
new reason necessarily refuses it. A caller that passes
`strictReadonlyWrites: true` **and** puts a ruled-non-key value in `data.id` now
gets `ERR_READONLY_FIELD_REJECTED` where it previously got a success whose `id`
had been silently dropped. That is the option's whole promise ("don't
half-apply my payload") reaching one more strip class, and it is the outcome the
flag's own doc now states. Nothing else moves: default-mode callers still get a
successful write plus an event, the strip itself is unchanged, and
`strictReadonlyWrites` is in-process only (`WriteObservabilityOptions`), so no
REST/wire caller can reach either behaviour.

**The refusal error no longer describes every rejection as read-only.**
`ReadonlyFieldRejectedError` composed one sentence ("… are read-only and would
have been stripped", remedied by `{ context: { isSystem: true } }`) that is
false for a `primary_key` drop — `isSystem` does not exempt that strip. The
message is now built from the `drops` breakdown the error already carried, so it
names each reason against its own fields and offers the right remedy. The
**read-only-only message is byte-identical** to #5126's / #5503's text (pinned
directly), the error `code` is unchanged, and adding a reason deliberately does
not add an error code: callers catch one code and read `drops`.

Consumers that branch on `reason` were swept. `service-automation`'s flow-step
warning map is a `Record<DroppedFieldsEvent['reason'], string>`, so tsc demanded
the new wording — the loud shape, kept that way on purpose. The protocol
responses that carry `droppedFields` (`api/batch.zod.ts`, `api/protocol.zod.ts`
×3, plus the cross-object batch extension) all derive from
`DroppedFieldsEventSchema` and widen transitively; REST's
`X-ObjectStack-Dropped-Fields` header is generic over the reason and needed no
change. One consumer does NOT widen safely and is filed rather than fixed here:
objectui's `writeWarningToast` picks its wording with a binary ternary whose
`else` arm would announce a stripped `id` as "Read-only" (objectui#3935).
8 changes: 5 additions & 3 deletions content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,11 @@ interface WriteObservabilityOptions {
}

interface DroppedFieldsEvent {
object: string; // resolved object name
fields: string[]; // caller-supplied fields that were dropped
reason: 'readonly' | 'readonly_when'; // why they were dropped
object: string; // resolved object name
fields: string[]; // caller-supplied fields that were dropped
// 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';
}
```

Expand Down
7 changes: 6 additions & 1 deletion content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,12 @@ await data.update('attendance', { id, work_duration: 480 }, {
```

`reason` is `'readonly'` for this static lock and `'readonly_when'` for a conditional
[`readonlyWhen`](/docs/references/data/field) predicate. The listener is an in-process
[`readonlyWhen`](/docs/references/data/field) predicate. A third value,
`'primary_key'`, reports the one legal strip that is **not** a read-only lock: an
`update` payload whose `id` the engine has already ruled is not an identifier is
dropped rather than written over the targeted row's primary key. The vocabulary is
open — it grows as the write path gains legal strips — so branch on `reason`
exhaustively rather than treating "not `readonly_when`" as "read-only". The listener is an in-process
callback: it is delivered by the local engine, and does **not** cross the RPC / Virtual
Data Engine boundary, so a remote caller never receives these events. Without a listener,
the only trace is a server-side `WARN` naming the object, the field, and both remedies.
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/api/batch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const result = BatchConfigSchema.parse(data);
| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). |
| **data** | `Record<string, any>` | optional | Full record data (if returnRecords=true) |
| **index** | `number` | optional | Index of the record in the request array |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. |


---
Expand Down Expand Up @@ -143,7 +143,7 @@ A cross-object batch strip event: dropped fields plus the operation index
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object the write targeted (resolved object name) |
| **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload |
| **reason** | `Enum<'readonly' \| 'readonly_when'>` | ✅ | Why the fields were dropped: static readonly (#2948) or a TRUE readonlyWhen predicate (#3042) |
| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) |
| **index** | `integer` | ✅ | Index of the operation in the request `operations` array |


Expand Down Expand Up @@ -182,7 +182,7 @@ A cross-object batch strip event: dropped fields plus the operation index
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **results** | `any[]` | ✅ | Per-operation result, index-aligned with the request operations |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'>; index: integer }[]` | optional | Write-observability (#3407/#3431/#3455/#3794): caller-supplied fields the engine LEGALLY stripped from an operation before it was written — static `readonly` (#2948) or a TRUE `readonlyWhen` predicate (#3042). This endpoint is the console record form's save path (master-detail writes parent + children in one transaction), so without it the ONE surface where a user edits a `readonlyWhen` field reported plain success while the value never landed. Each event carries the `index` of its operation. Present ONLY when ≥1 field was dropped; the batch still committed without them (results/success semantics unchanged). Optional — omit-when-empty keeps the shape backward-compatible. |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'>; index: integer }[]` | optional | Write-observability (#3407/#3431/#3455/#3794): caller-supplied fields the engine LEGALLY stripped from an operation before it was written — static `readonly` (#2948) or a TRUE `readonlyWhen` predicate (#3042). This endpoint is the console record form's save path (master-detail writes parent + children in one transaction), so without it the ONE surface where a user edits a `readonlyWhen` field reported plain success while the value never landed. Each event carries the `index` of its operation. Present ONLY when ≥1 field was dropped; the batch still committed without them (results/success semantics unchanged). Optional — omit-when-empty keeps the shape backward-compatible. |


---
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ const result = AiAgentCapabilitiesSchema.parse(data);
| **object** | `string` | ✅ | The object name. |
| **id** | `string` | ✅ | The ID of the newly created record. |
| **record** | `Record<string, any>` | ✅ | The created record, including server-generated fields (created_at, owner). |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431): caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (#3043 ingress strip), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431): caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (#3043 ingress strip), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. |


---
Expand All @@ -377,7 +377,7 @@ const result = AiAgentCapabilitiesSchema.parse(data);
| **object** | `string` | ✅ | Object name |
| **records** | `Record<string, any>[]` | ✅ | Created records |
| **count** | `number` | ✅ | Number of records created |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied `readonly` fields the #3043 create-ingress strip removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the union of dropped field names) rather than per-row, because the insert-time strip is static-`readonly` only — schema-uniform, so every row drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied `readonly` fields the #3043 create-ingress strip removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the union of dropped field names) rather than per-row, because the insert-time strip is static-`readonly` only — schema-uniform, so every row drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) |


---
Expand Down Expand Up @@ -1469,7 +1469,7 @@ Uninstall package response
| **object** | `string` | ✅ | Object name |
| **id** | `string` | ✅ | Updated record ID |
| **record** | `Record<string, any>` | ✅ | Updated record |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431): caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` (#2948) or a TRUE `readonlyWhen` predicate (#3042). Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431): caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` (#2948) or a TRUE `readonlyWhen` predicate (#3042). Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object the write targeted (resolved object name) |
| **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload |
| **reason** | `Enum<'readonly' \| 'readonly_when'>` | ✅ | Why the fields were dropped: static readonly (#2948) or a TRUE readonlyWhen predicate (#3042) |
| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) |


---
Expand Down
Loading
Loading