Skip to content

approvals: a non-admin approver's decision from the request page answers 404 RECORD_NOT_FOUND — the action door loads sys_approval_request in the caller's data scope #28657

Closed

Description

@objectstack-fleet

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

  1. 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.
  2. As admin, mint a member X: POST /api/v1/auth/organization/invite-member {"email":"X","role":"member"}, then X POST /api/v1/auth/sign-up/email.
  3. 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".
  4. As X: POST /api/v1/data/showcase_invoice {"name":"INV-1","account":ACTIVE_ACCOUNT_ID,"owner":"X"}, then PATCH /api/v1/data/showcase_invoice/ID {"status":"sent","issued_on":"2026-11-01"}. This opens request AREQ (Invoice Dual Sign-off) with X in pending_approvers.
  5. Sign X in through the console form. Open /_console/apps/com.objectstack.account/component/approvals/inbox, click the row, then click Approve → Confirm.
  6. Expected: decision_progress goes 0/2 → 1/2, X leaves pending_approvers, an approve row appears in GET /api/v1/approvals/requests/AREQ/actions, and X's decision buttons are gone.
  7. Actual: POST /api/v1/actions/sys_approval_request/approval_approve {"recordId":"AREQ","params":{"comment":"…"}} → 404 RECORD_NOT_FOUND. GET /api/v1/approvals/requests/AREQ still 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)

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

  1. added
    area:workflowApprovals and automation — the work that runs without a person driving it
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    priority:p1High: required for production / M2
    on Nov 1, 2026
  2. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    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.


    Generated by Claude Code

  3. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    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 is session_01FEkQ5mze6z6hhTUauqWMmZ
    Account: 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:server
    Seat: domain:server#1
    Ruling-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.ts and packages/runtime/src/action-execution.ts (one shared helper for the run_action parity), the forward-refusal relay only if the 400 prescription is the same path, their tests, and a runtime changeset. ⛔ No packages/spec/**, ⛔ no ui/**, ⛔ no widening of the member grant on sys_approval_request (pending-approver-read-grant.ts untouched).
    Container & model: S, mode:thread, model and effort as the thread was opened
    Clause-②: 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_action bridge of @objectstack/runtime · the platform path is a declared same-origin type:'api' action whose target authorizes the caller itself (ADR-0066 D4) · reached by every approval decision a non-admin sends from the request page
    Thread-read: none
    Premise: holds (read 2026-11-01T13:03Z at origin/main e4ec4f22c5: refuseDeniedSubjectLoad runs ahead of the actionType === 'api' forward in domains/actions.ts, so a member with no data-door read on sys_approval_request gets 404 RECORD_NOT_FOUND before the approvals door can authorize them)
    Serial constraints cleared: no open PR touches packages/runtime/src/domains/actions.ts, domains/mcp.ts or action-execution.ts (read 2026-11-01T13:03Z).
    Resume-from: none — fresh from origin/main e4ec4f22c5

    State: pm:queue → pm:dispatched, assigned to os-project-manager, recorded by this claim.


    Generated by Claude Code

  4. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  5. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

    Landing record · #28657 · PR #28708

    PR #28708 merged by the merge queue as 0c6b7b8ee4 on main (Fixes #28657; contract review PASS 6559614320; ACCEPT 6559626306). The card closed on the merge.

    Reading Probe Result
    Queue membership git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-28708-*' 0 refs
    origin/main by content git diff --quiet 0c6b7b8ee4 origin/main -- the PR's 5 files all 5 identical
    Ancestry git merge-base --is-ancestor 0c6b7b8ee4 origin/main holds
    • Landed: at the REST /actions door and the MCP run_action bridge, a same-origin type:'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 the recordId alone, 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/runtime patch.
    • Release: this was the remaining P0 FAIL of the W3-RT retest QA run · release-18.3.0-w3-retest (13/13) · 6e11a929 · 2026-11-01 · 11 PASS / 0 PARTIAL / 2 FAIL / 0 BLOCKED / 0 NOT-RUN #28656 (approvals.account-app-entry acceptance[3]). Its pin is the real-stack HTTP test in packages/verify.

    Release: branch claude/project-thread-8eqzai merged; nothing unpushed. pm:dispatched removed and the card unassigned.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p1High: required for production / M2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions