You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(objectql,metadata-core): refuse a by-id update whose scalar where.id names a different row than the payload id (#11229)
* fix(objectql,metadata-core): refuse a by-id update whose scalar where.id names a different row than the payload id (#11142)
update(obj, { id: 'rec_1', ... }, { where: { id: 'rec_2' } }) used to bind
rec_1 and silently discard the where.id predicate — the one
unhonoured-predicate shape #11009's refusal deliberately left standing,
because refusing it reverses the #5748-pinned verdict 'a SCALAR data.id
still wins over a scalar where.id'. The maintainer ruling on #11142
(2026-08-23, option A) authorizes that reversal for the UNEQUAL truthy
scalar shape only.
resolveEngineUpdateDispatch now rejects the conflict with a message naming
both ids, decorated code UPDATE_ID_MISMATCH + status 400 (the
recordNotFoundError convention; code registered in the ADR-0112 ledger
under @objectstack/objectql). Both throwers — assertEngineUpdateDispatch
(every pinned fake) and ObjectQL.update — go through one shared
engineUpdateDispatchRejectError, so fakes and the real engine refuse
identically. The #5748/#11009 refusals stay plain Errors, byte-identical.
Pin-reversal discipline: the interrupted pin flips to a refusal pin in
ENGINE_UPDATE_DISPATCH_CASES and in the #6435 contrast pins — never
deleted; the equal-ids spelling (REST folds the path id into the payload)
gains its own passing pin; falsy and non-scalar where.id boundaries keep
their pre-existing verdicts, pinned so the refusal cannot creep past the
ruled scope.
Fixes#11142
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
* chore(spec): regenerate error-code reference docs; add the #11142 changeset
The UPDATE_ID_MISMATCH ledger entry lands in the generated
content/docs/references pages (check:generated --fix, only the artifact it
proved stale), and the breaking-changeset carries the ADR-0087 disposition
marker (not-required: no authorable surface moves; the refused shape is a
self-contradictory input whose fix is a per-site intent decision).
Part of #11142
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
---------
Co-authored-by: Claude <noreply@anthropic.com>
**BREAKING (accept-set tightening)**: a by-id `update` whose truthy scalar `options.where.id` names a DIFFERENT row than the truthy scalar payload `data.id` is now refused loudly — `UPDATE_ID_MISMATCH`, HTTP 400 — instead of silently binding the payload row and discarding the `where.id` predicate (#11142).
8
+
9
+
`update(obj, { id: 'rec_1', title: 'x' }, { where: { id: 'rec_2' } })` used to write `rec_1` with no diagnostic: the caller declared "update rec_1 where id = rec_2" — a condition that can never hold — and the by-id path dropped the losing spelling exactly the way #11009's extra `where` keys were dropped. This was the one unhonoured-predicate shape #11009's refusal deliberately left standing, because refusing it partially reverses the #5748-pinned verdict (`a SCALAR data.id still wins over a scalar where.id`). The maintainer ruling on #11142 (2026-08-23) authorizes that reversal for the UNEQUAL truthy scalar shape only.
10
+
11
+
What changes, per call shape (`resolveEngineUpdateDispatch`, so every pinned test double inherits the same verdict):
12
+
13
+
-`data.id === where.id` (both truthy scalars) is **unchanged** — by-id. This is the normal REST spelling: the ingress folds the path id into the payload, so redundant-but-agreeing pairs are routine.
14
+
-`data.id` and `where.id` both truthy scalars and **different** — including differing only in type, e.g. `42` beside `'42'` — now **throws**`UPDATE_ID_MISMATCH` with `status: 400`, naming both ids. A declared `multi: true` does not rescue the call (the payload id outranks `multi` per #5748, so the contradiction stands). Previously the write landed on the payload row with the condition silently ignored.
15
+
- A **falsy** scalar `where.id` (`0`, `''`) beside a payload id is unchanged (a falsy id identifies no row on this ladder, so there is no second row address to conflict with), and a **non-scalar**`where.id` (`{ $in: [...] }`, an array, `null`) beside a payload id keeps its #5748 by-id verdict — widening over either is a separate decision, deliberately not taken here.
16
+
17
+
A caller hitting the new refusal wrote two row addresses and meant one of them; each fix is a one-line edit at the call site: make the two ids equal (or drop `where.id`) to keep addressing the row by the payload id, or remove `id` from the payload to address the row by `where.id`. The refusal is decorated with `code: 'UPDATE_ID_MISMATCH'` and `status: 400` on the thrown error (registered in the ADR-0112 error-code ledger; the spec's `ErrorCode` union gains the member), so REST callers get a located 400 instead of a sanitised 500, and doubles pinned to `assertEngineUpdateDispatch` throw the identical envelope.
18
+
19
+
<!-- adr-0087: not-required (no-migration-prescription) No authorable surface is removed or renamed — no spec key, no export, no config field changes spelling, so `objectstack migrate meta` has nothing to rewrite and no ledger entry could serve an upgrader. The newly-refused call shape was a self-contradictory input whose declared condition was never evaluated; deciding which of the two ids the caller meant is a per-site intent decision a mechanical rewrite must not make, and the refusal text itself names both call-site fixes. -->
Copy file name to clipboardExpand all lines: content/docs/references/api/contract.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
27
27
28
28
| Property | Type | Required | Description |
29
29
| :--- | :--- | :--- | :--- |
30
-
|**code**|`Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +284 more>`| ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30
+
|**code**|`Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +285 more>`| ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31
31
|**declaredCode**|`string`| optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) |
|**userMessage**|`string`| optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -321,6 +321,7 @@ const result = ApiErrorSchema.parse(data);
// spelling and objectstack#5748 left it exactly as it was.
328
462
{what: 'id carried in the data payload, no where at all',data: {id: 'rec_1',title: 'x'},options: undefined,expect: 'by-id',expectId: 'rec_1'},
329
463
{what: 'a SCALAR data.id still wins over an explicit multi:true',data: {id: 'rec_1',title: 'x'},options: {multi: true},expect: 'by-id',expectId: 'rec_1'},
330
-
{what: 'a SCALAR data.id still wins over a scalar where.id',data: {id: 'rec_1',title: 'x'},options: {where: {id: 'rec_2'}},expect: 'by-id',expectId: 'rec_1'},
464
+
// [#11142] The REVERSED #5748 pin. This row read
465
+
// `expect: 'by-id', expectId: 'rec_1'` from #5748 until the maintainer
466
+
// ruling on #11142 (2026-08-23) flipped the UNEQUAL shape to a refusal: a
467
+
// truthy scalar `where.id` naming a different row than the payload id is a
468
+
// predicate the by-id path would silently discard — the last silent member
469
+
// of the #5748/#11009 dropped-declaration family. The pin flips, it does
470
+
// not disappear; the EQUAL spelling keeps its own passing pin right below.
471
+
{what: 'a SCALAR data.id beside a DIFFERENT scalar where.id — refused, no longer silently wins (#11142 reverses the #5748 pin for the unequal shape)',data: {id: 'rec_1',title: 'x'},options: {where: {id: 'rec_2'}},expect: 'reject'},
472
+
// [#11142] The equal-ids spelling stays honoured: the REST ingress folds
473
+
// the path id into the payload (`{ ...data, id: request.id }` beside
474
+
// `where: { id: request.id }`), so redundant-but-agreeing is a NORMAL
475
+
// spelling, not a conflict.
476
+
{what: 'data.id === where.id — the redundant-but-agreeing spelling (REST folds the path id into the payload) stays by-id (#11142)',data: {id: 'rec_1',title: 'x'},options: {where: {id: 'rec_1'}},expect: 'by-id',expectId: 'rec_1'},
477
+
// [#11142] `multi: true` cannot rescue the conflict: the payload id outranks
478
+
// `multi` (#5748), so the call is still a by-id write carrying a where.id it
479
+
// can never honour.
480
+
{what: 'a SCALAR data.id beside a DIFFERENT scalar where.id and multi:true — still refused, the payload id outranks multi (#11142)',data: {id: 'rec_1',title: 'x'},options: {where: {id: 'rec_2'},multi: true},expect: 'reject'},
331
481
// ── The payload's scalar test (objectstack#5748). A non-scalar `data.id`
332
482
// names no row, so it stops shadowing everything under it: the decision
333
483
// falls through to `where.id`, then `multi`, then `reject`. Before #5748
0 commit comments