Repository navigation
fix(metadata-protocol): an update by id answers the same refusal whether or not the id exists, as delete does - #28615
Conversation
… id exists WIP: the HTTP-door cases, measured before the change. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
…her or not the id exists, as delete does The by-id update doors (updateData, updateManyData, batchData update) no longer ask existence ahead of the engine. The engine judges the write first and refuses a row that is not there itself; the protocol asks existence only after the engine resolved a null readback. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
The engine pin: a refusal the middleware chain answers comes before the by-id not-found gate, for update as for delete. The dogfood pin: on the stock showcase, PATCH, the bulk update doors and DELETE answer one refusal for an id that names a row and for one that names none, with the edit-grant, row-scope and administrator controls. 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>
The batch conformance double refuses a missing id the way ObjectQL's by-id gate does; a refused PATCH on a plugin driver costs the remote no read; a tokenless PATCH, like a tokenless DELETE, reads nothing ahead of the engine. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
… after the write The existence check below the write throws recordNotFoundError in the seam itself, and the protocol test drives its refused-caller and out-of-scope cases through the file's one engine double. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
The action loads its record through the read door, so the matching data surface is GET by id. A member without edit on the object gets the write refusal on PATCH whatever the id, now that an update by id is judged before the record is looked up. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
A PATCH with no token, or a blank one, reads nothing ahead of the engine. A missing id is answered RECORD_NOT_FOUND by the engine's not-found gate, or by the existence check after a null readback, and a token never turns it into a conflict. 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 88d048ed0614a5ab30d5d81a6c8680a7c4173f67 && git checkout 88d048ed0614a5ab30d5d81a6c8680a7c4173f67
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7449e479c452394223ff5ab3a7af41c76a880608 0acc8a19bedce53504aae3c85b2215a8a5757fb3 && git checkout -B drift-repro 7449e479c452394223ff5ab3a7af41c76a880608 && git merge --no-ff 0acc8a19bedce53504aae3c85b2215a8a5757fb3
node scripts/docs-audit/affected-docs.mjs --json 7449e479c452394223ff5ab3a7af41c76a880608
|
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ACCEPT → head 维护者速读
席位意见:收。席位逐行读了
席位核对
Generated by Claude Code |
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
Clause-②: no
Metadata-protocol: none
Skills-docs: none — no file under
skills/**teaches what a by-id update answers for an id that names no record (agit grepofskills/forRECORD_NOT_FOUNDnames only the approvals record lock), so no skill line changes. The Update Record section ofcontent/docs/protocol/kernel/http-protocol.mdxstates the order.维护者速读
PATCH、批量updateMany、batch(update)三个入口,以及经它们更新的所有入口(dispatcher/data、/mcpHTTP 工具、声明式更新动作)。405、乐观锁(If-Match)、upsert 与系统上下文写入均不变。回滚 = revert 本 PR。packages/metadata-protocol/src/protocol.ts的updateData——删掉写前的存在性探测,改为引擎返回null后再查存在;②packages/metadata-protocol/src/protocol-batch-data.ts的两处 update 分支(同一改法);③packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts。pnpm --filter @objectstack/dogfood exec vitest run test/update-by-id-refusal-order.dogfood.test.ts—— 预期 16/16 通过(四类对象上 PATCH、批量更新、DELETE 对"存在/不存在"的 id 给出同一个回答,另有编辑权限、行级范围、管理员三组对照)。What changes
An update by id now answers the same refusal whether or not the id exists, as delete does.
updateData(PATCH /api/v1/data/:object/:id, and every door that updates through it: the runtime dispatcher's/data, the/mcpHTTP tools,callData, declarative update actions) no longer reads the record before callingengine.update. The engine judges the write first — the security middleware's object- and row-level verdicts, the external datasource's write guard — and then its own by-id not-found gate (inside the middleware chain, ahead of everybefore*hook) answers404 RECORD_NOT_FOUNDto a caller the write is open to. The protocol asks existence (the system-context probe) only when the engine resolved anullreadback: that keeps the404for an engine that resolvesnullfor a missing row, and keeps200 { record: null }for an update that moved its row out of the caller's row scope.updateDatais asked the waydeleteDataasks it:assertVersionMatch, a probe only when the caller sent a token.updateManyDataandbatchData'supdateoperation: the same — no probe ahead of the engine, the existence check after anullreadback, per row.content/docs/protocol/kernel/http-protocol.mdxstates the order.Unchanged, and already one answer for both ids for a caller refused the operation:
DELETEon every door and the cross-objectPOST /api/v1/batch(the engine judges first), and clone and the record field-access read, which load the record through the read door first (each measured at the HTTP door on the stock showcase). The action-by-id record load, the MCP stdio bridge'supdate_record/delete_recordandcallData's no-protocol fallback likewise read the record as the caller, through the read door, before any write, on their update and delete arms alike (read in code).Tests
packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts, 16 cases. A plain member (baseline only) onshowcase_project_membership,sys_user_permission_set,showcase_ext_customerandshowcase_ext_order:PATCH,updateManyandbatch(update) answer the same status and body for an id that names a row and one that names none;DELETEkeeps its one answer; a staleIf-Matchkeeps one status and code. Controls: a member holding the edit grant updates (200) and gets404 RECORD_NOT_FOUNDfor no row; a member with edit and a row-level restriction (showcase_private_note) gets one not-found answer for a foreign row and for no row and still updates its own; the administrator updates (200), gets404for no row on an ordinary object, and getsEXTERNAL_WRITE_FORBIDDENfor both ids on a read-only federated object, as itsDELETEdoes; refused writes leave the row untouched.packages/objectql/src/engine-write-not-found-gate.test.tsgains section 5: a refusal the middleware chain answers comes before the by-id not-found gate, for update and delete, and nothing reaches the driver.protocol.record-not-found.test.ts: a refused write answers the same for a real and a missing id onupdateDataanddeleteDatawith no read ahead of the engine; the post-write existence check is asked as the system and keeps the200of an update that moved its row out of scope.protocol.bulk-record-not-found.test.ts: the same parity onupdateManyDataandbatchData.Verification
All readings below were taken on head
0acc8a19b(this branch,origin/mainmerged at258fb1e94) unless a line names another commit (the metadata-protocol, runtime and dogfood rows were read ond0d3d0be0;0acc8a19bchanges onlypackages/objectql/src/protocol-data.test.ts); wall times are from a shared container. CI on0acc8a19b: 50 check-runs, 38 success, 12 skipped, none failed — all eightTest Coreshards,TypeScript Type Check,Lint · ESLintandCheck Changesetamong them.Touched packages — tests and typecheck
Other suites that drive
updateDatathrough a real protocol, on0acc8a19b: clientclient.test.ts219, organizationscreate-explicit-organization-wall.test.ts45, plugin-approvalsrecord-lock-batch-row-status.integration.test.ts4, http-conformancehono-declared-envelope.conformance.test.ts20, service-automationwrite-nodes-stored-metadata-family-refusal.integration.test.ts17, service-messaging (two notification-template files) 38 — all passed.Consumer suites, on
5a4fb7a95(the change itself is the same there; later commits touch tests and one equivalent spelling of the post-write check):pnpm --filter @objectstack/runtime test499 files / 8092 tests passed;pnpm --filter @objectstack/rest test362 files / 6904 tests passed.pnpm --filter PKG typecheckexit 0 formetadata-protocol,objectql,runtimeanddogfood;eslint --no-inline-configover the 10 touched.tsfiles exit 0.Ablation of the reorder — one-shot, restored
Mutation (
scripts/ablation-replace.mjs, anchor hit 1 → 0, blob78abbab6ef4b→c387c962240a): an existence probe put back ahead ofengine.updateinupdateData.ablation-dist-preflightfound the marker indist/index.jsanddist/index.cjsafter the rebuild.PATCHfamilies, the staleIf-Matchcase and the administrator's federated-object case (the bulk doors stay green: they were not mutated);protocol.record-not-found.test.tsunder the mutation: 4 of 18 red.Restore: blob back to the
HEADblob78abbab6ef4b,git diff HEADempty, whole-treegit status --porcelainempty, rebuilt;ablation-dist-preflight --absentmarker absent from all 24 built files; dogfood pin 16/16 and the unit file 18/18 green.Gates — dispatch-gates.mjs --commands on 0acc8a1
93 derived families, run locally, every one exit 0 (
pnpm check:type-check-debtunder the verify lock).dispatch-gates.mjs --ranwith every exit code recorded:93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN. Among them:Full dogfood battery, under the verify lock
On
924d21987: 363 files passed, 3 skipped, 1 failed —showcase-anonymous-deny-surfaces.dogfood.test.ts› "the SAME answer the REST data surface gives for that same id", which compared the action door's ghost-id answer with a member'sPATCHonshowcase_task, an object that member may not edit. That is this change's consequence: the comparison now uses the data read (the surface the action's record load goes through) and asserts the member'sPATCHis the write refusal. The repaired file is 75/75 ond0d3d0be0.Acceptance notes
pipelinespy instead of "the engine was never called". The engine-layer guarantee is pinned by the engine's own not-found gate tests.update-path-id-winsand two runtime tests change their read counts: a tokenlessPATCH, like a tokenlessDELETE, reads nothing ahead of the engine, and a refusedPATCHon a plugin driver costs the remote no read.packages/objectql/src/protocol-data.test.ts(OCC cases). A PATCH with no token or a blank one now asserts zero reads ahead of the engine; a missing id is answeredRECORD_NOT_FOUNDby the engine's not-found gate (the double rejects with the shared envelope) or, for an engine that resolves anullreadback, by the system-context existence check after the write — in both, a token never turns the missing row into a409.403 EXTERNAL_WRITE_FORBIDDEN, the answer itsDELETEalready gave, where it answered404.Generated by Claude Code