fix(approvals): admin override to recover an approval routed to an unstaffed position (#3424)#3451
Merged
Merged
Conversation
…staffed position (#3424) An `approval` node routed to a position/team with no holders resolved to only the unresolvable `position:<name>` literal in `pending_approvers`, so no concrete user was in the slate. Every normal `decide` / `reassign` / `recall` then returned FORBIDDEN and, with `lockRecord`, the target record stayed RECORD_LOCKED forever — a data-availability dead-end with no in-product recovery. Trivial to hit in fresh/demo orgs (positions seeded, holders not) and whenever a role is vacated. Let a platform or tenant admin act on any pending request to release it: approve, reject, reassign it to a real approver, or recall it. The override finalizes the request (which releases the record lock, keyed on a pending request); a tenant admin's authority is org-scoped, a platform admin's is not, and the action is audited under the admin's own id. An admin approval is authoritative — it finalizes the node even under unanimous/quorum/per_group rather than counting as one vote among the (empty) slate. - Add server-computed `sys_approval_request.viewer.can_override`; the approve/reject/reassign declared actions OR it into their `visible` gate so the console surfaces the recovery path with no hand-wired button. - Warn loudly when a node resolves to no concrete approver, so the misconfiguration is visible instead of silently locking the record. The literal-fallback behavior (15.x slot back-compat) is otherwise unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmQPXXbgomoqrXtoxr3CS2
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 104 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…3424) Note the platform/tenant admin recovery path for a request routed to an unstaffed position (approve / reject / reassign / recall), and add `can_override` to the per-viewer block description. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmQPXXbgomoqrXtoxr3CS2
os-zhuang
marked this pull request as ready for review
July 24, 2026 15:08
Two authoring-time / demo-time complements to the admin-override recovery:
- lint: new advisory rule `approval-approvers-may-resolve-empty` (info) fires
when EVERY approver on an approval node routes to a group that can be empty
(position/team/department) — the exact shape that dead-ends when nobody holds
the group. It prescribes a guaranteed-staffed fallback
(`{ type: 'org_membership_level', value: 'owner' }`). Advisory, since staffing
is runtime data a linter can't see; individual/owner-tier fallbacks suppress
it. Non-gating (info → suggestion).
- showcase: the seed staffed `manager`/`finance`/`legal` but not `exec`, the
second tier of `showcase_budget_approval` (manager → exec) — a user driving a
budget approval to step 2 hit the exact #3424 dead-end. Staff `exec` too so
every position the showcase flows route to is actionable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmQPXXbgomoqrXtoxr3CS2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #3424 — an
approvalnode routed to a position (or team/department) with no holders created a permanently undecidable request and locked the target record forever, with no in-product recovery.Root cause. When an approver spec (e.g.
{ type: 'position', value: 'sales_manager' }) resolves to zero holders,expandApproversfalls back to the unresolvable literalposition:sales_managerinpending_approvers— no concrete user is in the slate. Every normal decision then fails the "is a pending approver" / "is the submitter" checks withFORBIDDEN, and (withlockRecord) thebeforeUpdatelock hook keeps the recordRECORD_LOCKEDfor as long as the request ispending. The only exit was editing the DB by hand. Trivial to hit in fresh/demo orgs (positions seeded, holders not) and whenever a role is vacated in production.The literal fallback is intentional (15.x stored slots stay queryable — pinned by existing tests), so this does not change request creation. Instead it adds the missing admin escape hatch.
What changed
Privileged override (
packages/plugins/plugin-approvals/src/approval-service.ts)isOverrideActor(context, requestOrg)— true for a platform admin (admin_full_access/platform_admin/posture: PLATFORM_ADMIN) or a tenant admin (organization_admin/org_owner/org_admin/posture: TENANT_ADMIN), reading the same resolved-authz signals the engine's superuser bypass already trusts. A platform admin crosses tenants; a tenant admin is scoped to their own org; system contexts always pass.decideNode,reassign, andrecallhonour an override actor even when they hold no slot / aren't the submitter — so an admin can approve, reject, reassign to a real approver, or recall. The override finalizes the request, which releases the record lock (keyed on a pending request). The decision is audited under the admin's own id.unanimous/quorum/per_group, rather than counting as one vote in the (empty) slate.openNodeRequestnow warns loudly when a node resolves to no concrete approver, so the misconfiguration surfaces in logs instead of silently locking the record.Surfacing (
sys-approval-request.object.ts,spec/contracts/approval-service.ts)viewergains a server-computedcan_override(true for a privileged admin on a pending request). Theapprove/reject/reassigndeclared actions OR it into theirvisiblegate, so the console shows the recovery path with no hand-wired button. Existing approver/submitter gating is unchanged.The console side (
viewer.can_overridetype + inbox hint) is in the companion objectui PR.Why not the other options in the issue
position approver: falls back to a position: literal when nobody holds itand siblings), and can't be caught at authoring (a staffed position can be vacated later). A runtime warning is added instead.Testing
pnpm turbo run test --filter=@objectstack/plugin-approvals→ 156 passed. New coverage inapproval-service.test.ts(admin approve/reject/reassign/recall on a stuck unstaffed-position request, org-scoping, unanimous finalization,viewer.can_override) andsys-approval-request.object.test.ts(the override gate on the declared actions). FulltsupDTS typecheck of the plugin passes.A
patchchangeset is included.🤖 Generated with Claude Code
https://claude.ai/code/session_01VmQPXXbgomoqrXtoxr3CS2
Generated by Claude Code