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 (a record_change flow, tiered approval nodes routed to { type: 'position', value: 'sales_manager' })
Steps to reproduce
- Define an
approval node with lockRecord: true and approvers: [{ type: 'position', value: 'sales_manager' }].
- Ensure the
sales_manager position exists but has zero sys_user_position rows (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/approve as 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_approved node fails with the same RECORD_LOCKED if 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_approved throws RECORD_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.
Summary
When a record-change flow with an
approvalnode routes to a position that has no holders, the approval request becomes permanently undecidable AND the underlying record staysRECORD_LOCKEDforever, 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 driveropportunity_approval(arecord_changeflow, tieredapprovalnodes routed to{ type: 'position', value: 'sales_manager' })Steps to reproduce
approvalnode withlockRecord: trueandapprovers: [{ type: 'position', value: 'sales_manager' }].sales_managerposition exists but has zerosys_user_positionrows (the default in a fresh org — seeds create positions but assign no holders).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"}.mark_approvednode fails with the sameRECORD_LOCKEDif resumed.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:
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.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.