Skip to content

Commit 3dbfc3a

Browse files
committed
feat(spec,objectql): DroppedFieldsEvent.reason names the dispatch-ruled id strip (#6437)
`DroppedFieldsEvent.reason` was a closed enum over the two READ-ONLY strips, so the primary-key strip added by #6262 / PR #6433 (multi) and #6435 (by-id) was invisible to `onFieldsDropped` and `strictReadonlyWrites`. Adds `primary_key` and routes both strip sites through `reportDroppedFields`. Because `strictReadonlyWrites` coverage is DERIVED from the reported set (measured: `strictDrops.push` applies no reason-class filter), reporting the new reason also adds a refusal — deliberate, documented, pinned both ways. The refusal message is now composed from `drops` so a `primary_key` rejection never claims the field was read-only; the read-only-only message stays byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
1 parent f5a9bc2 commit 3dbfc3a

14 files changed

Lines changed: 665 additions & 72 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/service-automation": patch
5+
---
6+
7+
feat(spec,objectql): `DroppedFieldsEvent.reason` names the dispatch-ruled id strip (#6437)
8+
9+
The write path's strip-observability seam declared a narrower vocabulary than
10+
the strips it reports on. `DroppedFieldsEvent.reason` was a closed enum over the
11+
two READ-ONLY strips (`readonly` #2948 / `readonly_when` #3042), so the
12+
primary-key strip added by #6262 / PR #6433 (multi branch) and #6435 (by-id
13+
branch) — a `data.id` the update dispatch has ALREADY RULED is not a primary
14+
key, removed from the SET payload before it can overwrite the targeted rows'
15+
identity — was invisible to `onFieldsDropped` and to `strictReadonlyWrites`.
16+
Both PRs were right to refuse the alternative: force-fitting `readonly` would
17+
make `reason` lie, which is worse than silence. This adds the value instead.
18+
19+
**New reason: `primary_key`.** It names the FIELD's role, not the offending
20+
value's shape, so it stays true if the strip ever widens to the same-value
21+
truthy-scalar no-op the engine deliberately leaves alone today —
22+
`not_a_primary_key` would describe the value and become false that day. The
23+
house rule it follows is #5503's, applied in the other direction: a new arm is
24+
warranted exactly when no existing arm is truthful. #5503 reported the
25+
implicitly-readonly runtime-owned strip as plain `readonly` because that *was*
26+
true of it; `readonly` is not true of an `id` (a truthy scalar `id` writes
27+
fine), so this one gets its own value.
28+
29+
**⚠️ Behaviour change, deliberate and measured: `strictReadonlyWrites` gains a
30+
new refusal.** The option's contract says it covers "every drop
31+
`onFieldsDropped` reports" — coverage DERIVED from the reported set, never an
32+
enumeration frozen at #5126, and confirmed by reading `reportDroppedFields` on
33+
`main`, whose `strictDrops.push` applies no reason-class filter. So reporting a
34+
new reason necessarily refuses it. A caller that passes
35+
`strictReadonlyWrites: true` **and** puts a ruled-non-key value in `data.id` now
36+
gets `ERR_READONLY_FIELD_REJECTED` where it previously got a success whose `id`
37+
had been silently dropped. That is the option's whole promise ("don't
38+
half-apply my payload") reaching one more strip class, and it is the outcome the
39+
flag's own doc now states. Nothing else moves: default-mode callers still get a
40+
successful write plus an event, the strip itself is unchanged, and
41+
`strictReadonlyWrites` is in-process only (`WriteObservabilityOptions`), so no
42+
REST/wire caller can reach either behaviour.
43+
44+
**The refusal error no longer describes every rejection as read-only.**
45+
`ReadonlyFieldRejectedError` composed one sentence ("… are read-only and would
46+
have been stripped", remedied by `{ context: { isSystem: true } }`) that is
47+
false for a `primary_key` drop — `isSystem` does not exempt that strip. The
48+
message is now built from the `drops` breakdown the error already carried, so it
49+
names each reason against its own fields and offers the right remedy. The
50+
**read-only-only message is byte-identical** to #5126's / #5503's text (pinned
51+
directly), the error `code` is unchanged, and adding a reason deliberately does
52+
not add an error code: callers catch one code and read `drops`.
53+
54+
Consumers that branch on `reason` were swept. `service-automation`'s flow-step
55+
warning map is a `Record<DroppedFieldsEvent['reason'], string>`, so tsc demanded
56+
the new wording — the loud shape, kept that way on purpose. The protocol
57+
responses that carry `droppedFields` (`api/batch.zod.ts`, `api/protocol.zod.ts`
58+
×3, plus the cross-object batch extension) all derive from
59+
`DroppedFieldsEventSchema` and widen transitively; REST's
60+
`X-ObjectStack-Dropped-Fields` header is generic over the reason and needed no
61+
change. One consumer does NOT widen safely and is filed rather than fixed here:
62+
objectui's `writeWarningToast` picks its wording with a binary ternary whose
63+
`else` arm would announce a stripped `id` as "Read-only" (objectui#3935).

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,11 @@ interface WriteObservabilityOptions {
282282
}
283283

284284
interface DroppedFieldsEvent {
285-
object: string; // resolved object name
286-
fields: string[]; // caller-supplied fields that were dropped
287-
reason: 'readonly' | 'readonly_when'; // why they were dropped
285+
object: string; // resolved object name
286+
fields: string[]; // caller-supplied fields that were dropped
287+
// why they were dropped — an OPEN vocabulary that grows with the write
288+
// path's legal strips; branch on it exhaustively, never with a binary test
289+
reason: 'readonly' | 'readonly_when' | 'primary_key';
288290
}
289291
```
290292

content/docs/protocol/objectql/security.mdx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,12 @@ await data.update('attendance', { id, work_duration: 480 }, {
325325
```
326326

327327
`reason` is `'readonly'` for this static lock and `'readonly_when'` for a conditional
328-
[`readonlyWhen`](/docs/references/data/field) predicate. The listener is an in-process
328+
[`readonlyWhen`](/docs/references/data/field) predicate. A third value,
329+
`'primary_key'`, reports the one legal strip that is **not** a read-only lock: an
330+
`update` payload whose `id` the engine has already ruled is not an identifier is
331+
dropped rather than written over the targeted row's primary key. The vocabulary is
332+
open — it grows as the write path gains legal strips — so branch on `reason`
333+
exhaustively rather than treating "not `readonly_when`" as "read-only". The listener is an in-process
329334
callback: it is delivered by the local engine, and does **not** cross the RPC / Virtual
330335
Data Engine boundary, so a remote caller never receives these events. Without a listener,
331336
the only trace is a server-side `WARN` naming the object, the field, and both remedies.

content/docs/references/api/batch.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const result = BatchConfigSchema.parse(data);
5858
| **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). |
5959
| **data** | `Record<string, any>` | optional | Full record data (if returnRecords=true) |
6060
| **index** | `number` | optional | Index of the record in the request array |
61-
| **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. |
61+
| **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. |
6262

6363

6464
---
@@ -143,7 +143,7 @@ A cross-object batch strip event: dropped fields plus the operation index
143143
| :--- | :--- | :--- | :--- |
144144
| **object** | `string` || Object the write targeted (resolved object name) |
145145
| **fields** | `string[]` || Caller-supplied field names the engine removed from the write payload |
146-
| **reason** | `Enum<'readonly' \| 'readonly_when'>` || Why the fields were dropped: static readonly (#2948) or a TRUE readonlyWhen predicate (#3042) |
146+
| **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) |
147147
| **index** | `integer` || Index of the operation in the request `operations` array |
148148

149149

@@ -182,7 +182,7 @@ A cross-object batch strip event: dropped fields plus the operation index
182182
| Property | Type | Required | Description |
183183
| :--- | :--- | :--- | :--- |
184184
| **results** | `any[]` || Per-operation result, index-aligned with the request operations |
185-
| **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. |
185+
| **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. |
186186

187187

188188
---

content/docs/references/api/protocol.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ const result = AiAgentCapabilitiesSchema.parse(data);
351351
| **object** | `string` || The object name. |
352352
| **id** | `string` || The ID of the newly created record. |
353353
| **record** | `Record<string, any>` || The created record, including server-generated fields (created_at, owner). |
354-
| **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. |
354+
| **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. |
355355

356356

357357
---
@@ -377,7 +377,7 @@ const result = AiAgentCapabilitiesSchema.parse(data);
377377
| **object** | `string` || Object name |
378378
| **records** | `Record<string, any>[]` || Created records |
379379
| **count** | `number` || Number of records created |
380-
| **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.) |
380+
| **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.) |
381381

382382

383383
---
@@ -1469,7 +1469,7 @@ Uninstall package response
14691469
| **object** | `string` || Object name |
14701470
| **id** | `string` || Updated record ID |
14711471
| **record** | `Record<string, any>` || Updated record |
1472-
| **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`. |
1472+
| **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`. |
14731473

14741474

14751475
---

content/docs/references/data/data-engine.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
451451
| :--- | :--- | :--- | :--- |
452452
| **object** | `string` || Object the write targeted (resolved object name) |
453453
| **fields** | `string[]` || Caller-supplied field names the engine removed from the write payload |
454-
| **reason** | `Enum<'readonly' \| 'readonly_when'>` || Why the fields were dropped: static readonly (#2948) or a TRUE readonlyWhen predicate (#3042) |
454+
| **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) |
455455

456456

457457
---

0 commit comments

Comments
 (0)