Uh oh!
There was an error while loading. Please reload this page.
fix(objectql,metadata-core): refuse a by-id update whose scalar where.id names a different row than the payload id - #11229
Conversation
….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
…ngeset 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
…ere-id-conflict-refusal
📓 Docs Drift CheckThis PR changes 3 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 127 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6a2a8151ae1a530a903ec29a743a8be924c89f07 && git checkout 6a2a8151ae1a530a903ec29a743a8be924c89f07
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dd84ddd796d32a1deb9b8b71165c9bce5ddac30a 60bcad871a5496826d290dfb38281d62f01e9c19 && git checkout -B drift-repro dd84ddd796d32a1deb9b8b71165c9bce5ddac30a && git merge --no-ff 60bcad871a5496826d290dfb38281d62f01e9c19
node scripts/docs-audit/affected-docs.mjs --json dd84ddd796d32a1deb9b8b71165c9bce5ddac30a
|
os-zhuang
commented
Aug 23, 2026
Answers to the PM's wire-status question (measured, then read — in that order). 1. Is 2. What does the wire actually produce after this change? Measured in-process on the real
3. Fix needed in Net: the refusal is loud on the wire, not just on the error object — and the single-record PATCH route is additionally immune by construction. Adding this as a section to the PR body next. Generated by Claude Code Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11142
Implements the maintainer ruling recorded on the card (2026-08-23, verbatim 「其他同意你的意见」, adopting option A): a by-id
updatewhose truthy scalaroptions.where.idnames a different row than the truthy scalar payloaddata.idis refused loudly —UPDATE_ID_MISMATCH, status 400 — instead of silently binding the payload row and discarding thewhere.idpredicate. This deliberately reverses the #5748-pinned verdicta SCALAR data.id still wins over a scalar where.idfor the unequal shape only; the equal-ids spelling (the REST ingress folds the path id into the payload) stays honoured and now carries its own passing pin.Where the refusal actually lives (the seat's open question)
The refusal is raised in the dispatch resolution layer itself —
resolveEngineUpdateDispatchinpackages/metadata-core/src/engine-update-dispatch.tsreturns arejectverdict carryingmessage+ declaredcode/status. There is no second refusal-raising file on the by-id path: the production throw ispackages/objectql/src/engine.ts's existing dispatch-reject throw site, now routed through a new shared thrower (engineUpdateDispatchRejectError, also used byassertEngineUpdateDispatch) so the real engine and every pinned fake throw the identical decorated error. This confirms the dispatch-layer assumption: it is the same place the #11009 refusal fires, not a per-driver check.Landed surface vs the declared prediction — wider by a few mechanical files, so the seat can size the hold:
packages/metadata-core/src/engine-update-dispatch.tsandpackages/objectql/src/engine.tsas predicted, pluspackages/objectql/src/engine-update-dispatch.ts(re-export shim),packages/objectql/src/engine-update-dispatch.test.ts,packages/objectql/src/engine-update-by-id-payload-id.test.ts(a second #5748-era pin of the same shape lived there and flips with the case table),packages/spec/src/api/error-code-ledger.zod.ts(ADR-0112 code registration) + its two regenerated reference-doc pages, and the changeset.The change, per call shape
data.id === where.id(both truthy scalars) — unchanged, by-id. New explicit pin:data.id === where.id — the redundant-but-agreeing spelling (REST folds the path id into the payload) stays by-id (#11142).code: 'UPDATE_ID_MISMATCH',status: 400.multi: truedoes not rescue the call (payload id outranksmultiper ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748, so the contradiction stands) — pinned.42beside'42'refuses, and the message spelling (quoted string vs bare number) makes the type mismatch visible — a coercing comparison would be the lenient-consumer move PD Add comprehensive test suite for Zod schema validation #12 forbids. Pinned.where.id(0,'') beside a payload id stays by-id (a falsy id identifies no row on this ladder — truthiness rule, not part of the reversal); a non-scalarwhere.id($in, array,null) beside a payload id keeps its ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748 by-id verdict; awherecarrying a conflicting id plus extra keys still gets the A compare-and-setwhereon a by-idupdateis silently inert — the extra predicate keys never reach the driver, andSqlHttpOutbox.redeliver's status guard is one of them #11009 unhonoured-keys refusal first, byte-identical — no existing A compare-and-setwhereon a by-idupdateis silently inert — the extra predicate keys never reach the driver, andSqlHttpOutbox.redeliver's status guard is one of them #11009 pin moves.Pin-reversal discipline: the interrupted pin flips, it is not deleted — the case-table row keeps its position with
expect: 'reject'and a comment citing the ruling, and the #6435 contrast pina truthy scalar data.id that DISAGREES with where.id still winsbecomes a refusal pin asserting code+status and that nothing reaches the driver.UPDATE_ID_MISMATCHis registered in the ADR-0112 ledger under@objectstack/objectql(the production thrower; the stamp itself lives in metadata-core's shared thrower — noted in the ledger comment). Not aVALIDATION_ERRORsynonym (the admission gate agrees): the payload parses fine — two row addresses contradict each other, the same mismatch class asQUERY_OBJECT_MISMATCH. The #5748 / #11009 refusals deliberately stay plain undecoratedErrors.Before/after on the flipped pin (measured, not asserted)
Probe:
resolveEngineUpdateDispatch({ id: 'rec_1', title: 'x' }, { where: { id: 'rec_2' } })resolved through the built@objectstack/metadata-coredist from a dependent package.origin/main, package rebuilt; mutation proved on disk both ways — anchored greps on the sourceUPDATE_ID_MISMATCH=0,11142=0, thenablation-dist-preflight metadata-core 'UPDATE_ID_MISMATCH' --absent: marker absent from all 18 built files): verdict{"kind":"by-id","id":"rec_1"}— the silent drop. The flipped-pin suites go red against that build:Test Files 2 failed (2), failing exactly at the A scalardata.idbeside a DIFFERENT scalarwhere.idsilently drops thewhere.idpredicate — the one unhonoured-predicate shape #11009's refusal deliberately left standing #11142 surface (TypeError: engineUpdateIdConflictMessage is not a functionat the flipped tests).git statusclean against HEAD is the byte-identity proof — package rebuilt, preflight: marker present in all 4 built files): verdict{"kind":"reject","code":"UPDATE_ID_MISMATCH","status":400}; equal pair stays{"kind":"by-id","id":"rec_1"}; both suites green (65/65). The mutation script carried atrap ... EXIT INT TERMrestore.Non-REST ingress survey (the ruling's recorded confidence gap)
Method: a repo-wide scan of non-test TypeScript under
packages/for.update(call windows that passoptions.where(34 windows across 1898 files), each classified by reading the call site; plus a sweep of shipped flow configs inexamples/andskills/. Controls proving the queries were live: the call-site scan re-found every already-known site (protocol.ts:10335/10367,stdio-data-bridge.ts:395,action-execution.ts:216/1078,crud-nodes.ts:424,engine.tsupdateById); the flow-config sweep's control stringupdate_recordhit 9 files before thefields-key filter was applied.Claim established (stated at its real strength): no non-REST ingress in this repository relies on payload-priority-under-conflict. Every ingress that supplies both id sources is in one of two groups:
protocol.updateData({ ...data, id: request.id }besidewhere: { id: request.id }— every REST PATCH), REST batch update ({ ...data, id }, nowhereat all), objectql scoped-repoupdateById, service-storagemetadata-store(file + upload-session), service-automationsuspended-run-store(save + recordTerminal), thesys_notificationmigration. Unaffected — the equal carve-out exists for exactly this pattern.where: { id }(an unequal pair can only come from the caller writing contradictory ids): MCP stdio bridge (stdio-data-bridge.ts:395), runtimecallDataObjectQL fallback (action-execution.ts:216), the action engine facade (action-execution.ts:1078), protocolbulkDataupdate and upsert arms (protocol.ts:10335/10367), and the flowupdate_recordexecutor (author-writtenfieldsbesidefilter). Today an unequal pair in this group silently writes the payload row — in the stdio bridge,callDatafallback andbulkDataarms that write lands after an existence probe that judged thewhererow, i.e. the same cross-row shape the RESTPATCH /data/:object/:id:请求体里的标量id压过路径:id,存在性探测/OCC 判在一行、写落在另一行、响应报第三个说法 #6479 fix removed fromupdateData. After this change those calls refuse loudly. That is the ruling's premise confirmed by measurement, not contradicted — no fork-back needed.where.idsites (outbox{ id: { $in: ids } }sweeps) are outside the refusal's condition (non-scalar), and no shipped flow/example/skill writesidintoupdate_record'sfields(all usefilter: { id: ... }only).Boundary of the claim: measured over this repository's
packages/,examples/,skills/. Sibling repos (objectuireaches data through REST, which folds;cloud) and third-party SDK consumers were not measured — "none exists anywhere" is not claimed.Verification
Union run at the final head
60bcad871(merge oforigin/main@ee2ff455a, which gained no commits touching this surface; premise re-verified against that main — pin string present, unequal shape still silently honoured):@objectstack/metadata-coretests:Test Files 11 passed (11)/Tests 165 passed (165); typecheckDone(its program includes tests).@objectstack/objectqlfull suite:Test Files 229 passed (229)/Tests 4060 passed (4060); typecheckDone. Flipped-pin files verbose: 65/65 with every A scalardata.idbeside a DIFFERENT scalarwhere.idsilently drops thewhere.idpredicate — the one unhonoured-predicate shape #11009's refusal deliberately left standing #11142 test listed.@objectstack/metadata-protocol137 passed | 2 skipped/1879 passed | 10 skipped;@objectstack/mcp20 passed/207 passed;@objectstack/service-messaging26 passed/259 passed(after buildingobjectql+driver-sqldists — the first run's 7 reds were the fresh-worktree unbuilt-dist trap, not the diff). Direction: these are downstream consumers of the tightened contract (--filteron the named packages; the'...@pkg'prefix-filter full sweep is CI's).check:generatedgreen after--fixregenerated the one proved-stale artifact (check:docs→ the two error-code reference pages); ledger admission suite2 files / 70 testsgreen (synonym gate accepts the code).node scripts/pm/dispatch-gates.mjs, change set derived by the script itself — 9 paths): all matched families run at60bcad871, every one exit 0, includingcheck:engine-double-contract(366 rows held),check:error-code-casing,check:dispatcher-error-vocabulary(OK — 21 unregistered code-stamping site(s), all classified; the new code is registered so it is not reported),check:nul-bytes(OK (scanned 6494 text file(s) ...)),check:where-matcher,check:query-options-erasure,check:cross-package-test-inputs,check:type-check-coverage,check:durability-log-level,check:slot-lookup(ratchet holds),check:adr-0087-registration([BREAKING] not-required (no-migration-prescription)accepted),check:changeset-no-major(minor only, launch-window convention), and the doc/content families the regenerated pages triggered.check:type-check-debt --re-measure(workspace-wide) was reproduced narrowly for the one ledger entry my diff can move: the gate's ownremeasureProjectconstruction was replicated for@objectstack/objectql(extends its tsconfig, test-exclusion removed, default typeRoots); tsc's own output counts 354 non-TS6059 errors == the recorded ledger value 354, and the only 2 diagnostics naming my edited files are on pre-existing lines my diff does not touch. Invariance for untouched packages: the generated program is per-package and my diff adds no test files elsewhere.check-dev-prereqsreds locally withThe workspace is not built — 52 of 67 packages— a local build-completeness precondition of this worktree, not a diff finding; the spec-dist freshness half it guards is built and current here.Changeset:
minor(breaking declared in body — launch-window convention), ADR-0087 disposition marker included;skip-changesetnot applicable (real changeset present).Wire status (measured, answering the PM's question)
The declared
status: 400is what the wire actually produces, measured in-process on the realRestServerroute handlers over the real protocol over the real engine (scratch test under the verify lock, deleted after):PATCH /api/v1/data/task/rec_1with body{ id: 'rec_2' }→ 200 on the path row — the single-record door cannot produce the conflict at all (the RESTPATCH /data/:object/:id:请求体里的标量id压过路径:id,存在性探测/OCC 判在一行、写落在另一行、响应报第三个说法 #6479 fold rewrites the body id to the path id before the engine sees it).POST /data/:object/updateManyandbatchData's update/upsert arms — answer the row withsuccess: false,errors[0].code = 'UPDATE_ID_MISMATCH',errors[0].httpStatus = 400(toRowApiErrorparses the code against the ADR-0112 union; the ledger registration in this PR is what makes it survive).mapDataErrorover the real engine throw (the classification every single-record data-route catch shares — pinned by the [rest] Hook refusals carrying an explicitstatusCodeare not mapped by/api/v1/data— they leak as HTTP 500INTERNAL_ERRORwith no located guidance #7525 suite):status: 400, body carries the refusal message +code: 'UPDATE_ID_MISMATCH'+object. Mechanism:classifyDataError's generic declared-status passthrough (declaredHttpStatusreadsstatus/statusCode, 400–599;thrownCodeFieldsnarrows the code against the registered union) — therecordNotFoundErrorpattern.content/docs/api/error-handling-server.mdx:11's "small fixed set" sentence predates that passthrough and understates it; doc untouched here per scope.callData/actions): read, not measured —errorFromThrownreads.status/.statusCode, so 400 + code there too.Generated by Claude Code