Repository navigation
fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read - #23628
Conversation
…d, at both /data doors Claude-Session: https://claude.ai/code/session_01YNquHRZKDpiKsgmHXSd9Jx Co-authored-by: Claude <noreply@anthropic.com>
…r; a row the caller cannot read answers not-found Claude-Session: https://claude.ai/code/session_01YNquHRZKDpiKsgmHXSd9Jx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNquHRZKDpiKsgmHXSd9Jx Co-authored-by: Claude <noreply@anthropic.com>
…contract Claude-Session: https://claude.ai/code/session_01YNquHRZKDpiKsgmHXSd9Jx Co-authored-by: Claude <noreply@anthropic.com>
…nflict-current-record-visibility
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 a11f9df0a8f0d143feb0387d35b31196b97fce3d && git checkout a11f9df0a8f0d143feb0387d35b31196b97fce3d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e611e2bdae9c1adb3d28d0e6b8e0cb4cb2f77c2d 2a20fe2d80044a52e88ee5238c238674d798c85a && git checkout -B drift-repro e611e2bdae9c1adb3d28d0e6b8e0cb4cb2f77c2d && git merge --no-ff 2a20fe2d80044a52e88ee5238c238674d798c85a
node scripts/docs-audit/affected-docs.mjs --json e611e2bdae9c1adb3d28d0e6b8e0cb4cb2f77c2d
|
席位核对 — ACCEPT ·
|
Contract reviewServed-tier: Inputs: card #23586 (body and every comment, 6183306689 through 6187598620), PR #23628 (body, its 3-file list, the net diff against the merge base ① Derived judgmentsPublic surface. Accept set. No request served before is refused now: the matched-token and no-token paths are the old paths unchanged (the extra read sits on the mismatch branch only; pin 4 counts the engine reads). A hidden row's stale token moves from one error ( Every path that answers a version conflict.
A hidden row's stale-token answer against the no-token control, both doors. On a mismatch the fix re-reads the row by id under the caller's context ( A read that is refused rather than absent (the object's read grant withheld): the fix propagates it. That is plugin-security's own rule for the write pre-image (#21771 ruling A: only absence is "cannot read"; a declared refusal keeps the write's answer). The refusal carries no row and no version, and the same caller's no-token write already answers an absent row and a present row differently, so nothing about existence is newly disclosed. Right. FLS. The existence probe stays the system's. Pins. One new file in Gates on the head. 44 check-runs, all completed. The five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard) and every other non-skipped run conclude Disclosure rule. The PR body, the changeset and the pin file's docblock stay at function level: mechanism and answers, no reproduction recipe beyond what the card itself states, no record samples. Right. ② Semver levelThe changeset
③ Boundary flagsDev deviations (report 6187368395):
PR-body acceptance notes: the Hono doors untouched (right under the claim's file surface; the producer's narrowed answer reaches them);
Nothing escalated. Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 38705881035 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #23586
Clause-②: no
What changes (function level)
metadata-protocol'supdateDataanddeleteDatajudge the caller's version token on the rowprobeRecordreads with a system context. That probe asks existence, not visibility, and it stays exactly as it is. What changes is the mismatch branch ofassertVersionOf:context(readAsCaller, a by-idengine.findOnewith the same context spelling as the write it precedes), so every RLS and FLS rule of the read door applies.assertVersionOfthrowsrecordNotFoundError(object, id). That is the same producer the security layer's by-id write gate uses for a row the caller cannot read, so the stale-token request gets exactly what the same request without a token gets:404 RECORD_NOT_FOUND, with nocurrentVersionand nocurrentRecord.ConcurrentUpdateErroras before, withcurrentRecordset to the caller's read, so it is field-masked like the caller's own read.currentVersionis still the token the comparison ran on.assertVersionOfbecame async and takes the request context. Both callers (updateData, andassertVersionMatchfordeleteData) passrequest.contextand await it.The four
probeRecordcallersupdateDataandassertVersionMatch(deleteData): these are the conflict path. Both are fixed by the one change inassertVersionOf.assertRecordExists, used byupdateManyDataandbatchData'supdatebranch: existence only. It never takes a version token, never hands the probed row to the caller, and leaves the write toengine.updateunder the caller's policy. Not this path.batchData'supsertfork: existence only, deciding update versus insert. It has no version token, and the response row isengine.update's readback under the caller's context, never the probed row. Not this path.Pins
New file
packages/runtime/src/domains/data-conflict-caller-visibility.test.ts. It uses the measured rig shape: a realObjectQLover a realSqlDriver(better-sqlite3), with the realSecurityPluginstarted (an RLS policy and an FLS rule on one object, nothing on a second). It runsPATCHandDELETEthroughRestServer's registered routes and through the runtimeHttpDispatcher's/datadomain. A dispatcher throw is rendered bydeclaredEnvelopeForThrow, the exit the Hono hosts use, and the thrown error is read too. Per door and verb:toEqual), with nocurrentVersionorcurrentRecordon the wire or on the thrown error. The row is unchanged.409 CONCURRENT_UPDATE,currentVersionequal to the stored token, andcurrentRecordequal to the caller's own read of the row, without the hidden field.409with the whole row and its version, so conflict detection still works.200, and the engine'sfindOneis called the same number of times as without a token (plus the one probeDELETEalready issues when a token is present).The pins live in
runtime, beside the existing both-door OCC pins (data-update-expected-version.test.ts,data-delete-expected-version.test.ts). It is the one package whose test graph holdsRestServer, the dispatcher,plugin-security, a SQL driver anddeclaredEnvelopeForThrowtogether. They are in a new file.Verification
The readings below were taken at
7a4652fdb4, beforemainwas merged in at2a20fe2d80. The re-runs on the merged head are in theos-dev-reportcomment on #23586.metadata-protocoldist):8 failed | 8 passed (16). The eight failures are the RLS and FLS rows on both doors and both verbs (a409carrying the hidden row or field where the control answered404). The positive-control and matching-token rows passed.metadata-protocolrebuilt):16 passed (16). The final verification runs are listed in the report comment on metadata-protocol: a 409 CONCURRENT_UPDATE carries currentRecord as read with a system context (probeRecord), so a caller who sends a stale version may receive a row, or fields, that row- or field-level security hides from them #23586.pnpm --filter @objectstack/metadata-protocol test: 237 files, 28429 passed.typecheck: exit 0.pnpm --filter @objectstack/runtime test: 385 files, 5798 passed.typecheck(withcheck:test-typecheck): exit 0, and the new file adds no ledger entry.pnpm --filter @objectstack/rest test: 285 files, 5858 passed.dispatch-gates --commandslist for this diff, run one by one.check:query-options-erasurecaught an untyped options bag on the first pass. It is now typed toEngineQueryOptionsParsed/EngineQueryOptions. Per-gate readings are in the report.Acceptance notes
plugin-hono-server) are not touched. After this change the producer's conflict answer holds only what the caller reads, so ifdeclaredConflictContextforwardedcurrentRecord, that record would be safe to send. The hold hono: a 409 DELETE_RESTRICTED answered by createHonoApp carries no developerMessage, dependentObject, dependentCount or object, which RestServer's structuredCodeAnswer arm carries (the #23531 family beyond CONCURRENT_UPDATE) #23587 placed there can be lifted on that card. A row the caller cannot read now answers404 RECORD_NOT_FOUNDthrough those doors as well.currentVersionis the token the comparison ran on (the system probe'supdated_at), so its meaning is unchanged. If an FLS rule ever hidupdated_atitself, the version token would still be published on a conflict. Agit grepoverexamples/andpackages/finds no field-permission entry keyed on.updated_at(0 hits), and this PR does not change that case.Generated by Claude Code