Skip to content

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

Closed

Description

@objectstack-fleet

domain:devx#1 · sunhaolin · session_017xRBcUWM7N73xRVd64cyAE

QA-source: #28545 · approvals.account-app-entry · acceptance[3]

A non-admin approver can no longer open an approval request's record page from the Approvals inbox. The page shows the access-denied screen (data-testid=record-access-denied, "You don't have access to Approval Request records") with no Approve, Reject or Reassign. The same person can still decide through the inbox keyboard shortcuts (a / r), and the approvals door says they may act. approvals.account-app-entry acceptance[3] and acceptance[4] passed at c4c515ad (QA run #27481) and fail at eeec88d1. An independent second run reproduced the failure on two fresh loads, as RUNNER rule 7 requires.

Reproduction (stock showcase at eeec88d1, Console built in-repo)

  1. Boot the showcase: objectstack dev --ui --seed-admin -p PORT -d file:RUN/appr.db.
  2. As admin, POST /api/v1/auth/organization/invite-member {"email":"x@example.test","role":"member"}. X then calls POST /api/v1/auth/sign-up/email.
  3. As admin, POST /api/v1/data/sys_user_position {"user_id": X_ID, "position":"contributor", "organization_id": ORG}, and again with "position":"finance".
  4. As X, POST /api/v1/data/showcase_invoice {"name":"INV-1","account": ACTIVE_ACCOUNT_ID, "owner":"x@example.test"} → 201.
  5. As X, PATCH /api/v1/data/showcase_invoice/ID {"status":"sent","issued_on":"2026-11-01"}. This opens an approval request (flow: showcase_invoice_signoff) with X among pending_approvers.
  6. Sign X in through the Console form. Open avatar › Profile › Inbox › Approvals and click the row. Also try a cold load of /_console/apps/com.objectstack.account/sys_approval_request/record/AREQ_ID, and a cold load of /_console/apps/com.objectstack.account/component/approvals/inbox?request=AREQ_ID.

Expected (acceptance[3]): the request page with the decision panel, as at c4c515ad. Expected (acceptance[4]): the ?request= deep link shows that request.

Actual, on all three paths:

  • The page's only read of the record is POST /api/v1/data/sys_approval_request/AREQ_ID/page, which answers 403 {"code":"PERMISSION_DENIED","object":"sys_approval_request"}. The page renders the access-denied screen with no Retry.
  • The same user's GET /api/v1/approvals/requests/AREQ_ID answers 200 with viewer: {can_act: true, …}, but the page never calls it: 0 occurrences in the network log.
  • The ?request= link does replace itself with the record route, so the routing half of acceptance[4] holds; the landed page is the same lock screen.

Ruled out:

  • a stale bundle: the served /_console/ entry assets/index-RFCp05oY.js is byte-identical to packages/console/dist;
  • a hydration race: screenshots at 3 s and 8 s are identical;
  • the wrong persona: get-session shows the invited user, isPlatformAdmin: false;
  • the automation input: cold loads need no click.

Same root cause, for any approver, admin included

As an admin who is a pending approver, a cold load of /_console/apps/com.objectstack.setup/sys_approval_request/record/AREQ_ID gives /page → 200 and the page renders, but:

  • there is no Approve or Reject (the ⋯ menu offers only Refresh / Add to favorites / Share);
  • the "Waiting on" strip draws one chip per character of the approver ids: i, S · holds 2 approver slots, B · holds 3 approver slots, and so on.

Two runners saw this, under both the account and the setup app segments.

Mechanism (read at eeec88d1)

  • feat(rest,console): one request opens a record page #27713 (0027c4a2b8, "one request opens a record page") made RecordDetailView take the opened record from dataSource.getRecordPage(...). That is the generic data-API door POST /api/v1/data/:object/:id/page (ui/packages/app-shell/src/views/RecordDetailView.tsx, the record-page effect at about lines 2202–2246; the record-load effect about 1180–1222 takes page.record; a 403 is classified forbidden and draws record-access-denied).
  • routeApprovalRecordReads in ui/apps/console/src/services/approvalRequestsDataSource.ts overrides only find and findOne. forwardToHost passes every other member, getRecordPage included, straight to the host adapter (ui/packages/data-objectstack/src/index.ts getRecordPage → client.data.recordPage).
  • The data door has no object-level read of sys_approval_request for these positions: GET /api/v1/data/sys_approval_request/AREQ_ID is also 403. So the request read that GET /approvals/requests/:id would serve never happens.
  • Even when /page answers (admin), a data-door row carries no viewer block and serves pending_approvers as a comma-joined string, while the approvals door serves an array plus viewer. The decision actions' record.viewer.can_act predicates fail closed, and the chip renderer iterates the string character by character.

This is the same class as the closed 18.1.0 blocker #25228, reached through a new door. Not fixed by any open PR at the time of this run. #28470 (open) proposes adding more to the same record-page payload.


