Repository navigation
fix(runtime): a non-admin approver's decision from the request page reaches the approvals routes instead of answering 404 - #28708
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mze6z6hhTUauqWMmZ
… that needs nothing from the row
The REST /actions door and the MCP run_action bridge answered their own
404 RECORD_NOT_FOUND ahead of the api forward when the caller's scope did
not deliver the subject row, so a non-admin pending approver's decision
from the request page never reached the approvals routes that admit them.
Both doors now call one shared rule: an api forward whose request needs no
field of the row is forwarded and answers its target's answer; the request
is built from the recordId alone; every other branch keeps the 404. The
forward also relays a target's { code, error } sentence.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mze6z6hhTUauqWMmZ
…e member outright Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mze6z6hhTUauqWMmZ
… serve does Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mze6z6hhTUauqWMmZ
📓 Docs Drift Check7 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 39f9768f4971b29ce015860c8a7b5dd134e16172 && git checkout 39f9768f4971b29ce015860c8a7b5dd134e16172
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin da6f0ce35da60bf81e79890b227815a989f9b165 f3768a460e0ea970462b374dc23b592c7dc8f63c && git checkout -B drift-repro da6f0ce35da60bf81e79890b227815a989f9b165 && git merge --no-ff f3768a460e0ea970462b374dc23b592c7dc8f63c
node scripts/docs-audit/affected-docs.mjs --json da6f0ce35da60bf81e79890b227815a989f9b165 |
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract reviewServed-tier: Inputs read: card #28657 (its body and all 3 comments: triage, claim, os-dev-report 6559402196), PR #28708 (body, 5 files, net diff against merge-base ① Derived judgmentsThe accept-set change: on the REST
② Semver level
③ Boundary flags
No must-fix findings. Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #28657. Five files (+540 / −5): runtime/src/action-execution.ts, runtime/src/domains/actions.ts, two test files, one @objectstack/runtime patch changeset. Labels documentation, size/l, tests, tooling. Metadata-protocol: none. No packages/spec/**, no ui/**, and pending-approver-read-grant.ts untouched (order ruling 4). |
| Diff check (seat) | One shared refuseDeniedSubjectLoadBeforeDispatch, called at the same point by the REST /actions door and the MCP run_action bridge (ruling 5). A denied load forwards only a same-origin type:'api' action that is not a declarative update and whose request needs no row field (apiActionRequestReadsSubjectRow: a {field} path token other than {id}, or a recordIdField other than id, keeps the 404). On a denied load the request is built from { id: recordId } alone (apiForwardSubjectRecord), at both doors (ruling 2). The declarative, flow and script/body branches keep refuseDeniedSubjectLoad unchanged (ruling 3). The { code, error } string sentence relay is the same code path, so it rides along as ruling 6 allows. |
| Evidence | CI green on head f3768a46 (filter=latest: 35 success, 13 skipped, 0 failed; Test Core, TypeScript Type Check, Lint & Repo Gates and Build Core all success). The real-stack HTTP pin in packages/verify is red on base (404 RECORD_NOT_FOUND) and green on head. The ablation goes red and restores clean. @objectstack/runtime passes 504 files / 8196 tests, and 61 of 61 derived gates exit 0. The live showcase probe of the card's step 7 answers 404 on base and 200 on head, with decision progress 0/2 → 1/2. |
| Open question | approval_* actions are not ai.exposed, so run_action parity is pinned on an exposed object of the same shape. Answered A (leave them unexposed), as the contract review also answers. No card. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with the contract review PASS on this head and every check green or an expected skip. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #28657
Clause-②: yes (widening: on
POST /api/v1/actions/:object/:actionand MCPrun_action, a same-origintype: 'api'action whose subject row the caller's own scope does not deliver, and whose request needs no field of that row, is forwarded and answers its target's answer where it answered404 RECORD_NOT_FOUND)Metadata-protocol: none
维护者速读
/api/v1/actions/...与 MCPrun_action)在调用者读不到主记录时,不再先回 404;对不需要该记录字段的同源type:'api'动作照常转发,由目标接口自己鉴权并给出答复。sys_approval_request的数据读权限,所以非管理员审批人在详情页做任何决定都被 404 挡住,18.3.0 发不了。refuseDeniedSubjectLoadBeforeDispatch两处调用。packages/runtime/src/action-execution.ts(新的共享判定refuseDeniedSubjectLoadBeforeDispatch/apiActionRequestReadsSubjectRow/apiForwardSubjectRecord);packages/runtime/src/domains/actions.ts(REST 门那一行);packages/verify/src/action-door-api-forward-denied-subject.test.ts(真实审批栈上的 HTTP 钉子)。pnpm --filter @objectstack/verify exec vitest run src/action-door-api-forward-denied-subject.test.ts,预期 3/3 通过(main 上 2 个失败,都是 404RECORD_NOT_FOUND)。What changes
packages/runtime/src/action-execution.tsrefuseDeniedSubjectLoadBeforeDispatch(action, objectName, recordId, subject): contract point 3 as both doors' dispatch reads it. It returns without refusing exactly when the load was denied andforwardsOnDeniedSubjectLoad(action)holds; otherwise it isrefuseDeniedSubjectLoad, unchanged.forwardsOnDeniedSubjectLoad(action): not a declarative update (operationbeforetype),type: 'api', a same-origintarget, andapiActionRequestReadsSubjectRow(action)false.apiActionRequestReadsSubjectRow(action): true for a{field}path token intargetother than{id}, or arecordIdParamread from arecordIdFieldother thanid. Read off the declaration before substitution, so a token that only looks like a field errs toward the 404.apiForwardSubjectRecord(recordId, subject): the row the forward's request is built from. A delivered row as it was; on a denied load{ id: recordId }and nothing else.dispatchAdmittedBusinessAction(MCPrun_action) calls the two at the same point the REST door does.apiActionForwardRefusalreads a target's bare stringerror(the approvals routes'{ code, error }) as the refusal's message, before the "its target answered HTTP N" fallback. This is the card's 400 prescription relay: it was this code path, so it rides along (ruling 6).packages/runtime/src/domains/actions.ts:dispatchAdmittedRestActioncallsrefuseDeniedSubjectLoadBeforeDispatchwhere it calledrefuseDeniedSubjectLoad, and builds the api request fromapiForwardSubjectRecord..changeset/28657-api-action-forward-denied-subject.md:@objectstack/runtimepatch (files[]shipsdist, which carries the change).How ruling 2 is enforced: the forward never sees the load's leftovers.
buildApiActionRequestis handedapiForwardSubjectRecord(...), which on a denied load is built from the routedrecordIdalone. A declaration that needs a row field is caught one step earlier byapiActionRequestReadsSubjectRow, so it is refused with today's 404 before the loop check, the injector or the builder run. Pinned by the helper case (a denied load whose leftover row carriesemailyields{ id }only) and by the door cases for{email}in the path andrecordIdField: 'email'(404, no forward, on both doors).Untouched, as ruled:
pending-approver-read-grant.ts, the member grant onsys_approval_request,packages/spec/**,ui/**, the flow, script/body and declarative-update branches, andresume_run.Tests
The HTTP-layer pin on a real stack: red on base e4ec4f2, green on head
packages/verify/src/action-door-api-forward-denied-subject.test.tsboots the real stack withbootStack(requires: ['automation', 'triggers', 'approvals'], the providersservemounts for those tokens): the runtime dispatcher, the real approvals service and its decision routes, the real in-process forward. No fetch is doubled. A private object, a two-approverunanimousflow; the admin creates the record, and plain members act.PERMISSION_DENIED).POST /api/v1/actions/sys_approval_request/approval_approve{"recordId":AREQ,"params":{"comment":…}}answers 200;pending_approversgoes from both approvers to the other one; oneapproverow with the approver asactor_idand the comment;GET /api/v1/approvals/requests/AREQreadsdecision_progress{ behavior: 'unanimous', got: 1, need: 2 }.FORBIDDEN), the route's own sentence as the message, and nothing moves.Base (
packages/runtime/src/action-execution.tsanddomains/actions.tswritten frome4ec4f22c5, runtime rebuilt,ablation-dist-preflight --absenton the new helper's name):Head:
Test Files 1 passed (1) · Tests 3 passed (3) · VERDICT command-exit 0. Restore proven by blob hashes equal to HEAD,git status --porcelainempty, rebuild, and the preflight finding the marker indist/index.jsanddist/index.cjs.Ablation: only the api-branch move reverted, the HTTP pin goes red
node scripts/ablation-replace.mjs --file packages/runtime/src/domains/actions.ts --anchor "actionExec.refuseDeniedSubjectLoadBeforeDispatch(actionDef, objectName, recordId, subject);" --replacement "actionExec.refuseDeniedSubjectLoad(objectName, recordId ?? 'ablation-28657', subject);" -- bash ablate-inner.shRuntime pins (doors over fakes): ruling 2, parity, the 400 relay
New
describe('[#28657] …')inpackages/runtime/src/domains/api-action-forward.test.ts, 10 cases:/api/v1/approvals/requests/areq_hidden/approvewith the caller's bearer, and the target's 200 is the answer.{ code: 'FORBIDDEN', error }is the answer, its sentence as the message.recordIdParamread fromidis seeded from the routed id alone.{email}in the path andrecordIdField: 'email'on a denied load answer 404RECORD_NOT_FOUNDand nothing is forwarded; the firing control on a readable row forwards with the row's field.{ code: 'VALIDATION_FAILED', error }is relayed with its sentence, not "its target answered HTTP 400".run_action: the same approve shape on a denied load is forwarded and answersok: true; the two row-field shapes are thrown 404RECORD_NOT_FOUNDwith nothing forwarded; the{ code, error }sentence rides the throw.On base (
-t 28657): 7 failed, 3 passed. The 3 that pass on base are the ruling-2 404 cases and their firing control, which pin behaviour that must not change. On head:Tests 68 passed (68).Ruling 3 (every non-api branch keeps the 404) stays pinned by the existing cases, all green on head:
action-door-record-load-denied.test.ts"[#16370] flow door — a denied caller-scope load is refused before the run exists" (REST and MCP), "[#16370] script/body door — the same verdict, the same refusal" (REST and MCP), andhttp-dispatcher.actions-type-dispatch.test.ts"[#16370] a ROW-SCOPED flow whose record never loaded is REFUSED — no run is created". The declarative update is excluded byforwardsOnDeniedSubjectLoad(pinned in the helper case) and runs ahead of the call in both doors.MCP parity with
approval_approveitself is not reachable: thesys_approval_requestactions declare noai.exposed, sorun_actionrefuses them at the system-object exposure gate before the subject load, on base and on head alike. Parity is pinned on the same declaration shape on an exposed object.Live probe: the card's reproduction on a stock showcase, step 7
Boot (no
--ui: the probe is HTTP only):OS_PORT=PORT pnpm -C examples/app-showcase exec objectstack dev --seed-admin --no-compile -p PORT -d file:DIR/data.db, afterpnpm exec turbo run build --filter='!@objectstack/docs'(117 tasks). Then, with curl and anOriginheader: adminPOST /api/v1/auth/sign-in/email;POST /api/v1/auth/organization/invite-member{"email":X,"role":"member"}; XPOST /api/v1/auth/sign-up/email; adminPOST /api/v1/data/sys_user_positionforcontributorandfinance; XPOST /api/v1/data/showcase_invoice{"name":…,"account":ACTIVE_ACCOUNT_ID,"owner":X_EMAIL}andPATCH …/ID{"status":"sent","issued_on":"2026-11-01"}; then step 7.Base (runtime rebuilt from
e4ec4f22c5, fresh database):Head (fresh database):
X leaves
pending_approvers, anapproverow exists, and X'scan_actis false, which is the card's step 6.Packages, gates, lint
pnpm --filter @objectstack/runtime test:Test Files 504 passed (504) · Tests 8196 passed | 19 skipped (8215).pnpm --filter @objectstack/runtime typecheckandpnpm --filter @objectstack/verify typecheck(each includescheck:test-typecheck, so the new test files are type-checked): exit 0.eslint --no-inline-config packages/runtime packages/verify: exit 0.node scripts/pm/dispatch-gates.mjs --commandson the final headf3768a46: 61 commands, every one exit 0 (--ranreconciliation: "61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED"). The list with each exit code is in the card's report.Acceptance notes
ai.exposed, so an agent cannot run them throughrun_actionbefore or after this change. Exposing them is a plugin-approvals decision outside this card.{ code, error }rather than the ADR-0112 envelope. This PR only reads that sentence at the forward; the routes' own shape is unchanged.Verification
@objectstack/verifyis downstream of runtime):pnpm --filter @objectstack/verify exec vitest run src/action-door-api-forward-denied-subject.test.ts.Generated by Claude Code