Uh oh!
There was an error while loading. Please reload this page.
fix(objectql,metadata-core,service-messaging): refuse a by-id write whose where carries predicate keys the by-id path drops (#11009) - #11147
Conversation
…hose where carries predicate keys the by-id path drops (#11009) A by-id update/delete routes to driver.update/delete(object, id, ...), which bind only the primary key — every other options.where key was silently discarded, so a compare-and-set guard evaluated to nothing and the write landed unconditionally. Measured on better-sqlite3 through a real ObjectQL + SqlDriver: SqlHttpOutbox.redeliver's terminal-status guard was inert; a row claimed in_flight between its read and its write was reset anyway and the redelivery reported success. The dispatch predicates (metadata-core, shared with every pinned test double) now rule: - a where naming a scalar id and NOTHING else stays by-id, with or without multi (the LifecycleService guarded-reap idiom keeps its per-record cascade path — pinned in both case tables); - a where carrying a scalar id plus other keys WITH multi:true routes to the predicate path (updateMany/deleteMany), which compiles every where key — the compare-and-set spelling; - the same shape without multi — and any data.id-sourced by-id beside extra where keys (payload id outranks multi per #5748) — is refused loudly, naming the dropped keys, via the shared #11009 composer. ObjectQL.update/delete key their predicate-path branch on the ladder's verdict (not the raw multi flag) and throw the dispatch's own message. SqlHttpOutbox.redeliver rides the predicate path so its guard is evaluated; MemoryHttpOutbox re-checks terminal status after its guard, so both IHttpOutbox implementations refuse a mid-flight claim with DELIVERY_NOT_ELIGIBLE instead of reporting success. Fixes#11009 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift Check8 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 16 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 665eb3344cca33d7834f9d3854c585cfb08dc635 && git checkout 665eb3344cca33d7834f9d3854c585cfb08dc635
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin acb4dbc09703d5c6145efb376c50ea12dfe9f41c 2e3d05049e47276cc59b60f3d90a7b3f6fce65a4 && git checkout -B drift-repro acb4dbc09703d5c6145efb376c50ea12dfe9f41c && git merge --no-ff 2e3d05049e47276cc59b60f3d90a7b3f6fce65a4
node scripts/docs-audit/affected-docs.mjs --json acb4dbc09703d5c6145efb376c50ea12dfe9f41c |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32602076426 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11009
The defect, measured before the fix
A by-id
update/deleteroutes todriver.update(object, id, …)/driver.delete(object, id, …), which bind ONLY the primary key (sql-driver.ts:5815builds.where('id', id)+ tenant scope and never readsoptions.where). Every otherwherekey was silently discarded. Reproduced onorigin/main@95437e7d2(better-sqlite3, realObjectQL+SqlDriver,sys_http_delivery):update(…, { attempts: 0 }, { where: { id: 'p1', status: { $in: ['success','failed','dead'] } }, multi: false })on apendingrow — landed (attempts7 → 0) although the predicate demanded a terminal status;SqlHttpOutbox.redeliverwith a guard that flips the row toin_flightinside its read→write window reset the row anyway (pending,attempts: 0, claim cleared) and reported success.Both measurements were taken as assertions of the buggy behaviour and passed on the unfixed tree before any edit.
The fix — refuse at the engine (triage's option 2 + 3, the #5748/#6435 loud-refusal family)
The rule lands in the shared dispatch predicates (
packages/metadata-core/src/engine-{update,delete}-dispatch.ts), soObjectQL, its case tables, and every one of the pinned fake engines (check:engine-double-contract) get one verdict:where: { id }— pure primary-key address, with or withoutmulti: trueLifecycleServiceguarded-reap idiom keeps its per-record cascade path; now pinned in both case tables and inengine-delete-dispatch.test.ts)where: { id, …extra }, multi: true(id sourced fromwhere)multi— the predicate path (updateMany/deleteMany) compiles EVERYwherekey; the compare-and-set spellingwhere: { id, …extra }, nomultiengineByIdUnhonouredPredicateMessage, one source-blind composer shared by both verbs)data.id+ any extrawherekeys,multior notwheresilently droppedmulti(#5748) and cannot be demoted onto the predicate path, so the unhonourable predicate is loud, never silently dropped or silently bulk-promotedObjectQL.update/deletenow key their predicate-path branch on the ladder's verdict instead of the rawoptions.multiflag (otherwise a refuseddata.id+multicall would silently become a bulk write) and throw the dispatch's own message; the classic no-id-no-multi shapes keepENGINE_UPDATE_REJECT_MESSAGE/ENGINE_DELETE_REJECT_MESSAGEbyte-identical.Blast-radius census (measured FIRST, per the triage order)
Scripted sweep (balanced-paren argument scan over every
.update(/.delete(call with awhere:bag, 4863 files) + a second sweep fordata.id-sourced shapes + amulti: falsegrep. Every hit classified:whereid+extra, production: exactly one —SqlHttpOutbox.redeliver(multi: false, the measured victim; migrated in this PR). Zero others.whereid+extra +multi: true: zero callers anywhere (the rerouted cell of the table above ships with no occupants).data.id+ separatewherebag: zero production callers.where: { id }+multi: true(verdict deliberately unchanged):LifecycleServiceguarded reap (production — its comment depends on the by-id cascade path),plugin-sharingbu-tree tests,engine-data-events.test.ts(pins the per-record event for exactly this shape). This is why "multi always wins" was rejected: it would have broken a deliberate, pinned production behaviour.update_record/delete_recordnodes whosefilternamesidplus other keys withoutmulti— previously silently unconditional, now loud (stated in the changeset).assert*Dispatch; the one test asserting the old single reject-message constant per case (service-queue/db-queue-adapter.test.ts) now quotes the predicate's own per-case message.The guard of every censused caller is expressible on the predicate path, so the triage stop-condition was never reached, and the by-id driver path was not widened.
redeliveroff the trap — both implementationsSqlHttpOutbox.redeliverrides the predicate path (multi: true): the terminal-status CAS is compiled into the oneUPDATE, a miss writes 0 rows, and the existing read-back reportsDELIVERY_NOT_ELIGIBLEinstead of a false success. Tenant classification is unchanged (threadedtenantId, neverbypassTenantAudit) — tenant-audit, theupdatehalf:ackis a dispatcher sweep butredeliveris request-reachable — two sites on one object with OPPOSITE classifications #10740's pins migrated to theupdateManyop with their vacuity controls intact, plus a new positive control for that op.MemoryHttpOutbox.redeliverre-checks terminal status after its awaited guard (bounded inline fix, declared on the issue): without it the twoIHttpOutboximplementations would disagree on exactly the race this card closes.outbox-dispatcher-scope.tsdocstring re-anchored:multino longer discriminatesredeliverfrom the sweep helper; the tenant classification does.Acceptance evidence (triage-named)
redeliver-concurrent-claim.integration.test.ts— realObjectQL+SqlDriveron better-sqlite3: the guard window flips the row toin_flight; the reset does not land (row staysin_flight,attempts: 4,claimed_by: 'racer') andredeliverrejectsDELIVERY_NOT_ELIGIBLE. Before the fix the same harness measuredpending/attempts: 0/success. A still-works leg pins the unraced reset, and the memory-store leg pins the same contract.Verification (local; CI convergence is the PM's read)
All at head
2e3d05049, through the shared verify lock:metadata-core165 ·objectql4046 (228 files — dispatch conformance drives the REAL engine over both case tables, reject messages asserted byte-identical) ·driver-sql1795 ·metadata-protocol1853 (the 13 pinned fakes) ·service-messaging259 (re-run at head) ·service-automation998 ·plugin-sharing624 ·plugin-webhooks128 ·service-queue56.pnpm typecheckon the four changed packages (script names echoed; wrapper verdictcommand-exit 0).check:changeset-gate-self-tests·check:durability-log-level·check:objectui-changeset·check:slot-lookup·check:stack-collection-maps·check:test-source-alias·check:type-source-resolution·check-adr-0087-registration·check-changeset-no-major·check-ci-filter-parity·check-empty-changeset·check-engine-split-ratio·check-plugin-teardown-shape·docs-audit/check-affected-docs·check:query-options-erasure·check:engine-double-contract·check:where-matcher·check:nul-bytes·check:driver-conformance(prompt-named;sql-driver.tsuntouched) ·check:i18n·check:type-check-coverage·check:type-check-debt(after the full workspace closure build the gate itself prescribes) — all green. Gate list re-derived withnode scripts/pm/dispatch-gates.mjs(no paths; 16 changed files vs merge base95437e7d2).Out of scope, filed
data.idbeside a DIFFERENT scalarwhere.idsilently drops thewhere.idpredicate — the one unhonoured-predicate shape #11009's refusal deliberately left standing #11142 — a scalardata.idbeside a DIFFERENT scalarwhere.idstill silently drops thewhere.idpredicate; the only unhonoured-predicate shape left standing, because refusing it reverses a pinned ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748 verdict (expectIdcase) and is a maintainer decision. A scalardata.idbeside a DIFFERENT scalarwhere.idsilently drops thewhere.idpredicate — the one unhonoured-predicate shape #11009's refusal deliberately left standing #11142 is not addressed here and remains open.Generated by Claude Code
Generated by Claude Code