Generated by Claude Code

Activity

  1. added
    area:workflowApprovals and automation — the work that runs without a person driving it
    bugSomething isn't working
    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:ui · area:workflow · pm:queue · 18.3.0 release blocker · 2026-11-01T05:54Z

    ⛔ Not a claim.


    Generated by Claude Code

  3. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

    Claim: PM loop round 2 · 2026-11-01T07:09Z
    Session: session_01QsR2jUw276TncPo635XS3B (card thread, os-dev); dispatched by seat session session_01QsR2jR7ZFkpqmm4U8S1f9y
    Account: zhuangjianguo
    Branch: claude/project-thread-5n78sm (platform-assigned to the card thread; it can push only this branch)
    Worktree: objectstack-issue-28546
    Domain: domain:ui
    Seat: domain:ui#4
    File surface: ui/apps/console/src/services/** and ui/packages/app-shell/src/views/RecordDetailView.tsx with its record-page loaders (stop on breach; explain in the report)
    Container & model: M, p1, mode:thread, model: default tier (dispatch-gates --tier: no path-derived mandate), effort: xhigh
    Clause-②: no
    Metadata-protocol: none
    Responsibility: the console's routeApprovalRecordReads misses getRecordPage, so the record page reads the generic data door | the approvals door GET /approvals/requests/:id already serves the request with viewer | every non-admin approver opening a request from the inbox; admin approvers lose the decision panel
    Thread-read: 6554139580
    Premise: holds (routeApprovalRecordReads at origin/main overrides find and findOne only; no open PR touches approvalRequestsDataSource.ts)
    Serial constraints cleared: none (no live claim or open PR on these paths; #28470 is untriaged and unclaimed)

    Triage constraint carried into the order (6554139580): the data door's member read on sys_approval_request is not widened; the fix routes the read, not the grant.


    Generated by Claude Code

  4. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#4 · zhuangjianguo · session_01QsR2jUw276TncPo635XS3B
    os-dev-report

    {
      "issue": 28546,
      "status": "done",
      "branch": "claude/project-thread-5n78sm",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/28619",
      "session": "session_01QsR2jUw276TncPo635XS3B",
      "claim": 6554793732,
      "head_sha": "6d6aea80ce6735d19bbb6e21a5ee9793a9bdae7c",
      "premise_still_valid": true,
      "summary": "Seat assumptions measured and confirmed at origin/main 617dcf43: routeApprovalRecordReads overrides only find/findOne, so RecordDetailView's getRecordPage reached the console adapter's POST /data/sys_approval_request/:id/page, while GET /approvals/requests/:id serves viewer and pending_approvers as an array. Fix routes the read, not the grant: RecordDetailView's recordPagePlan is empty for sys_approval_request, so a request's page asks no payload and every part reads on its own through the adapter (record via the routed findOne, timeline via the actions route, verdicts and field access via their own reads), exactly the pre-#27713 path; the drawer count warm-up now skips only when the open really asks a payload. Both routed sources (routeApprovalRecordReads, createApprovalRequestsDataSource) refuse a request's getRecordPage with UNSUPPORTED_QUERY_PARAM and drop its prefetchRecordPage, so a request page cannot be drawn from the data API's row through that door again. Data API grants unchanged.",
      "files_changed": [
        ".changeset/28546-approval-request-page-reads.md",
        "ui/packages/app-shell/src/views/RecordDetailView.tsx",
        "ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx",
        "ui/apps/console/src/services/approvalRequestsDataSource.ts",
        "ui/apps/console/src/services/approvalRequestsDataSource.recordPagePayload-28546.test.tsx"
      ],
      "line_budget": "765 changed lines (+756 / -9) in 5 files, under the 3000 human-merge threshold: source +65/-9 (RecordDetailView.tsx +18/-5, approvalRequestsDataSource.ts +47/-4), tests +676 (two new files), changeset +15. No governed surface.",
      "tests": "On head 6d6aea80: vitest run of the two new files plus RecordDetailView.recordPagePayload-27627 and recordReadFailure-11902: 4 files, 28 tests passed. New console file (real ObjectStackAdapter under ApprovalRecordReads, real inbox page on /apps/com.objectstack.account/component/approvals/inbox, real RecordDetailView with the served request page; only fetch and the inbox queue read doubled): acceptance[3] row click -> decision panel -> one approval POSTs the action door -> re-read -> decisions gone; acceptance[3] cold record route; acceptance[4] ?request= replaces itself with the record route showing that request, Back leaves; admin pending approver gets Approve/Reject and chips Dev Admin, Ada Auditor, Lee Legal; every case asserts zero requests to /api/v1/data/sys_approval_request and no record-access-denied; plus 6 guard unit pins. New app-shell file (real adapter): request page sends no page request and reads findOne; CONTROL ordinary record still sends one page request; drawer neighbour counts warmed. RED ON OLD CODE: the two sources restored to base 617dcf43 (blob hashes verified, restored from HEAD, git diff HEAD empty): 10 failed, 3 passed (the 3 CONTROLs); member cases drew record-access-denied, admin case drew the panel with no approve and 5 per-character approver chips for u1,u2,u3. Ablation via ablation-replace.mjs: drawer warm-up gate removed -> exactly the drawer case red (1 failed/2 passed), restored blob == HEAD; routeApprovalRecordReads guard removed -> exactly its 2 pins red (2 failed/8 passed), restored. Neighbouring suites at 010fbf63 (git grep -l of app-shell and console test files naming RecordDetailView, recordPagePayload, approvalRequestsDataSource, routeApprovalRecordReads, ApprovalRecordReads; 227 files, a declared narrowing, CI Test UI runs the rest): 114 files/903 tests and 113 files/817 tests passed. Type-check @object-ui/app-shell and @object-ui/console: Done, exit 0. The merge of main at 6d6aea80 touched no ui file.",
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack derived 34 families at 6d6aea80; all 34 ran on 6d6aea80 and exited 0 (exit captured before any pipe; type-check-debt, query-options-erasure and ui vitest scripts/ under the verify lock; GITHUB_EVENT_PATH holding the PR body). --ran reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. ui vitest scripts/: 169 files passed, 2 skipped, 4900 tests. check-sdui-manifest exit 0 (manifest intact, 106 components). Not derived, for the body: check-partof-closing-keyword exit 0, check-changeset-no-major --event exit 0. CI families: read from PR #28619 check-runs, in_progress at report time, not re-run here.",
      "dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
      "mcp_calls": "3 GitHub MCP reads: mcp__github__issue_read get #28546, get_comments #28546 twice (claim verification); no MCP GitHub write. Non-GitHub MCP: get_session, subscribe_pr_activity #28619.",
      "api_writes": "2 via the fleet-write relay: pr_create objectstack-ai/objectstack#28619 (draft, no labels, no assignees; run 41287958464; read-back identical 11532 bytes); this os-dev-report comment on #28546. git push is not a REST write. REST reads: 3 gh api search/issues queries for a parallel open PR (none found).",
      "deviations": [
        "Route choice: the seat assumption read as routing getRecordPage inside routeApprovalRecordReads. A client-composed payload for a request cannot be faithful: its update/delete verdict parts replace POST /security/explain, which plugin-detail reads by its own fetch, not through a DataSource member, so a composed payload would leave them absent and the header verdicts fail open. So the page asks no payload for a request (RecordDetailView), and the routed sources refuse one (guard). Same intent: the read is routed, the grant untouched.",
        "The routed-source guard is unreachable from today's UI (ablating it leaves the integration cases green); it is kept, with its own pins, as the class guard for a future caller (#25228 and this card are one class through two doors).",
        "Declaration lines copied from the claim (Clause-②: no, Metadata-protocol: none). Measured consistent: no @object-ui/i18n key, no @object-ui/types or DataSource member change, no spec change.",
        "File surface: the claim's two paths plus one new test file beside RecordDetailView.tsx (RecordDetailView.approvalRequestPage-28546.test.tsx) and the changeset. The plan's now-constant approvals flag reads true.",
        "Live browser retest on a booted showcase NOT MEASURED here (reason: the triage assigns the P0 retest to QA; the jsdom harness runs the shipped adapter, routed source, inbox and record view with only the transport doubled).",
        "PR assignee and reviewer not set, per the seat's common rule 10 and os-dev.md, over the harness's best-effort assignee instruction. Commit trailers are the model-free pair; the harness's model-named trailer was not used."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none. #28470 (open, unclaimed) proposes adding parts to the same record page payload; after this PR a request's page asks none of it. Noted in the PR's Acceptance notes, not filed."
      ]
    }
  5. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

    ACCEPT → PR #28619 comment 6555533292 · 2026-11-01T08:27Z · no contract-review face owed · landing through the merge queue. The live retest on the P0 item stays with QA.


    Generated by Claude Code

  6. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

    Landed · 2026-11-01T08:52Z · PR #28619 merged through the merge queue as 8a078da0. Record: ACCEPT 6555533292 (at 6d6aea80); no contract review owed.

    probe before (8a078da0~1) now (origin/main)
    guardRecordPageRead in console/src/services/approvalRequestsDataSource.ts 0 3
    SYS_APPROVAL_REQUEST_OBJECT) return in app-shell/src/views/RecordDetailView.tsx 0 1

    The card closed through Fixes. The live retest on the P0 item (approvals.account-app-entry acceptance[3]/[4]) stays with QA. pm:dispatched comes off in the same stroke as this note.


    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

Labels

area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:uipriority: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