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 non-scalar where.id predicate stands beside the payload id (#11230) (#11432)
update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a','b'] } },
multi: true }) used to write exactly one row — rec_1 — with no diagnostic: the
payload id outranked `where` and `multi` alike, so the declared row SET and the
declared bulk intent were BOTH discarded, and rec_1 need not even have been a
member of the set. The last silent member of the dropped-declaration family
(#5748 payload operator-objects, #11009 extra where keys, #11142 unequal scalar
where.id). Closing it reverses the REMAINING half of the #5748-pinned verdict
'a SCALAR data.id still outranks where and multi', which the maintainer ruling
on #11230 (2026-08-23) authorizes.
resolveEngineUpdateDispatch now rejects the shape with a message naming the
payload id and the KIND of predicate the caller wrote, carrying the SAME
ADR-0112 envelope #11142 declares — UPDATE_ID_MISMATCH + status 400. One ledger
member for one defect class, two messages: the two shapes share a remedy (drop
one of the two row-address spellings), so a second registered code would buy no
consumer a branch it could act on, while growing the closed `code` vocabulary
every AI-authored consumer must switch over (ADR-0112 D3/D4). No new error code
is registered; the ledger's existing entry is widened in place.
Pin-reversal discipline: both pins asserting the old verdict are INVERTED in
place, never deleted and never relaxed — 'a SCALAR data.id still outranks where
and multi' (its first expectation was exactly this shape) and 'a NON-SCALAR
where.id keeps its #5748 verdict', the boundary #11142 deliberately left
standing. The boundaries that survive gain their own pins: falsy scalar
where.id, a `where` declaring no id, and the no-payload-id ladder.
Fixes#11230
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
Co-authored-by: Claude <noreply@anthropic.com>
**BREAKING (accept-set tightening)**: a by-id `update` whose bound truthy scalar payload `data.id` stands beside a DECLARED but non-scalar `options.where.id` — `{ $in: [...] }`, an array, `null` — is now refused loudly (`UPDATE_ID_MISMATCH`, HTTP 400) instead of silently binding the payload row and discarding both the id predicate and any declared `multi: true` (#11230).
7
+
8
+
`update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a', 'b'] } }, multi: true })` used to write exactly one row — `rec_1` — with no diagnostic: the payload id outranked `where` and `multi` alike (#5748), so the declared row SET and the declared bulk intent were both dropped, and `rec_1` need not even have been a member of the set. This was the LAST silent member of the dropped-declaration family (#5748 payload operator-objects, #11009 extra `where` keys, #11142 unequal scalar `where.id`); closing it reverses the remaining half of the #5748-pinned verdict `a SCALAR data.id still outranks where and multi`, which the maintainer ruling on #11230 (2026-08-23) authorizes.
9
+
10
+
What changes, per call shape (`resolveEngineUpdateDispatch`, so every pinned test double inherits the same verdict):
11
+
12
+
- A truthy scalar `data.id` beside a **non-scalar**`where.id` — an operator object, an array, `null`, or an explicitly-`undefined``id` key — now **throws**`UPDATE_ID_MISMATCH` with `status: 400`, naming the payload id and the KIND of predicate the caller wrote. `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 both declarations silently ignored.
13
+
- Boundaries that do **not** move: a **falsy** scalar `where.id` (`0`, `''`) is a scalar and keeps its #11142 verdict (by-id); a `where` that declares **no**`id` key at all (`{}`, or no `where`) is untouched; and with **no** scalar payload id the ladder is exactly as #5748 left it (`multi` when declared, otherwise `reject`) — the refusal lives only on the payload-sourced by-id arm.
14
+
- The refusal shares the #11142 error code deliberately — one ADR-0112 ledger member for one defect class, two messages. No new code is registered.
15
+
16
+
A caller hitting the new refusal declared a row address and a row-set predicate in one call and meant one of them; each fix is a one-line edit at the call site: drop `id` from the payload to write EVERY row the predicate selects (`update(object, fields, { where: { id: { $in: [...] } }, multi: true })`), or drop `where.id` to write the single row the payload names (`update(object, { id, ...fields })`). The refusal text names both. Measured before shipping: **no in-repo call site constructs the pair** — every production `where.id` predicate (the outbox sweeps) carries a payload with no `id` — so the in-repo blast radius is nil; an external SDK caller can still write it, and today that silently drops both declarations.
17
+
18
+
<!-- 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 is a self-contradictory input whose declared condition was never evaluated; deciding whether the caller meant the payload row or the predicate's row set is a per-site intent decision a mechanical rewrite must not make, and the refusal text itself names both call-site fixes. -->
// `multi` (#5748), so the call is still a by-id write carrying a where.id it
479
617
// can never honour.
480
618
{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'},
619
+
// [#11230] The OTHER half of the reversed #5748 pin, and the last silent arm
620
+
// of this family: a DECLARED but NON-SCALAR `where.id` beside a scalar
621
+
// payload id. It never had a row of its own in this case-set — the assertion
622
+
// that pinned it lived in `engine-update-dispatch.test.ts` ("a SCALAR
623
+
// data.id still outranks where and multi", first expectation) and is flipped
624
+
// there. It gets rows HERE now, so every double bound to this case-set
625
+
// inherits the refusal rather than each one re-deriving it.
626
+
{what: 'a SCALAR data.id beside an $in where.id and multi:true — refused; the row SET and the declared bulk intent were BOTH silently dropped (#11230 reverses the remaining half of the #5748 pin)',data: {id: 'rec_1',title: 'x'},options: {where: {id: {$in: ['a','b']}},multi: true},expect: 'reject'},
627
+
{what: 'a SCALAR data.id beside an $in where.id, no multi — refused (#11230)',data: {id: 'rec_1',title: 'x'},options: {where: {id: {$in: ['a','b']}}},expect: 'reject'},
628
+
{what: 'a SCALAR data.id beside an ARRAY where.id — refused (#11230)',data: {id: 'rec_1',title: 'x'},options: {where: {id: ['a','b']}},expect: 'reject'},
629
+
{what: 'a SCALAR data.id beside a NULL where.id — refused (#11230)',data: {id: 'rec_1',title: 'x'},options: {where: {id: null}},expect: 'reject'},
630
+
// [#11230] The boundary that does NOT move: a FALSY scalar `where.id` IS a
631
+
// scalar, so it is not this refusal's shape at all and keeps the #11142
632
+
// verdict. Pinned so the reversal cannot creep over the truthiness rule.
633
+
{what: 'a SCALAR data.id beside a FALSY scalar where.id (0) — still by-id, a falsy id identifies no row (the #11142 boundary, untouched by #11230)',data: {id: 'rec_1',title: 'x'},options: {where: {id: 0}},expect: 'by-id',expectId: 'rec_1'},
481
634
// ── The payload's scalar test (objectstack#5748). A non-scalar `data.id`
482
635
// names no row, so it stops shadowing everything under it: the decision
483
636
// falls through to `where.id`, then `multi`, then `reject`. Before #5748
0 commit comments