Repository navigation
fix(runtime): a declarative update action refuses a caller with no edit grant 403 before the record lookup, as the data door does - #30878
Conversation
… 403 before the subject-load verdict, as the data door does Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3
…oors on a booted kernel Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3
… 403 ahead of the not-found pair Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3
… bypasses it, so the census holds no new elevation read Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 28 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 99b9207ab254a3cf7f5870c2cb9107b4b9555abd && git checkout 99b9207ab254a3cf7f5870c2cb9107b4b9555abd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 98f640d581339769d54cf27c8f7b02cbc46849f4 5f17e7c8feabe06e769b7846af2e133d141eaa4a && git checkout -B drift-repro 98f640d581339769d54cf27c8f7b02cbc46849f4 && git merge --no-ff 5f17e7c8feabe06e769b7846af2e133d141eaa4a
node scripts/docs-audit/affected-docs.mjs --json 98f640d581339769d54cf27c8f7b02cbc46849f4 |
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW 席位核对 · ACCEPT · 2026-11-06T09:02Z · #30848Head-sha: 2dc4bdf
|
…swers the write's 403; the edit-grant holder carries the ghost-id 404 Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42192137004 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW 席位核对 · ACCEPT (delta) · 2026-11-06T10:16Z · #30848Head-sha: 5f17e7c (one commit on the accepted
|
Requested by Elon · project thread
domain:server#1 · os-elon-musk · session_016WV4czxVZYV4CcMiCbofw3
Fixes #30848
Clause-②: no
Metadata-protocol: none
Claim: comment 6679693194 (branch
claude/project-thread-y8qx4d).维护者速读
operation: 'update',REST/actions与 MCPrun_action)在按"读不到这条记录"回 404 之前,先问调用者对该对象有没有编辑权;没有就回 403PERMISSION_DENIED,与数据接口(PATCH /data、MCPupdate_record)对同一次写入的回答一致。explain(update),正常路径不变;有编辑权的人仍对"看不到的行"和"不存在的 id"回同一个 404,不形成存在性探测。单 PR 可整体回滚。packages/runtime/src/action-execution.ts(refuseUngrantedDeclarativeUpdate与executeDeclarativeUpdateAction中它的调用点)、packages/runtime/src/declarative-update-refusal-order.pin.test.ts。cd packages/runtime && pnpm exec vitest run --project local src/declarative-update-refusal-order.pin.test.ts,预期 20/20 通过。What changes
refuseUngrantedDeclarativeUpdatein@objectstack/runtime(action-execution.ts).executeDeclarativeUpdateAction, the executor both action doors call (REST/actionsand MCPrun_action), now calls it before it consumes the subject-load verdict (refuseDeniedSubjectLoad), and only when that load was denied.explain({ object, operation: 'update' }), the contract's "would the middleware allow this operation?" answer, computed by the enforcement walk. The REST import-template gate already askscreatethe same way. No row is named in the ask, so the answer cannot depend on whether the id names a row. Nothing is re-derived from permission sets, and nothing changes inplugin-security.explainthat throws, refuses with the data door's own refusal:403 PERMISSION_DENIEDwith the middleware's user-facing sentence (permission_deniedviarenderOperationMessage, the caller's locale, the deployment's i18n override). With no security service, or one withoutexplain, nothing is refused here, as at the data door.executeDeclarativeUpdateActionupdated to name the new order.The line the card asked for
On
maina2f523aa,invokeBusinessActionloads the subject atpackages/runtime/src/action-execution.ts:4406(const subject = await loadActionSubjectRecord(…)), and the declarative branch refuses on that load's verdict at:4169(refuseDeniedSubjectLoad(objectName, recordId, subject)inexecuteDeclarativeUpdateAction) before the write, the one place the object-level edit permission was asked, is attempted. The REST/actionsdoor follows the same sequence (domains/actions.ts, its own load, then the same executor).Other subject-loading paths (checked, not changed)
ActionSchema.operationisz.enum(['update']).refuseDeniedSubjectLoadcallers are read admissions, not writes performed as the caller: the flow and script/body dispatch (refuseDeniedSubjectLoadBeforeDispatch), the api forward that needs the row, and the flow start record keys (refuseUnreadableFlowStartRecords). No data door performs the same write for the same caller, so there is no second answer to disagree with. Their 404 is the read-admission answer by design (runtime: execute the declarative row-leveloperation: 'update'action — the platform action route performs one data-plane update of the current record as the caller (runtime half of #14092) #15079, MCPrun_actionon a flow action answersok: trueand starts the run on a row the caller cannot read (or that does not exist) — the flow door still turns a denied load into an implicit grant #16370). They do not share this bug, and no follow-up is listed.Why
content/docs/protocol/kernel/http-protocol.mdx, "Refusals come before the record lookup": a caller who may not update the object gets the same 403 whether or not the record id names a record.content/docs/protocol/objectui/actions.mdx: a declarative update fires with the caller's permissions "exactly as for a user edit". Both sentences were false for the action door. The finding came from #30802 / PR #30846 (travel_expenseop 4), which needs one expected refusal for this operation.Tests
packages/runtime/src/declarative-update-refusal-order.pin.test.tsboots one kernel (the plugin setunresolved-object-name.actions-door.pin.test.tsuses: ObjectQL, sqlite-wasm, Hono, the app, platform objects, auth, security, sharing, REST, dispatcher). Objectpin_stepis private OWD with a declarativedecideaction. Two invited members: a reader (default set: read + create) and an editor (an assigned set: read + create + edit). Each member owns one row.PATCH /data, the MCP bridge'supdate(whatupdate_recordcalls),POST /actions/pin_step/decide/:id, and the MCP bridge'srunAction(whatrun_actioncalls). The MCP leg is driven through the real/mcproute with a stand-inmcpservice that calls the two bridge members and reports the thrownstatusandcode. The tools' own envelope rendering is@objectstack/mcp's and is not under test here. Expected: the reader gets 403PERMISSION_DENIEDon all three rows at every door; the editor gets 200 on its own row and 404RECORD_NOT_FOUNDon the other two at every door. A refused write leaves the row untouched.Baseline on main's action door (the re-measure)
The pin run against
origin/main'saction-execution.ts(test file kept): 2 failed / 14 passed of the first 16 cases. Both failures were reader × hidden row and reader × missing id, at exactly two doors:Every other cell, including editor × hidden/missing (404 at all doors) and both visible rows, already agreed. This is the card's table (filer's probe on
c17719dd) on current main. The filer's probe script needs PR #30846's scenario harness worktree, so the re-measure went through this booted pin instead.Ablation (restored: blob equals HEAD, git diff HEAD empty)
scripts/ablation-replace.mjs: the verdict is consumed before the ask (refuseDeniedSubjectLoad(…); await refuseUngrantedDeclarativeUpdate(…)), i.e. the old order. The anchor went 1 → 0, and the blob went97cc3fe3→7e1ef3a8. The suite imports runtimesrc/directly, so no dist rebuild is needed.action RESTandaction MCP. Every editor row and every data-door row stays green.97cc3fe3equals HEAD, andgit diff HEADis empty.ablation-replacerefused before running anything (anchor 1 → 1). The second attempt is the reading above.Acceptance notes
updatewith no payload, so two pre-lookup refusals the data door gives a caller who holds the update grant are not asked here: a field-level write refusal on a field the payload names (middleware step 2.5), and a read-only external datasource's write guard. For such a caller writing a hidden row or a missing id, the action door still answers 404 where the data door answers 403. Both answers stay the same for the hidden row and the missing id, so there is no oracle. This is not measured here, and the pinned matrix does not reach it.update, so it keeps 404 for that caller. This is not measured here, and it needs a permission set granting edit without read.explaindenies a caller whose permission sets resolve empty. Since ADR-0056 D2 the middleware's CRUD gate does too, so this matches.Verification
All on head
2dc4bdfa, except where a line namesad748b98. That earlier head differs only in docblocks and the changeset prose, plus one line removed from the new function: theisSystemshort-circuit, dropped so thatexplainanswers a system context as the middleware does.pnpm --filter @objectstack/runtime typecheck: exit 0 (2dc4bdf).action-declarative-update.test.ts,action-invocation-audit.test.tsandaction-declarative-update-stale-summary.test.ts: 4 files, 90 tests passed (2dc4bdf).@objectstack/runtimelocal project, whole suite: 550 files, 8801 passed, 19 skipped (ad748b9).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 60 families, all run on 2dc4bdf with exit 0;--ranreconciliation: 60 derived, 60 run, 0 NOT-MEASURED. The first pass on ad748b9 had one red,check:system-context-census(a newisSystemread in a symbol no census row names), cleared by dropping that line. It also had one exit 3,check:dual-build-cjs-loads(PREREQUISITE NOT MET, no whole-tree dist), which exits 0 on 2dc4bdf.pnpm lint: exit 0 (2dc4bdf).@objectstack/runtimepatch, carrying both declaration lines.5f17e7c8(one commit on2dc4bdfa, test-only): the merge queue's Dogfood Regression Gate (3/3) caughtshowcase-anonymous-deny-surfaces.dogfood.test.tspinning the old 404 for the no-edit member (run 42192137004). The case now asserts this PR's 403PERMISSION_DENIEDfor the member, keeps Action-body writes have no not-found gate:ctx.api.object().update()against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867's ghost-id 404 with the dev administrator who holds the edit grant, and compares the action with the PATCH for both callers. Dogfood, full suite unsharded on5f17e7c8: 423 files passed and 1 failed, 3537 tests passed and 2 failed. Both failures werecatalog-activation-door.dogfood.test.ts:149 and :167 timing out at 5000 ms under box load; that file passed 10/10 alone on the same head, and the fixed file passed 80/80.Human Minimum
packages/specholds no runtime logic. (not touched)pnpm lintpasses.main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3