Repository navigation
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
Description
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)
- Boot the showcase:
objectstack dev --ui --seed-admin -p PORT -d file:RUN/appr.db. - As admin,
POST /api/v1/auth/organization/invite-member{"email":"x@example.test","role":"member"}. X then callsPOST /api/v1/auth/sign-up/email. - As admin,
POST /api/v1/data/sys_user_position{"user_id": X_ID, "position":"contributor", "organization_id": ORG}, and again with"position":"finance". - As X,
POST /api/v1/data/showcase_invoice{"name":"INV-1","account": ACTIVE_ACCOUNT_ID, "owner":"x@example.test"}→ 201. - 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 amongpending_approvers. - 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_IDanswers 200 withviewer: {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/entryassets/index-RFCp05oY.jsis byte-identical topackages/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") madeRecordDetailViewtake the opened record fromdataSource.getRecordPage(...). That is the generic data-API doorPOST /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 takespage.record; a 403 is classifiedforbiddenand drawsrecord-access-denied). routeApprovalRecordReadsinui/apps/console/src/services/approvalRequestsDataSource.tsoverrides onlyfindandfindOne.forwardToHostpasses every other member,getRecordPageincluded, straight to the host adapter (ui/packages/data-objectstack/src/index.tsgetRecordPage→client.data.recordPage).- The data door has no object-level read of
sys_approval_requestfor these positions:GET /api/v1/data/sys_approval_request/AREQ_IDis also 403. So the request read thatGET /approvals/requests/:idwould serve never happens. - Even when
/pageanswers (admin), a data-door row carries noviewerblock and servespending_approversas a comma-joined string, while the approvals door serves an array plusviewer. The decision actions'record.viewer.can_actpredicates 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
objectstack-fleet commentedon Nov 1, 2026
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.
eeec88d1, and a regression.approvals.account-app-entrypassed atc4c515ad(QA run · priority:P0 (23/23) · c4c515ad · 2026-10-29 · 20 PASS / 3 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #27481). Since feat(rest,console): one request opens a record page #27713, a non-admin approver gets the access-denied screen in place of the decision panel. Even an admin approver gets no Approve or Reject, and sees one approver chip per character of the ids.domain:ui. The record page'sgetRecordPageread bypassesrouteApprovalRecordReads(ui/apps/console/src/services/approvalRequestsDataSource.ts), and the approvals door already answers 200 withviewer. ⛔ The data door's member read onsys_approval_requestis not widened to make the page work. The fix routes the read, not the grant.approvals.account-app-entryas a permanent test, plus the admin-approver panel and the chip strip. The fix is then retested on the P0 item.Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
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 sessionsession_01QsR2jR7ZFkpqmm4U8S1f9yAccount:
zhuangjianguoBranch:
claude/project-thread-5n78sm(platform-assigned to the card thread; it can push only this branch)Worktree:
objectstack-issue-28546Domain:
domain:uiSeat:
domain:ui#4File surface:
ui/apps/console/src/services/**andui/packages/app-shell/src/views/RecordDetailView.tsxwith 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: xhighClause-②: 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 panelThread-read: 6554139580
Premise: holds (
routeApprovalRecordReadsatorigin/mainoverridesfindandfindOneonly; no open PR touchesapprovalRequestsDataSource.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_requestis not widened; the fix routes the read, not the grant.Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
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." ] }objectstack-fleet commentedon Nov 1, 2026
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
objectstack-fleet commentedon Nov 1, 2026
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y
Landed · 2026-11-01T08:52Z · PR #28619 merged through the merge queue as
8a078da0. Record: ACCEPT 6555533292 (at6d6aea80); no contract review owed.8a078da0~1)origin/main)guardRecordPageReadinconsole/src/services/approvalRequestsDataSource.tsSYS_APPROVAL_REQUEST_OBJECT) returninapp-shell/src/views/RecordDetailView.tsxThe card closed through
Fixes. The live retest on the P0 item (approvals.account-app-entryacceptance[3]/[4]) stays with QA.pm:dispatchedcomes off in the same stroke as this note.Generated by Claude Code