Repository navigation
Description
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
Filed by the PM seat for the W3-RT runner session_017xRBcRX4wwwVw5bEckffdp, whose own write was refused by its session permissions (RUNNER rule 2: staged text, posted verbatim through the same door).
QA-source: #28656 · approvals.account-app-entry · acceptance[3]
What happens
On the request page (/_console/apps/com.objectstack.account/sys_approval_request/record/ID), a non-admin pending approver sees the decision panel (the #28546 fix holds: no access-denied screen). But executing any decision from that page fails. Approve → Confirm sends POST /api/v1/actions/sys_approval_request/approval_approve and gets 404 {"code":"RECORD_NOT_FOUND","message":"Record areq_… not found in sys_approval_request"}. The dialog shows that text, and the request is unchanged.
Scope: the request page's actions are all type:'api' through the same door. As the non-admin pending approver, approval_reject, approval_comment and approval_request_info each answer the same 404, so the whole decision panel is unusable. The admin, sending the same body shape to the same door, gets 200 success:true and the request moves. The submitter's approval_remind and approval_comment also answer 404, so Recall, Send reminder and Reply on the page do not work for non-admins either. The inbox's inline approve says "To add a comment or attachment, open the request instead", so a commented decision is impossible for a non-admin.
Measured at framework 6e11a929 (main, 2026-11-01), with the Console built in-repo, on a stock showcase boot. Reproduced on two independent boots, with two different personas and flows, and by a second independent verifier (RUNNER rule 7).
Reproduction
- Boot the stock showcase:
OS_PORT=PORT pnpm -C examples/app-showcase exec objectstack dev --ui --seed-admin --no-compile -p PORT -d file:/tmp/x/data.db. - As admin, mint a member X:
POST /api/v1/auth/organization/invite-member{"email":"X","role":"member"}, then XPOST /api/v1/auth/sign-up/email. - As admin, give X two positions:
POST /api/v1/data/sys_user_position{"user_id":X_ID,"position":"contributor","organization_id":ORG}, and the same with"finance". - As X:
POST /api/v1/data/showcase_invoice{"name":"INV-1","account":ACTIVE_ACCOUNT_ID,"owner":"X"}, thenPATCH /api/v1/data/showcase_invoice/ID{"status":"sent","issued_on":"2026-11-01"}. This opens request AREQ (Invoice Dual Sign-off) with X inpending_approvers. - Sign X in through the console form. Open
/_console/apps/com.objectstack.account/component/approvals/inbox, click the row, then click Approve → Confirm. - Expected:
decision_progressgoes 0/2 → 1/2, X leavespending_approvers, anapproverow appears inGET /api/v1/approvals/requests/AREQ/actions, and X's decision buttons are gone. - Actual:
POST /api/v1/actions/sys_approval_request/approval_approve{"recordId":"AREQ","params":{"comment":"…"}}→ 404 RECORD_NOT_FOUND.GET /api/v1/approvals/requests/AREQstill reads pending, 0/2,viewer.can_act: true.
HTTP-only form: X POST /api/v1/actions/sys_approval_request/approval_approve {"recordId":"AREQ","params":{}} → 404. The contrasts that isolate the door:
- X
POST /api/v1/approvals/requests/AREQ/approve→ 200; - the inbox inline approve (key
a) → 200; - admin through the action door → 200.
Second boot, second flow: the seeded non-admin persona Ada Auditor on a showcase_dynamic_approval stage-2 request page → Approve → Confirm → the same 404.
Mechanism (read at 6e11a92)
ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx(around L428–480) sends a declared same-origintype:'api'action throughsendApiActionThroughDoor(Console: send a same-origintype: 'api'action click throughPOST /actions/:object/:actioninstead of fetching itstargetdirectly (ruling on #27506, step 3 of 3) #27611) toPOST /api/v1/actions/:object/:action.packages/runtime/src/domains/actions.tsloads the subject row in the caller's data scope (loadActionSubjectRecord→callData 'get', around L749).refuseDeniedSubjectLoad(around L882) answers 404 before the api forward (L897+) to/api/v1/approvals/requests/{id}/approve, which authorizes the pending approver itself.- Members have no data-door read on
sys_approval_request, by design: Approvals: a non-admin approver who opens an approval request from the Approvals inbox sees "You don't have access to Approval Request records" instead of the decision panel (regression from #27713) #28546's fix deliberately did not widen it.pending-approver-read-grant.tsgrants the subject record (the invoice), not the request row. - This is the same class as Non-admin approvers get a blank approval request page since the console pin moved past objectui#12484 (18.1.0 release blocker) #25228 and Approvals: a non-admin approver who opens an approval request from the Approvals inbox sees "You don't have access to Approval Request records" instead of the decision panel (regression from #27713) #28546: an approvals row read through a generic data-scoped door, reached here through a third door. PR fix(app-shell,console): an approver who opens an approval request sees its decision panel again, read from the approvals service #28619's jsdom test "one approval POSTs the action door" doubled the fetch answer, so it never observed this HTTP-layer refusal.
Fix direction (a suggestion, not a ruling): route the subject load or the forward for approval actions so the approvals service authorizes the caller. Do not widen the member data grant on sys_approval_request.
Related, same door: the action door relays a target's 400 as "Action 'approval_approve' — its target answered HTTP 400", dropping the target's prescriptive message (seen on a dynamic-approval decision with a missing required output).
Pin owed
No HTTP-layer or live pin covers "a non-admin approver executes a decision from the request page and the request moves". The checklist item approvals.account-app-entry acceptance[3] is the only coverage.
Does not reopen #28546, whose scope (the page rendering) holds.
Activity
objectstack-fleet commentedon Nov 1, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 审批与自动化 | none | P②
Triage: p1 ·
bug·domain:server·area:workflow·pm:queue· 18.3.0 release blocker · 2026-11-01T12:56Z⛔ Not a claim.
6e11a929. Approvals: a non-admin approver who opens an approval request from the Approvals inbox sees "You don't have access to Approval Request records" instead of the decision panel (regression from #27713) #28546's fix holds for its scope: the page renders the decision panel. But every decision a non-admin sends from that page answers 404RECORD_NOT_FOUND, becausepackages/runtime/src/domains/actions.tsloads the subjectsys_approval_requestrow in the caller's data scope before forwarding to the approvals door. Approve, reject, comment, request-info, remind and recall are all affected.approvals.account-app-entryacceptance[3] does not pass, so 18.3.0 does not ship.domain:server(the action door). It is the same class as Non-admin approvers get a blank approval request page since the console pin moved past objectui#12484 (18.1.0 release blocker) #25228 and Approvals: a non-admin approver who opens an approval request from the Approvals inbox sees "You don't have access to Approval Request records" instead of the decision panel (regression from #27713) #28546, reached through a third door. ⛔ The member data grant onsys_approval_requestis not widened. The fix lets the approvals service authorize the caller for approval actions, as the card suggests. The relay of the target's 400 prescription rides along if it is the same code path.Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC
Claim: PM loop round 1 · card thread · 2026-11-01T13:03Z
Session:
session_01R1q2xdtYgU6FKrZ7LgjPCC(the seat); the card thread issession_01FEkQ5mze6z6hhTUauqWMmZAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-8eqzai(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#1Ruling-ref: 「28657 插队」 (Leehom, 2026-11-01T13:01Z); triage record (p1,
pm:queue, 18.3.0 release blocker, triage#1 2026-11-01T12:56Z). Server seats 2 and 3 are full; no other seat holds this card.File surface:
packages/runtime/src/domains/actions.ts(the api-forward branch and the denied subject-load verdict ahead of it),packages/runtime/src/domains/mcp.tsandpackages/runtime/src/action-execution.ts(one shared helper for therun_actionparity), the forward-refusal relay only if the 400 prescription is the same path, their tests, and a runtime changeset. ⛔ Nopackages/spec/**, ⛔ noui/**, ⛔ no widening of the member grant onsys_approval_request(pending-approver-read-grant.tsuntouched).Container & model:
S,mode:thread, model and effort as the thread was openedClause-②: yes (expected: a same-origin api action on a caller-denied subject row answers its target's answer instead of the door's own 404); contract review owed on the final head before landing
Metadata-protocol: none
Responsibility: the code is the REST action door and the MCP
run_actionbridge of@objectstack/runtime· the platform path is a declared same-origintype:'api'action whose target authorizes the caller itself (ADR-0066 D4) · reached by every approval decision a non-admin sends from the request pageThread-read: none
Premise: holds (read 2026-11-01T13:03Z at
origin/maine4ec4f22c5:refuseDeniedSubjectLoadruns ahead of theactionType === 'api'forward indomains/actions.ts, so a member with no data-door read onsys_approval_requestgets 404RECORD_NOT_FOUNDbefore the approvals door can authorize them)Serial constraints cleared: no open PR touches
packages/runtime/src/domains/actions.ts,domains/mcp.tsoraction-execution.ts(read 2026-11-01T13:03Z).Resume-from: none — fresh from
origin/maine4ec4f22c5State:
pm:queue→pm:dispatched, assigned toos-project-manager, recorded by this claim.Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#1 · os-project-manager · session_01FEkQ5mze6z6hhTUauqWMmZ
os-dev-report
{
"issue": 28657,
"status": "done",
"branch": "claude/project-thread-8eqzai",
"pr": "#28708",
"session": "session_01FEkQ5mze6z6hhTUauqWMmZ",
"premise_still_valid": true,
"summary": "The REST /actions door and the MCP run_action bridge no longer answer their own 404 RECORD_NOT_FOUND ahead of a same-origin type:'api' forward when the caller-scope subject load is denied: both call one shared function, refuseDeniedSubjectLoadBeforeDispatch (action-execution.ts), which forwards exactly when the action is api, same-origin, not a declarative update and its request needs no row field (apiActionRequestReadsSubjectRow), and otherwise is refuseDeniedSubjectLoad unchanged. Ruling 2 is enforced twice: a declaration reading a row field ({field} other than {id}, or recordIdField other than id) keeps today's 404 before any forward step, and the request is built from apiForwardSubjectRecord, which on a denied load is { id: recordId } and nothing else. Ruling 6 was this path: apiActionForwardRefusal now reads a target's bare string error (the approvals routes' { code, error }), so the 400 prescription is relayed instead of 'its target answered HTTP 400'. No spec, ui, grant or pending-approver-read-grant change; card assignee untouched; PR opened with no assignees and no labels (the order names none and the diff carries a changeset).",
"tests": "HTTP-layer pin on a real stack (packages/verify/src/action-door-api-forward-denied-subject.test.ts: bootStack with requires automation/triggers/approvals, real dispatcher, real approvals routes, real in-process forward, no doubled fetch): control (approver's data-door read of the request answers 403 PERMISSION_DENIED); non-admin pending approver POST /actions/sys_approval_request/approval_approve answers 200 and the request moves (pending_approvers both -> the other one, one approve row with actor_id and comment, decision_progress unanimous got 1 need 2); a member on no slate gets the decision route's own 403 FORBIDDEN and sentence through the door, nothing moves. Red on base e4ec4f2 (both runtime files at base, runtime rebuilt, dist preflight --absent): 2 failed | 1 passed, each 404 RECORD_NOT_FOUND 'Record areq_... not found in sys_approval_request'; head: 3 passed. Runtime pins (api-action-forward.test.ts, 10 new cases: REST and MCP forward on a denied load, target refusal relayed, recordIdParam from id seeded from the routed id, ruling-2 404 with no forward for {email} path and recordIdField email on both doors plus firing control, 400 { code, error } sentence relayed on both doors, helper table): base -t 28657 7 failed | 3 passed (the 3 are the must-not-change 404s and their control); head 68/68. Ruling 3 pins are existing and green: action-door-record-load-denied.test.ts '[#16370] flow door ...' and '[#16370] script/body door ...' (REST + MCP), http-dispatcher.actions-type-dispatch.test.ts '[#16370] a ROW-SCOPED flow whose record never loaded is REFUSED'. Ablation (ablation-replace on domains/actions.ts, the api-branch call back to refuseDeniedSubjectLoad with marker 'ablation-28657', runtime rebuilt, preflight marker present in dist/index.js+index.cjs): HTTP pin 2 failed | 1 passed; restored blob == HEAD a762e11688a6, git status --porcelain empty, rebuild, preflight --absent ok, pin 3/3. pnpm --filter @objectstack/runtime test: 504 files, 8196 passed | 19 skipped. runtime + verify typecheck (incl. check:test-typecheck): exit 0. Live probe (card reproduction, stock showcase, OS_PORT=PORT pnpm -C examples/app-showcase exec objectstack dev --seed-admin --no-compile -p PORT -d file:DIR/data.db after a full turbo build, no --ui; steps 2-4 by curl with Origin, owner = X's email): base fresh boot step 7 = 404 RECORD_NOT_FOUND, progress 0/2 unchanged, actions [submit]; head fresh boot step 7 = 200, progress 0/2 -> 1/2, X off pending_approvers, can_act false, actions [submit, approve by X]. Commands and outputs in the PR body.",
"gates": "Local derived set on final head f3768a4: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack = 61 commands, each run with its exit captured before any pipe, all exit 0; --ran reconciliation: '61 derived famil(ies) accounted for, 61 run, 0 NOT-MEASURED'. On the earlier head 8ac2c7e two had read red and were fixed before the final run: check-plugin-teardown-shape --self-test exit 3 (shallow clone; fetched 621a487 at depth 1) and check:test-source-alias exit 1 (the new verify test imported plugin-approvals and trigger-record-change from dist; now the app's requires mounts them, as serve does). eslint --no-inline-config packages/runtime packages/verify: exit 0. Full list with exit codes: /mnt/project-files/pm-orders/server/28657-gates-ran.txt. CI check-runs on PR 28708: in_progress at report time.",
"dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm (Clause-②: yes (widening))",
"mcp_calls": "0: no GitHub MCP call; reads went through gh api REST",
"api_writes": "2: relay pr_create (fleet-write run 41317008630, PR 28708) and this os-dev-report comment (post-stamped). git push x4 to the harness branch is not a REST write.",
"open_questions": [
{
"question": "The sys_approval_request actions declare no ai.exposed, so MCP run_action refuses approval_approve at the system-object exposure gate before the subject load, on base and head alike. The order's 'same approver case through run_action succeeds' is therefore pinned on the same declaration shape on an exposed object, not on approval_approve itself. Should the approval actions be exposed to agents?",
"options": [
"A leave them unexposed (no change; agents decide through the approvals tools or not at all)",
"B expose them in plugin-approvals with ai.exposed and descriptions, a separate card"
],
"recommendation": "A, because exposing decision actions to agents is a product decision outside this card and nothing in 18.3.0 needs it"
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed: the approvals routes answer refusals as { code, error: string } rather than the ADR-0112 envelope; this PR only reads that sentence at the forward"
]
}
Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC
Landing record · #28657 · PR #28708
PR #28708 merged by the merge queue as
0c6b7b8ee4onmain(Fixes #28657; contract review PASS6559614320; ACCEPT6559626306). The card closed on the merge.git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-28708-*'origin/mainby contentgit diff --quiet 0c6b7b8ee4 origin/main --the PR's 5 filesgit merge-base --is-ancestor 0c6b7b8ee4 origin/main/actionsdoor and the MCPrun_actionbridge, a same-origintype:'api'action on a subject row the caller cannot read now forwards to its target, which authorizes the caller itself. The request is built from therecordIdalone, and a declaration that needs a row field keeps the 404. A non-admin pending approver's decision from the request page now moves the request: live showcase step 7 went from 404 to 200, with progress 0/2 → 1/2. The target's{ code, error }sentence is relayed.@objectstack/runtimepatch.approvals.account-app-entryacceptance[3]). Its pin is the real-stack HTTP test inpackages/verify.Release: branch
claude/project-thread-8eqzaimerged; nothing unpushed.pm:dispatchedremoved and the card unassigned.Generated by Claude Code