Repository navigation
A scalar data.id beside a DIFFERENT scalar where.id silently drops the where.id predicate — the one unhonoured-predicate shape #11009's refusal deliberately left standing #11142
Description
Activity
Triage: 入决策箱(
needs-user-decision+domain:engine,类型 Bug)。收紧它要部分回滚 #5748 的 pinned 裁决 A,只有维护者能翻已裁 pin。一句话问题:调用方写「更新 rec_1,条件是 id = rec_2」——一个永远不可能成立的条件——平台不报错,直接把 rec_1 改了。这是「静默丢弃谓词」家族里被上一轮裁决刻意留下的最后一格。
选项(客户可感知代价):
- A — 收紧:by-id 派发遇到「where.id 是标量且 ≠ 载荷 id」时响亮拒绝(与 ObjectQL.update 的
data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748/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 同族的编写期错误)。代价:翻一条 pinned 用例(a SCALAR data.id still wins over a scalar where.id的 expectId 断言反转为拒绝断言);两 id 相等的正常拼写(REST 把路径 id 折进载荷)不受影响。 - B — 维持:载荷 id 就是行地址,冲突的 where.id 视为调用方 bug、太罕见不设防。代价:全家族都改成响亮之后,唯一留下的静默格——AI 写出这个形状时,错误落库且零诊断。
业务直译:A = 转账单上「收款人」和「确认收款人」不一致就退回;B = 以第一格为准直接打款。
四轴:业务需求——形状罕见(实测由 #11009 实施时发现,非用户上报),拉动弱;长远合理——家族一致性:同类静默丢弃已全部改响亮,留一格特例是长期认知负担;防 AI 错——A 显著更优:矛盾输入被当场打回,而不是落库后无从追查;阶段聚焦——A 是一次窄改动+pin 反转,无扩散。
推荐 A,回退 B(裁 B 则在派发用例注释里写明「此格 deliberately 静默」并引用本裁决,让下一个实施者不再重开)。回一个字母即可。
裁后执行:A ⇒ engine 席派发,pin 反转遵循「被打断行为的 pin 反转为拒绝 pin,不是删除」纪律,拒收用例带 code+status 双断言;B ⇒ 关卡并在
ENGINE_UPDATE_DISPATCH_CASES上补裁决注释(一行,引本卡)。[facets-block]
- 业务需求:弱指 A —— 形状罕见、无用户上报,但撞上即错误落库
- 长远合理:指向 A —— 家族一致性:静默丢弃已全部改响亮,不留特例格
- 防 AI 错:指向 A —— 矛盾输入当场打回,优于落库后零诊断
- 阶段聚焦:指向 A —— 窄改动 + pin 反转,无扩散
推荐:A(unequal 标量 where.id 响亮拒绝;翻 ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748 pin 需你点头)· 置信缺口:两 id 不等形状的现网出现频率无读数;REST 之外的 ingress 是否依赖「payload 优先」未普查
Generated by Claude Code
- A — 收紧:by-id 派发遇到「where.id 是标量且 ≠ 载荷 id」时响亮拒绝(与 ObjectQL.update 的
Maintainer ruling recorded (2026-08-23, live PM chat, verbatim: 「其他同意你的意见」— adopting this card's recommendation A): refuse a by-id dispatch whose
where.idis a truthy scalar different from the bound payload id — same loud-refusal family as #5748/#11009. This deliberately reverses the #5748-pinned case'a SCALAR data.id still wins over a scalar where.id'for the UNEQUAL shape only; the equal-ids spelling (REST folds path id into payload) stays honored.State:
needs-user-decision→pm:queue(same stroke). Dispatch constraints: pin-reversal discipline — the interrupted behavior's pin flips to a refusal pin, never deleted; refusal case carriescode+statusassertions; implementer surveys non-REST ingresses for payload-priority reliance (the recorded confidence gap) and reports readings in the PR — a measured contrary fact forks back here, no silent re-ruling.
Generated by Claude Code
Claim · seat
domain:engine· sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y· branchclaude/issue-11142-where-id-conflict-refusalDispatching on the recorded maintainer ruling of 2026-08-23 (option A, verbatim 「其他同意你的意见」), which is the comment directly above this one.
Tier:
claude-fable-5, mandatory. This card changes contract accept/reject behaviour — a call that resolves today must reject after it — which is the clause-② condition read from the card's content, not a discretionary escalation.needs:contract-reviewattached in the same stroke as the claim.Declared file surface (for same-file serialization against other cards in this lane):
packages/metadata-core/src/engine-update-dispatch.ts—resolveEngineUpdateDispatchandENGINE_UPDATE_DISPATCH_CASES, including the pinned case'a SCALAR data.id still wins over a scalar where.id'.- whatever refusal site the by-id path actually raises from, to be named in the PR once measured.
This surface is a prediction; if the landed change is narrower, say so in the PR and the seat releases the hold early.
Dispatch constraints carried from the ruling:
- Pin-reversal discipline — the interrupted behaviour's pin flips to a refusal pin. It is never deleted. The equal-ids spelling (REST folds the path id into the payload) stays honoured and must keep its own passing case.
- The refusal case carries
code+statusdouble assertions, matching the ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748/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 family. - Survey non-REST ingresses for payload-priority reliance — this is the confidence gap the decision block recorded, and the ruling requires the readings be reported in the PR. Zero-hit discipline applies: a zero result is not a reading until a known-present control confirms the query is live.
- Scope is the UNEQUAL truthy scalar case only. Widening beyond it is the exact thing
update的 **by-id** 路径同样把非标量data.id交给驱动写主键列(#6262 的孪生形状,where.id胜出时) #6435 and 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 each declined as "a separate decision, not a rider". - A measured contrary fact forks back to this card — stop and report, never re-rule silently.
Generated by Claude Code
Correction —
needs:contract-reviewremoved (I attached it in error 20 minutes ago).Reason on record, because a stripped label and one that was never attached are indistinguishable in the evidence, and this one carries gate semantics.
I attached it in the claim above on the reasoning "clause ② applies to this card, so the label applies." That conflates two different things. Read from the label's own definition (
scripts/pm/ensure-pm-labels.sh, andCONTRACT_REVIEW_TIERatscripts/pm/dispatch-gates.mjs:2816):a PR whose ACTUAL diff touches the contract surface — or whose card's claim comment declares
Clause-②: yes(the content limb, judged from the card, path-independent) — but was dispatched below the contract-review tier waits outside the queue under this label until the review sub-round clears itBoth limbs are conditioned on dispatched below tier. The label's own description says the same: "Clause-② enqueue gate: dispatched below contract-review tier — blocked until the review clears it."
This card was dispatched at
claude-fable-5, which is the contract-review tier. So the clause-② obligation was discharged by the dispatch itself, not deferred to a review sub-round. Leaving the label on would have parked a correctly-dispatched card outside the enqueue gate waiting for a review that has no reason to run — a self-inflicted block, and precisely the failure mode the label exists to prevent in the other direction.Unchanged: clause ② does apply to this card (it changes contract accept/reject behaviour), the dispatch tier is
claude-fable-5and mandatory, and every constraint in the claim comment stands. The only thing that changed is a label that was never true.Same reading applies to #10746, which I checked in the same pass: dispatched at
claude-fable-5withClause-②: yesdeclared, and correctly carrying noneeds:contract-review. That absence is right, not a missing pair.
Generated by Claude Code
Liveness probe answer · session
session_01RfyXxZ2WPjcjhuXpiQQc3y· not the dev report (that follows at PR time, markedos-dev-report).- Alive and progressing — implementation, tests, reverse verification and most derived gates are done; currently finishing the gate union, then merge of moved
main, push, and draft PR. - Local commits: 2, complete (fix + regenerated docs/changeset). Not pushed yet — the branch on the remote is still the write-route probe.
- Not blocked on anything the PM owns.
- Premise held, re-verified against moved main: on
origin/mainatee2ff455athe pinned case'a SCALAR data.id still wins over a scalar where.id'still exists inENGINE_UPDATE_DISPATCH_CASES(grep count 1), and the unequal shape is still silently honoured — measured behaviourally, not assumed: rebuildingorigin/main's dispatch module and probing through the built dist gives{"kind":"by-id","id":"rec_1"}forupdate(o, { id: 'rec_1' }, { where: { id: 'rec_2' } }). None of the 6 commits main gained since my base touches my declared file surface (onlypackages/objectql/src/protocol-discovery.test.tsis in a shared package, a test file unrelated to the dispatch).
Duplicate check done as flagged: the only PR mentioning
11142is merged #11147 — the #11009 fix this card was filed from, not an implementation of this card.git ls-remote --heads origin | grep issue-11142shows only my branch. Proceeding.
Generated by Claude Code
Generated by Claude Code
- Alive and progressing — implementation, tests, reverse verification and most derived gates are done; currently finishing the gate union, then merge of moved
{ "issue": 11142, "status": "done", "branch": "claude/issue-11142-where-id-conflict-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/11229", "premise_still_valid": true, "summary": "Implemented ruling A: resolveEngineUpdateDispatch (packages/metadata-core/src/engine-update-dispatch.ts) now rejects a by-id update whose truthy scalar where.id differs from the truthy scalar payload id, with code UPDATE_ID_MISMATCH + status 400 registered in the ADR-0112 ledger; both throwers (assertEngineUpdateDispatch and ObjectQL.update's existing reject site) share one engineUpdateDispatchRejectError, so fakes and the real engine refuse identically — there is NO second refusal-raising file; the refusal lives in the dispatch layer, confirming the PM's assumption 2. The #5748 pin flipped to a refusal pin (never deleted) in ENGINE_UPDATE_DISPATCH_CASES and in the #6435 contrast-pin file (a second pin of the same shape the PM's surface prediction did not include); equal ids gained their own passing pin; falsy/non-scalar where.id boundaries pinned as out of ruled scope. Premise re-verified against moved main (ee2ff455a): pin string present, old behaviour measured live. Final commit 60bcad871 (merge of origin/main; none of its 6 commits touch this surface). Ingress survey verdict: no non-REST ingress in this repo relies on payload-priority-under-conflict — every dual-source ingress either folds the ids equal by construction (protocol.updateData, REST batch, updateById, metadata-store, suspended-run-store, sys_notification migration) or passes the payload verbatim beside where.id (MCP stdio bridge, callData fallback, action facade, bulkData update/upsert, flow update_record), where an unequal pair today silently cross-row-writes past its own existence probe — the ruling's premise confirmed, no fork-back. Boundary: measured over this repo's packages/, examples/, skills/; sibling repos and third-party SDK consumers not measured.", "tests": "All heavy runs via os-verify-lock, verdicts from the gate's own lines, exits captured before pipes. Union at final head 60bcad871: metadata-core 'Test Files 11 passed (11)'/'Tests 165 passed (165)' + typecheck Done (its program includes tests); objectql full suite 'Test Files 229 passed (229)'/'Tests 4060 passed (4060)' + typecheck Done, flipped-pin files verbose 65/65 with every #11142 test listed; downstream consumers (direction: downstream of the tightened contract, named --filter, prefix-filter full sweep is CI's): metadata-protocol '137 passed | 2 skipped'/'1879 passed', mcp '20 passed'/'207 passed', service-messaging '26 passed'/'259 passed' (its first run's 7 reds were unbuilt objectql/driver-sql dists in the fresh worktree, fixed by building them). Before/after on the flipped pin, measured through the BUILT metadata-core dist: BEFORE (module restored to origin/main; mutation confirmed on disk by anchored greps UPDATE_ID_MISMATCH=0/11142=0 in src, then ablation-dist-preflight --absent: marker absent from all 18 built files; rebuilt) probe gives {\"kind\":\"by-id\",\"id\":\"rec_1\"} and the two suites go red ('Test Files 2 failed (2)', failing at the #11142 surface); AFTER (restored to HEAD — git status clean vs HEAD is the byte-identity proof — rebuilt; preflight: marker present in 4 built files) probe gives {\"kind\":\"reject\",\"code\":\"UPDATE_ID_MISMATCH\",\"status\":400}, suites 65/65; the mutation script carried a trap EXIT INT TERM restore. Spec: check:generated green after --fix regenerated only the proved-stale check:docs pages; ledger admission suite 70/70. dispatch-gates.mjs derived 9 paths itself; every matched family exit 0 at 60bcad871 (engine-double-contract 366 rows held, error-code-casing, dispatcher-error-vocabulary 'OK — 21 unregistered site(s), all classified', nul-bytes 'OK (scanned 6494 text file(s))', where-matcher, query-options-erasure, cross-package-test-inputs, type-check-coverage, durability-log-level, slot-lookup ratchet holds, adr-0087-registration '[BREAKING] not-required (no-migration-prescription)' accepted, changeset-no-major green). Declared narrowings: (1) check:type-check-debt --re-measure reproduced narrowly for the one entry my diff can move — replicated the gate's remeasureProject config for objectql; tsc counts 354 non-TS6059 errors == the recorded 354, and the only 2 diagnostics in my edited files are pre-existing lines my diff does not touch; untouched packages invariant because the generated program is per-package and my diff adds test code nowhere else; (2) check-dev-prereqs reds locally ('workspace not built — 52 of 67 packages') — a local precondition of the partial worktree build, not a diff finding; (3) remaining ~60 downstream package suites are CI's. Survey zero-hit controls: the call-site scan re-found all known dual-source sites (protocol.ts:10335/10367, stdio-data-bridge.ts:395, action-execution.ts:216/1078, crud-nodes.ts:424, updateById); the flow-config sweep's control string 'update_record' hit 9 files before filtering for id-in-fields (zero hits).", "open_questions": [], "out_of_scope_findings": [ "filed as #11230: a scalar data.id beside a NON-scalar where.id ({$in},array,null) still silently drops the predicate and a declared multi — the last silent where.id shape after #11142; reversing it touches the other half of the #5748 pin, needs a ruling (unassigned, unlabeled for triage)", "filed as #11231 (finding label): settings-service-plugin and metadata database-loader fold their resolved id into the payload with the losing spread order ({ id, ...data }) — a payload id would override the ingress-resolved id with no where for the #11142 refusal to catch; reachability honestly unproven, hardening is flipping the spread order to match the documented REST-batch convention" ] }
Generated by Claude Code
Generated by Claude Code
os-dev-report
(Supersedes the comment above: the HTML-comment marker was stripped by the body sanitizer, so this repost opens with the literal text instead. Same report, plus the wire-status measurement the PM requested after the first post.)
{ "issue": 11142, "status": "done", "branch": "claude/issue-11142-where-id-conflict-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/11229", "premise_still_valid": true, "summary": "Implemented ruling A: resolveEngineUpdateDispatch (packages/metadata-core/src/engine-update-dispatch.ts) now rejects a by-id update whose truthy scalar where.id differs from the truthy scalar payload id, with code UPDATE_ID_MISMATCH + status 400 registered in the ADR-0112 ledger; both throwers (assertEngineUpdateDispatch and ObjectQL.update's existing reject site) share one engineUpdateDispatchRejectError, so fakes and the real engine refuse identically — there is NO second refusal-raising file; the refusal lives in the dispatch layer, confirming the PM's assumption 2. The #5748 pin flipped to a refusal pin (never deleted) in ENGINE_UPDATE_DISPATCH_CASES and in the #6435 contrast-pin file (a second pin of the same shape the PM's surface prediction did not include); equal ids gained their own passing pin; falsy/non-scalar where.id boundaries pinned as out of ruled scope. Premise re-verified against moved main (ee2ff455a): pin string present, old behaviour measured live. Final commit 60bcad871 (merge of origin/main; none of its 6 commits touch this surface). Ingress survey verdict: no non-REST ingress in this repo relies on payload-priority-under-conflict — every dual-source ingress either folds the ids equal by construction (protocol.updateData, REST batch, updateById, metadata-store, suspended-run-store, sys_notification migration) or passes the payload verbatim beside where.id (MCP stdio bridge, callData fallback, action facade, bulkData update/upsert, flow update_record), where an unequal pair today silently cross-row-writes past its own existence probe — the ruling's premise confirmed, no fork-back. Wire status (PM follow-up, measured on the real RestServer handlers over the real protocol over the real engine): the conflict answers 400 with the code at every REST door that can carry it — updateMany/batch rows carry code UPDATE_ID_MISMATCH + httpStatus 400 via toRowApiError, mapDataError classifies the throw as status 400 + code via the declared-status passthrough, and the single-record PATCH cannot produce the conflict at all (the #6479 fold rewrites the body id to the path id; measured 200 on the path row). No packages/rest change needed. Boundary: measured over this repo's packages/, examples/, skills/; sibling repos and third-party SDK consumers not measured.", "tests": "All heavy runs via os-verify-lock, verdicts from the gate's own lines, exits captured before pipes. Union at final head 60bcad871: metadata-core 'Test Files 11 passed (11)'/'Tests 165 passed (165)' + typecheck Done (its program includes tests); objectql full suite 'Test Files 229 passed (229)'/'Tests 4060 passed (4060)' + typecheck Done, flipped-pin files verbose 65/65 with every #11142 test listed; downstream consumers (direction: downstream of the tightened contract, named --filter; the prefix-filter full sweep is CI's): metadata-protocol '137 passed | 2 skipped'/'1879 passed', mcp '20 passed'/'207 passed', service-messaging '26 passed'/'259 passed' (its first run's 7 reds were unbuilt objectql/driver-sql dists in the fresh worktree, fixed by building them). Before/after on the flipped pin, measured through the BUILT metadata-core dist: BEFORE (module restored to origin/main; mutation confirmed on disk by anchored greps UPDATE_ID_MISMATCH=0/11142=0 in src, then ablation-dist-preflight --absent: marker absent from all 18 built files; rebuilt) the probe gives kind by-id with id rec_1, and the two suites go red ('Test Files 2 failed (2)', failing at the #11142 surface); AFTER (restored to HEAD — git status clean vs HEAD is the byte-identity proof — rebuilt; preflight: marker present in 4 built files) the probe gives kind reject with code UPDATE_ID_MISMATCH and status 400, suites 65/65; the mutation script carried a trap EXIT INT TERM restore. Wire measurement (scratch test on the real RestServer route handlers + real ObjectStackProtocolImplementation + real ObjectQL, echo storage driver; run under the lock, file deleted after, not committed): PATCH /api/v1/data/task/rec_1 with body id rec_2 answered 200 with record.id rec_1 and the driver received update('task','rec_1',...) — conflict unreachable at that door; POST /data/:object/updateMany with a row whose data.id contradicts record.id answered the row success:false with errors[0].code UPDATE_ID_MISMATCH and errors[0].httpStatus 400 (asserted); mapDataError over the real engine throw answered status 400 with code UPDATE_ID_MISMATCH (its 8 sibling rest-suite file failures in that run were unbuilt-dist imports of unrelated analytics suites — all 2081 executed tests passed). Spec: check:generated green after --fix regenerated only the proved-stale check:docs pages; ledger admission suite 70/70. dispatch-gates.mjs derived 9 paths itself; every matched family exit 0 at 60bcad871 (engine-double-contract 366 rows held, error-code-casing, dispatcher-error-vocabulary 'OK — 21 unregistered site(s), all classified', nul-bytes 'OK (scanned 6494 text file(s))', where-matcher, query-options-erasure, cross-package-test-inputs, type-check-coverage, durability-log-level, slot-lookup ratchet holds, adr-0087-registration '[BREAKING] not-required (no-migration-prescription)' accepted, changeset-no-major green). Declared narrowings: (1) check:type-check-debt --re-measure reproduced narrowly for the one entry my diff can move — replicated the gate's remeasureProject config for objectql; tsc counts 354 non-TS6059 errors == the recorded 354, and the only 2 diagnostics in my edited files are pre-existing lines my diff does not touch; untouched packages invariant because the generated program is per-package and my diff adds test code nowhere else; (2) check-dev-prereqs reds locally ('workspace not built — 52 of 67 packages') — a local precondition of the partial worktree build, not a diff finding; (3) remaining ~60 downstream package suites are CI's. Survey zero-hit controls: the call-site scan re-found all known dual-source sites (protocol.ts:10335/10367, stdio-data-bridge.ts:395, action-execution.ts:216/1078, crud-nodes.ts:424, updateById); the flow-config sweep's control string 'update_record' hit 9 files before filtering for id-in-fields (zero hits).", "open_questions": [], "out_of_scope_findings": [ "filed as #11230: a scalar data.id beside a NON-scalar where.id (operator object, array, null) still silently drops the predicate and a declared multi — the last silent where.id shape after #11142; reversing it touches the other half of the #5748 pin, needs a ruling (unassigned, unlabeled for triage)", "filed as #11231 (finding label): settings-service-plugin and metadata database-loader fold their resolved id into the payload with the losing spread order ({ id, ...data }) — a payload id would override the ingress-resolved id with no where for the #11142 refusal to catch; reachability honestly unproven, hardening is flipping the spread order to match the documented REST-batch convention" ] }
Generated by Claude Code
Generated by Claude Code
ACCEPT — PR #11229, enqueued
Seat
domain:engine, sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y. Out of draft, auto-merge (squash) enabled, in that order.CI read by name at
60bcad871: 32 check runs, 31success, 1skipped(Console Pin Gate— conditional, no.objectui-shain this diff). Zero non-green, zero running.The ruling was implemented as ruled, and the boundaries were pinned so it cannot creep
The reversal is confined to the unequal truthy scalar shape. What makes that credible is not the claim but the pins around it: a falsy scalar
where.id(0,'') stays by-id; a non-scalarwhere.id($in, array,null) keeps its #5748 verdict; awherecarrying a conflicting id plus extra keys still takes the #11009 refusal first, byte-identical, so no existing #11009 pin moves. #6435 and #11009 each declined to widen past their ruled scope; this card now has the fence to match.Pin-reversal discipline honoured exactly: 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 pin becomes a refusal pin assertingcode+statusand that nothing reaches the driver. The dev also found a second pin of the same shape outside my predicted surface (engine-update-by-id-payload-id.test.ts) and flipped that too rather than letting it rot.Strict identity is the right call and I want it on the record:
42beside'42'refuses, and the message spelling makes the type mismatch visible. A coercing comparison would have been the lenient-consumer move.My open question — answered by measurement, and the answer was not what the docs implied
I asked whether the declared
status: 400actually reaches a client, becausecontent/docs/api/error-handling-server.mdx:11says the kernel REST server "does not auto-translate an arbitrary thrown error" and recognizes only a small fixed set of codes. If true, a loud engine refusal could have surfaced as a 500 — every pin green, and the ruling's intent defeated at the wire.Measured on the real
RestServerhandlers + real protocol + real engine:updateMany/ batch rows →code: UPDATE_ID_MISMATCH,httpStatus: 400viatoRowApiError.mapDataError→ status 400 + code, via the declared-status passthrough.- Single-record
PATCHcannot produce the conflict at all — the RESTPATCH /data/:object/:id:请求体里的标量id压过路径:id,存在性探测/OCC 判在一行、写落在另一行、响应报第三个说法 #6479 fold makesdata.id === where.idon that path by construction (measured 200 on the path row).
⇒ The 400 reaches the wire, and no
packages/restchange is needed. The doc sentence does not govern this path. Worth noting the shape of the answer: the concern was legitimate, and it was closed by running the route rather than by re-reading the doc that raised it.packages/spectouch — accepted deliberately, not overlookedThis PR appends
UPDATE_ID_MISMATCHtopackages/spec/src/api/error-code-ledger.zod.ts.packages/specbelongs to thedomain:specseat, and I am recording why I did not bounce it:- ADR-0112 makes registration mandatory for a declared code; the ruling required a declared code. Refusing the append would make the maintainer's ruling unimplementable.
- The change is purely additive — one string plus an explanatory comment, in the
@objectstack/objectqlsection, i.e. this lane's own entries. No schema shape moves. - The rule that spec has one owner exists so contract-shape decisions have one owner. A gate-mandated ledger registration is not such a decision.
I'll notify the spec seat rather than let their ownership be bypassed silently.
Docs drift — checked, nothing falsified
Three hand-written pages name
ERROR_CODE_LEDGER. I read all three:error-catalog.mdxlists codes as examples ("such as") and documents the add-a-code process;error-handling-server.mdxdescribes what REST recognizes, not the ledger;data-engine.mdx's "Error Codes" table lists three codes and already omitsERR_READONLY_FIELD_REJECTED, which that same page discusses — so it was never exhaustive and its silence is not a lie. Adding a row would be an improvement, not a correction. ⛔content/docs/releases/v17.mdxis release-owned and correctly untouched.Two follow-ups filed rather than absorbed
- A scalar
data.idbeside a NON-scalarwhere.idstill silently drops the predicate — the remaining silent shape after the #11142 refusal, pinned by #5748 #11230 — a scalardata.idbeside a non-scalarwhere.idstill silently drops the predicate and a declaredmulti. This is the last silentwhere.idshape after this card, and reversing it touches the other half of the ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748 pin, so it needs its own ruling. Correctly filed unassigned and unlabelled for triage rather than smuggled into this PR — exactly the boundary the dispatch order drew. - Two ingresses fold their authoritative id into the payload with the losing spread order —
{ id, ...data }lets a payloadidoverride the id the ingress resolved #11231 (finding) —settings-service-pluginand the metadata database-loader fold their resolved id with the losing spread order ({ id, ...data }), so a payload id would override the ingress-resolved id with nowherefor this refusal to catch. Reachability honestly unproven, and stated that way.
Generated by Claude Code
- added a commit that references this issue
on Aug 23, 2026 - added a commit that references this issue
on Aug 23, 2026 - added a commit that references this issue
on Aug 25, 2026 - added a commit that references this issue
on Sep 28, 2026
Found while implementing #11009 (the unhonoured by-id predicate refusal). Filed separately and unassigned: acting on it would reverse a pinned #5748 verdict, which is a maintainer decision, not a rider on that card.
The shape
resolveEngineUpdateDispatchrulesby-idwithexpectId: 'rec_1'— the payload id outrankswhere(#5748, pinned inENGINE_UPDATE_DISPATCH_CASESas'a SCALAR data.id still wins over a scalar where.id'). Thewhere.id = 'rec_2'predicate is then discarded by the by-id driver path exactly the way #11009's extra keys were: the caller wrote "update rec_1 where id = rec_2" — a condition that can never hold — and the write lands onrec_1anyway, with no diagnostic.Why #11009 did not cover it
#11009's refusal fires on
wherekeys beyondid("a by-id dispatch carrying predicate keys beyondid", per its triage route). Here the onlywherekey ISid, so the refusal does not reach it — and making it reach would flip a case the shared case-set pins with an explicitexpectIdassertion, i.e. a partial rollback of #5748's ruling A. The #6435 block inengine.tsrecords the same boundary from the other side: widening beyond the ruled scope "is a separate decision, not a rider".When the two ids are EQUAL the shape is redundant-but-honoured (the REST ingress folds the path id into the payload, so
data.id === where.idis a normal spelling); only the UNEQUAL case is a silently-dropped declaration.What a fix has to decide
where.idis a truthy scalar different from the bound payload id (same loud-refusal family as ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748 / 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). Cheap, but changes a pinned verdict — needs a ruling.where.idis a caller bug too rare to arm. That keeps one silently-inert predicate shape in a family that has otherwise been made loud.Refs:
packages/metadata-core/src/engine-update-dispatch.ts(case'a SCALAR data.id still wins over a scalar where.id'), #5748, #6435, #11009.