Repository navigation
Approval routed to an empty position permanently locks the record (no admin override, no recovery) #3424
Description
Summary
When a record-change flow with an approval node routes to a position that has no holders, the approval request becomes permanently undecidable AND the underlying record stays RECORD_LOCKED forever, with no admin escape hatch. This is a data-availability dead-end: the record can never be edited or re-approved again.
Environment
@objectstack/*16.1.0 (open edition), single-tenant, sqlite-wasm driver- Repro app: a CRM with
opportunity_approval(arecord_changeflow, tieredapprovalnodes routed to{ type: 'position', value: 'sales_manager' })
Steps to reproduce
- Define an
approvalnode withlockRecord: trueandapprovers: [{ type: 'position', value: 'sales_manager' }]. - Ensure the
sales_managerposition exists but has zerosys_user_positionrows (the default in a fresh org — seeds create positions but assign no holders). - Trigger the flow (update a record so the entry condition matches).
- Observe the approval request is created with
pending_approvers: ["position:sales_manager"]and the opportunity is locked.
Actual behaviour
POST /api/v1/approvals/requests/:id/approveas the org owner → 403{"code":"FORBIDDEN","error":"actor '…' is not a pending approver"}— the owner is not in the (empty) resolved approver set.POST …/:id/withdraw→ 404 Not Found (no withdraw route for a flow-launched request).PATCH /api/v1/data/crm_opportunity/:id→ 400{"error":"RECORD_LOCKED: record is locked while an approval is in progress"}.- Re-triggering the flow is suppressed by the in-flight re-entrancy guard, and the paused run’s
mark_approvednode fails with the sameRECORD_LOCKEDif resumed. - Net result: the request is stuck
pending, the record is stuck locked, and the only way out is editing the sqlite DB directly (UPDATE sys_approval_request SET status='approved'; UPDATE crm_opportunity SET approval_status='approved'with the server stopped).
Expected behaviour
At least one of:
- Empty-approver detection at request creation — if a resolved approver set is empty, either auto-approve/auto-reject with a clear audit entry, or refuse to lock the record and surface a config error, rather than creating an undecidable request.
- Admin override / reassign — a privileged actor (org owner,
modify_all_data, or a delegated approvals admin) should always be able to approve, reject, reassign, or withdraw a pending request and release the lock. - Unlock on terminal failure — if the approval run dies (
mark_approvedthrowsRECORD_LOCKED), the lock should be released so the record isn’t orphaned.
Impact
High. Any approval routed to an unstaffed position permanently bricks the target record with no in-product recovery. This is very easy to hit in fresh/demo orgs (positions seeded, holders not) and in production whenever a role is vacated.
Workaround
Add a guaranteed-staffed fallback approver, e.g. { type: 'org_membership_level', value: 'owner' }, alongside the position approver so the set is never empty. This side-steps the dead-end but doesn’t address the missing override or the orphaned-lock-on-failure.
Activity
docs(approvals): document the admin override + viewer.can_override (#…
fix(approvals): defense-in-depth for empty-approver routing (#3424)
os-zhuang commentedon Jul 24, 2026
修复已提交。采用管理员 override 恢复方案(而非"创建时拒绝空审批人"):
type:value字面量回退是 15.x 存量槽位的兼容行为、有测试固定,且一个 position 可能在发布后才被清空、无法在创建期静态判定——所以补的是缺失的管理员逃生通道,而不是改动请求创建。PRs
viewer.can_override+ 运行期告警 + 文档(本 issue 由该 PR 的Fixes #3424合并后自动关闭)核心改动:平台管理员(
admin_full_access)或租户管理员(organization_admin,按 org 限定)可对任意pending请求 approve / reject / reassign / recall,通过 finalize 释放记录锁;审计记在管理员本人 id 名下;管理员审批为权威决定,即使unanimous/quorum/per_group也立即 finalize(不算作空 slate 里的一票)。纵深防御(同在 #3451):新增
os lint咨询规则approval-approvers-may-resolve-empty(全部路由到 position/team/department 时提示补保底审批人);showcase seed 补齐exec岗位(此前showcase_budget_approval第二级 manager→exec 未被 staff,正是本 issue 的复现)。你列的第 3 点(终态失败解锁)未纳入本次修复,已另立 #3456 跟进——那是 flow 执行写入上下文 vs 记录锁 hook 的独立问题;override 已提供恢复兜底,所以单独硬化,不塞进本 PR。
Generated by Claude Code
fix(approvals): admin override to recover an approval routed to an un…
feat(approvals): expression approvers, empty-slate policy, decision o…
fix(approvals): a dead approval run no longer leaves the record RECOR…
fix(approvals): delete the two `session.roles` admin exemptions — rec…
docs(plugin-approvals,plugin-sharing): correct the position-expansion…
fix(plugin-approvals): show `approval_recall` for the #3424 admin ove…
fix(plugin-approvals): show `approval_recall` for the objectstack-ai#…
fix(approvals): the #3424 override reaches a pending request only — r…
test(approvals): pin tenant-admin `can_override` and `resumed` on pen…
docs(spec): `IApprovalService.recall` names every actor who may recal…
docs(plugin-approvals): recall's docblock summary line states the sta…
test(approvals): pin tenant-admin `can_override` and `resumed` on pen…
docs(spec): `IApprovalService.recall` names every actor who may recal…
docs(plugin-approvals): recall's docblock summary line states the sta…
fix(approvals): isOverrideActor resolves TENANT-admin standing from t…
fix(plugin-approvals): every slot reader takes the caller's acting ad…
docs(qa): unblock 4 fixture-blocked checklist items and re-point the …
docs(qa): checklist items for the 17.7 pre-release security fixes (ob…
feat(plugin-approvals): an empty admin_rescue slate opens on the orga…