Repository navigation
fix(metadata-protocol): the by-id OCC comparison answers only for a row the caller reads - #28731
Conversation
…door when the caller's read returns no row On a version-token mismatch, updateData/deleteData re-read the row as the caller. When that read returns nothing, or refuses with a declared client-band status, the comparison is now dropped and the request goes on to the write, whose door answers it - the answer the same request without a token gets. A row the caller reads still answers 409 CONCURRENT_UPDATE with that row; a read that faults still propagates as raised. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
…untime and HTTP doors The protocol pins cover both arms (no row, read refused), the fault that still propagates and the readable-row conflict. The runtime pin adds an object the caller reads but may not write under the real security layer. The showcase pins compare status, code and sentence for a row and for no row, token or none, on a single install and behind the organization wall. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
…r takes Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
… body changed since the move Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 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 75cd2de3c1979da0d96efccdb290685f8cc2cd95 && git checkout 75cd2de3c1979da0d96efccdb290685f8cc2cd95
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01386412b8a502e823f0f8a3e66c713739cebd05 00f922f695baf5ea9d865d5ec6359c3d2547f7c8 && git checkout -B drift-repro 01386412b8a502e823f0f8a3e66c713739cebd05 && git merge --no-ff 00f922f695baf5ea9d865d5ec6359c3d2547f7c8
node scripts/docs-audit/affected-docs.mjs --json 01386412b8a502e823f0f8a3e66c713739cebd05
|
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Contract reviewServed-tier: Read-only shape held: the diff at this head ( ① Derived judgmentsThe one changed body is
② Semver levelDeclared: ③ Boundary flags
④ Design judgmentNo
Must-fix
Should-fix
Implemented-by: VERDICT: FAIL |
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Triage: a stalled PR, 11 h since its contract-review FAIL · 2026-11-02T04:20Z⛔ Not a claim. @sunhaolin (the
Generated by Claude Code |
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Contract-review-hold: session_017xRBcVy4VXmkQjn2MGeaWk · head 90a6068 · rework ruled, queued · until the rework commit lands on this branch · 2026-11-02T04:22Z This PR is not stranded. The
No adoption by Generated by Claude Code |
Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
…w absent from the caller's read A caller re-read that throws now propagates as raised again, refusals included; only a read that returns no row leaves the answer to the write door. The docblocks, the changeset and the http-protocol paragraph describe that absent-row case only. Pins: a write grant without a read grant keeps its stale-token write from running at the protocol double and at the runtime doors; the dogfood families whose read is refused compare status and code. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Contract reviewServed-tier: Read-only shape held: Merge claim verified. ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentAgainst ruling 5985885287 and the code it cites: the handover now covers exactly the ruling's subject — a row the caller's read returns nothing for, hidden or missing, answered by the write door whose step 2.7 asks the same read question and holds the guard — and nothing beyond it. A refused read is the read door's verdict on the caller, not on the row; propagating it keeps OCC's promise for the write-without-read class (a stale token never lands) while disclosing nothing about the row, since the refusal is object-level and identical for every id. The one residue (sentence wording for a neither-grant caller) is ruled, bounded to a sentence, pinned at the level the ruling accepts, and routed to its own item. The design holds; the M1 must-fix is closed; S1 is moot; S2 is done at the protocol double (refuse unset, Implemented-by: VERDICT: PASS |
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ACCEPT → head 维护者速读
席位意见:收。本 PR 落实维护者 2026-11-01 的 ① 裁定(选 a),返工时按路线 (i) 处理复核 FAIL 6561342764 指出的问题,维护者原话「#28731 同意你的推荐」。同一复核者对增量的复核 6577938158 为 PASS,没有 must-fix,也没有 should-fix。 席位核对
Generated by Claude Code |
One conflict, packages/metadata-protocol/src/protocol.ts: main moved the module preamble into themed modules (#29336) while this branch had edited the ConcurrentUpdateError docblock inside it. Resolved by taking main's removal and carrying the docblock edit to protocol-version-tokens.ts, whose move header now names that one changed docblock. The branch's three class-body hunks (updateData, probeRecord, assertVersionOf) merged clean. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ACCEPT → head 维护者速读
席位意见:收。本 head 与 席位核对
Generated by Claude Code |
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
Clause-②: no
Metadata-protocol: none
维护者速读
packages/metadata-protocol/src/protocol-record-reads.ts(assertVersionOf 的无行交接);packages/metadata-protocol/src/protocol.record-not-found.test.ts(有写无读一类的钉子);packages/runtime/src/domains/data-conflict-caller-visibility.test.ts(同一类在真实安全层两扇门上的钉子)pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.record-not-found.test.ts,预期 30 个用例全绿。Route (i)
Contract review 6561342764 asked for a ruling between two routes; the maintainer ruled route (i): 「#28731 同意你的推荐」. Only a row the caller's re-read returns no row for is handed to the write door; a re-read that throws — a refusal or a fault — propagates as raised, as before this PR.
What changes
assertVersionOf(packages/metadata-protocol/src/protocol-record-reads.ts, the comparison half of the by-id OCC gate thatupdateDataanddeleteDatarun before the write is authorized). On a token mismatch it re-reads the row as the caller, as before:engine.update/engine.delete, whose door answers it — the answer the same request gets without a token, and the answer a token on an id naming no row gets;409 CONCURRENT_UPDATEwith that row ascurrentRecord, field-masked as the caller reads it, including a row the caller reads but may not write (option a′ ruled out);assertVersionOfdocblock's own "the stale token then gets exactly the answer the same request without a token gets".protocol.ts(updateData,probeRecord,assertVersionOf),ConcurrentUpdateError's inprotocol-version-tokens.ts(its home since main's preamble move), and the record-reads module header describe the absent-row handover only, and say a thrown read propagates. The changeset does the same. Docs: one paragraph in the http-protocol page's refusal section: a stale token is answered409only for a record the caller can read; for a record absent from the caller's read, and for an id naming none, it changes none of the answers; a caller refused reading the object gets that read refusal.updateManyDataandbatchData'supdatearm carry no version token.no, patch: a refusal swap on existing doors, the same classification as fix(metadata-protocol): an update by id answers the same refusal whether or not the id exists, as delete does #28615. Metadata-protocol: none. Changeset:@objectstack/metadata-protocolpatch.Tests
protocol.record-not-found.test.ts, its existing pinned engine double, caller visibility added; no ledger change), per verb: a hidden row with the write refused; a hidden row with the write admitted up to the write door's read question; a read refused while the write is refused too (status and code compared, see below); a principal with a write grant and no read grant — the read refuses, the write door would admit the write: the stale token propagates the read refusal, the write is never asked and the row is unchanged, while the same double admits the no-token write (the control); a read that faults or throws any other 4xx propagates; the readable-row409control.data-conflict-caller-visibility.test.ts), per door (RestServer, dispatcher) and verb: an object the caller reads but may not write, with an RLS-hidden row (stale token = the control's403body; unknown id the same; a readable row still409); an object the caller may update and delete but not read, with no row rule: a stale token answers403 PERMISSION_DENIEDand the row is unchanged, on update and on delete; the no-token request lands (200), so the class is real.update-by-id-refusal-order.dogfood.test.ts): with the token inIf-Match, the PATCH body'sexpectedVersionand the DELETE's?expectedVersion, the two private-OWD rows the member's row scope hides compare status, code and sentence for the row, for no row and without a token. On the two objects the member may neither read nor write, its read is refused and propagates, so those families compare status and code only and deliberately do not compare the sentence: the read door's refusal and the write door's may word an operation-naming refusal differently. That sentence-level residue is accepted and handled by a separate D12 sentence-alignment item. Controls as before (edit-grant holder409carrying the row and200with the current token; the plain member's409on a row it reads but may not write).occ-stale-token-organization-wall.dogfood.test.ts): unchanged; another organization's row is absent from the member's read, so every carrier answers what no token answers.Acceptance notes
404 RECORD_NOT_FOUND, now produced by the write door.403 PERMISSION_DENIED); an operation-naming sentence may differ. Handled by a separate D12 sentence-alignment item; the pin for that case asserts status and code only.@objectstack/rest's builtdist/index.jscarries bundledmetadata-protocolsource, so the changed bytes ship in rest's tarball too; rest's own non-test source neither imports that package nor constructs the protocol class.Verification
Head
00f922f69: route (i) at8372e0a15plus a merge oforigin/mainat01386412b. Exit codes captured before any pipe; one locked battery,os-verify-lock: VERDICT command-exit 0(held 2939s, shared box).Merge with main — one conflict, resolved keeping both sides
One conflicted file,
packages/metadata-protocol/src/protocol.ts, one hunk: main moved the module preamble into themed modules, verbatim (#29336), while this branch had edited theConcurrentUpdateErrordocblock inside that preamble. Resolution: main's removal taken; the docblock edit carried, unchanged in wording, toConcurrentUpdateError's new homeprotocol-version-tokens.ts, whose move header now names that one changed docblock. This branch's three class-body hunks inprotocol.ts(updateData,probeRecord,assertVersionOf) merged clean and are byte-identical to the branch's own (compared hunk bodies against8372e0a15).protocol-record-reads.tsand every test file are untouched by the merge. The delta against main is 9 files: the 8 of this PR plusprotocol-version-tokens.ts.Build, pins, suites and typechecks at 00f922f — all green
pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' --filter '@objectstack/runtime^...' --filter '@objectstack/runtime' build— exit 0pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=1 src/protocol.record-not-found.test.ts— Tests 30 passed (30), exit 0pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=1 src/domains/data-conflict-caller-visibility.test.ts— Tests 24 passed (24), exit 0pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1over the four token-carrying files (update-by-id-refusal-order,occ-stale-token-organization-wall,data-request-shape-live,object-designer-field-reorder) — Test Files 4 passed (4), Tests 39 passed (39), exit 0typecheckexit 0; runtimetypecheckexit 0vitest run --maxWorkers=1— Test Files 304 passed, 4 skipped (308); Tests 22167 passed, 26 skipped (22193); exit 0At
8372e0a15(before this merge): runtime full suite--project local --maxWorkers=1— Test Files 515 passed, Tests 8343 passed, 19 skipped, exit 0; dogfoodtypecheckexit 0.One-shot ablation of the reviewed head's refusal hand-over, restore proven (at 8372e0a; protocol-record-reads.ts unchanged by the merge)
Mutation through
scripts/ablation-replace.mjs(wrap mode) plus an EXIT/INT/TERM trap on the absolute path: the reviewed head's catch re-instated (a declared client-band read refusal handed to the write door). A first attempt inside the battery was void — its tree reading was given the dist spelling of the marker, refused with exit 3, and no test ran under the mutation; it is reported here, not counted. The valid run (VERDICT command-exit 0, held 169s):78150561999ftoaa7a51f44c28; injected text 1, removed text 0 on disk; rebuild exit 0;ablation-dist-preflight --source-marker: marker present in 2 built filesRestore:
git checkout HEAD --on the absolute path; blob equals the HEAD blob78150561999f,git diff HEADempty, working tree clean; rebuild exit 0;ablation-dist-preflight --absent --source-marker: marker absent. Restored runs: 30/30, 24/24, 21/21.Derived gate families at 00f922f — 92 derived, 90 exit 0, 2 NOT MEASURED
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat00f922f69(9 paths vs merge base01386412b): 92 commands, each run under the lock and recorded with its exit code;--ran: "92 derived famil(ies) accounted for — 90 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)". NOT MEASURED (PREREQUISITE NOT MET, packages outside this build closure unbuilt):pnpm --filter @objectstack/spec run check:skill-examples,pnpm check:dual-build-cjs-loads.pnpm check:type-check-debtran, exit 0.Generated by Claude Code