Goal
Gate the declared approver actions on sys_approval_request by whether the current viewer can actually act, computed server-side — instead of the coarse record.status == "pending" they use today.
Why
After objectui#2697, the inbox renders decision actions from the object's declared set via DeclaredActionsBar. The approver-side actions (approval_send_back / approval_request_info / approval_reassign) are gated only on:
visible: 'record.status == "pending"'
So a submitter viewing their own pending request also sees "Send back / Request info / Reassign" — clicking them just 403s (FORBIDDEN: actor is not a pending approver). The service is the authority so it's safe, but it's a UX wart: buttons that can't work.
The old hardcoded inbox gated these behind canApproveReject = identities.some(id => pending_approvers.has(id)), where identities was built client-side (buildApproverIdentities: user id + email + role:*). That's both duplicated client logic and incomplete — it never resolved position/team/department membership, so a position-addressed approver could be wrongly hidden.
Design — mirror decision_progress (server computes, client zero-logic)
getRequest(requestId, context) already has the caller's full SharingExecutionContext (id, roles, positions, permissions) and already attaches a per-request computed block via attachDecisionProgress (→ decision_progress, added in #3274). Add a sibling per-viewer block the same way:
- New computed field on
ApprovalRequestRow (contract packages/spec/src/contracts/approval-service.ts, next to decision_progress at ~L109):
viewer?: { can_act: boolean; is_submitter: boolean };
can_act = is the caller a current pending approver, using the same resolution the service uses to authorize a decision (reuse expandApprovers / the pending-approver check — id + email + roles + positions + teams), so it's strictly more accurate than the old client heuristic.
is_submitter = submitter_id == context.userId (lets the frontend drop its own submitter_id == ctx.user.id comparisons too, for symmetry).
- Compute it in
getRequest only (the drawer's single-request read); no need to burden list endpoints.
- Point the declared actions'
visible at it (in sys-approval-request.object.ts):
- approver actions:
visible: 'record.status == "pending" && record.viewer.can_act'
- submitter actions (remind/recall/resubmit):
visible: '… && record.viewer.is_submitter' (equivalent to today, but consistent + server-sourced).
viewer is absent on list rows → the CEL must fail-closed gracefully (a record.viewer.can_act on an undefined viewer should hide, not throw). Confirm the CEL engine's null-nav behavior; if needed keep status == pending as the outer guard so list contexts stay predictable.
Files
packages/spec/src/contracts/approval-service.ts — add viewer? to ApprovalRequestRow.
packages/plugins/plugin-approvals/src/approval-service.ts — compute viewer in getRequest (reuse the pending-approver resolution; mirror attachDecisionProgress).
packages/plugins/plugin-approvals/src/sys-approval-request.object.ts — tighten the declared visible predicates.
- Tests: service test that
getRequest returns can_act:true for a pending approver (incl. a position/team-addressed one) and false for the submitter; can_act:false once finalized.
- objectui: sync the
ApprovalRequestRow contract type (apps/console/src/services/approvalsApi.ts) — no UI code change; the drawer already renders from declared visible. Optional: drop the now-redundant client buildApproverIdentities-based canApproveReject/canRecall gating if nothing else uses it.
Acceptance criteria
Refs
objectui#2697 (button retirement) · #3274 (decision_progress, the pattern to mirror) · framework#3300 (declared action set)
Goal
Gate the declared approver actions on
sys_approval_requestby whether the current viewer can actually act, computed server-side — instead of the coarserecord.status == "pending"they use today.Why
After objectui#2697, the inbox renders decision actions from the object's declared set via
DeclaredActionsBar. The approver-side actions (approval_send_back/approval_request_info/approval_reassign) are gated only on:So a submitter viewing their own pending request also sees "Send back / Request info / Reassign" — clicking them just 403s (
FORBIDDEN: actor is not a pending approver). The service is the authority so it's safe, but it's a UX wart: buttons that can't work.The old hardcoded inbox gated these behind
canApproveReject=identities.some(id => pending_approvers.has(id)), whereidentitieswas built client-side (buildApproverIdentities: user id + email +role:*). That's both duplicated client logic and incomplete — it never resolved position/team/department membership, so a position-addressed approver could be wrongly hidden.Design — mirror
decision_progress(server computes, client zero-logic)getRequest(requestId, context)already has the caller's fullSharingExecutionContext(id, roles, positions, permissions) and already attaches a per-request computed block viaattachDecisionProgress(→decision_progress, added in #3274). Add a sibling per-viewer block the same way:ApprovalRequestRow(contractpackages/spec/src/contracts/approval-service.ts, next todecision_progressat ~L109):can_act= is the caller a current pending approver, using the same resolution the service uses to authorize a decision (reuseexpandApprovers/ the pending-approver check — id + email + roles + positions + teams), so it's strictly more accurate than the old client heuristic.is_submitter=submitter_id == context.userId(lets the frontend drop its ownsubmitter_id == ctx.user.idcomparisons too, for symmetry).getRequestonly (the drawer's single-request read); no need to burden list endpoints.visibleat it (insys-approval-request.object.ts):visible: 'record.status == "pending" && record.viewer.can_act'visible: '… && record.viewer.is_submitter'(equivalent to today, but consistent + server-sourced).vieweris absent on list rows → the CEL must fail-closed gracefully (arecord.viewer.can_acton an undefinedviewershould hide, not throw). Confirm the CEL engine's null-nav behavior; if needed keepstatus == pendingas the outer guard so list contexts stay predictable.Files
packages/spec/src/contracts/approval-service.ts— addviewer?toApprovalRequestRow.packages/plugins/plugin-approvals/src/approval-service.ts— computevieweringetRequest(reuse the pending-approver resolution; mirrorattachDecisionProgress).packages/plugins/plugin-approvals/src/sys-approval-request.object.ts— tighten the declaredvisiblepredicates.getRequestreturnscan_act:truefor a pending approver (incl. a position/team-addressed one) andfalsefor the submitter;can_act:falseonce finalized.ApprovalRequestRowcontract type (apps/console/src/services/approvalsApi.ts) — no UI code change; the drawer already renders from declaredvisible. Optional: drop the now-redundant clientbuildApproverIdentities-basedcanApproveReject/canRecallgating if nothing else uses it.Acceptance criteria
returned.viewercomputed only ongetRequest; list endpoints unaffected; CEL fails closed whenviewerabsent.Refs
objectui#2697 (button retirement) · #3274 (
decision_progress, the pattern to mirror) · framework#3300 (declared action set)