Skip to content

Commit b949059

Browse files
authored
fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456) (#3703)
Closes #3456 (#3424 follow-up, expected-behavior item 3). Implements both options from the issue. Prevention: the automation engine stamps `flowRunId` onto the run context at setup, alongside `runAs`; it rides into every data node's ObjectQL context and into `ctx.session`, and the lock hook exempts a write whose `flowRunId` matches the pending request's `flow_run_id`. Keyed on run identity rather than elevation, so a `runAs:'user'` run stays RLS-scoped while it writes. Pure provenance — server-constructed like `isSystem`, read by no security middleware. Recovery: a sweep on the existing approvals clock finalizes a pending request whose owning run is terminal as `recalled`, releasing the lock, audited under the reserved actor `system:dead-run`. Covers the process-crash case no in-band handler can. Fail-safe by construction: it acts only on an explicit terminal status from a closed set, so `paused`, `running`, an unknown status, an unreadable run and a missing automation engine are all read as alive. Also fixes `AutomationEngine.getRun`, which returned the first log entry for a run id; a run that pauses then finishes records two entries under one id, so every approval/screen/wait flow reported itself as `paused` forever. Residual, deliberate: a `runAs:'user'` run with no trigger user (a schedule) passes no ObjectQL context, so it carries no `flowRunId` and the sweep is what recovers that shape.
1 parent 49f6809 commit b949059

15 files changed

Lines changed: 618 additions & 11 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
"@objectstack/plugin-approvals": patch
4+
"@objectstack/objectql": patch
5+
"@objectstack/spec": patch
6+
---
7+
8+
fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456)
9+
10+
The record lock is keyed on a **pending** `sys_approval_request`, and it could
11+
not tell *the run that owns that request* from *an unrelated user editing the
12+
record*. So a flow that touched its own target record while its own approval was
13+
still pending — a manual `resume` with no decision, or a node that writes the
14+
record between opening the approval and the decision — died on its own
15+
`RECORD_LOCKED`, and the record stayed locked behind the dead run. Recovery
16+
existed (#3424 lets an admin `recall`/`reject` to release it) but nothing made it
17+
self-healing.
18+
19+
Both halves are now closed.
20+
21+
**Prevention — the owning run may write its own record.** The automation engine
22+
stamps `flowRunId` onto the run context at setup, alongside `runAs`, and it
23+
travels with every data node's ObjectQL context into `ctx.session`. The lock hook
24+
exempts a write whose `flowRunId` matches the pending request's `flow_run_id`.
25+
It is keyed on run identity rather than elevation on purpose: a `runAs:'user'`
26+
run stays fully RLS-scoped while it writes. `flowRunId` is pure provenance —
27+
server-constructed like `isSystem`, never client-supplied, evaluated by no
28+
security middleware, and the only write it permits is to the one record its own
29+
run already holds a pending request against.
30+
31+
**Recovery — a sweep releases records held by runs that died anyway.** A pending
32+
request whose owning run has reached a terminal state (`completed`, `failed`,
33+
`cancelled`, `timed_out`) can never be decided, so it is finalised as `recalled`
34+
— releasing the lock — and audited under the reserved actor `system:dead-run`
35+
with the run and its status in the comment, so it is never mistaken for a
36+
submitter's withdrawal. It runs on the existing approvals sweep clock, which also
37+
covers the case no in-band handler can: a run killed by a process crash.
38+
39+
The sweep is fail-safe by construction. It acts only on an explicit terminal
40+
status from a closed set; `paused` (the normal state of a live approval),
41+
`running`, an unrecognised status, an unknown run, a `getRun` that throws, and a
42+
deployment with no automation engine are all read as "still alive". The failure
43+
mode is "a dead run's lock survives until an admin recalls it" — today's
44+
behaviour — never "a live approval is destroyed".
45+
46+
Also fixes `AutomationEngine.getRun`, which returned the **first** log entry for
47+
a run id rather than the latest. A run that pauses and later finishes records two
48+
entries under one id, so every suspend-then-finish run — every approval, screen
49+
and wait flow — reported itself as `paused` forever, both on the Runs
50+
observability surface and to this sweep.
51+
52+
Residual, deliberately not changed here: a `runAs:'user'` run with no trigger
53+
user (a schedule) passes no ObjectQL context at all, so it carries no
54+
`flowRunId` and is still subject to the lock. Manufacturing a context just to
55+
carry the run id would flip that run from its documented unscoped fail-open
56+
(#1888) to baseline-member RLS — a separate, larger change. The sweep is what
57+
recovers that shape.

content/docs/automation/approvals.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,12 @@ The node writes a `sys_approval_request` row: `status: 'pending'`,
210210
record is **locked** against edits while pending (`lockRecord`, default `true`),
211211
and the flow run parks until a decision arrives.
212212

213+
The lock applies to everyone *except the run that opened the request*. A flow may
214+
still write its own target record while its own approval is pending, so it can
215+
never deadlock against itself. The exemption is keyed on run identity rather than
216+
elevation, so a `runAs:'user'` run stays row-level-security scoped while it
217+
writes — it does not become a system write.
218+
213219
Only `approvers` is required on the node; everything else has a default
214220
(`behavior: 'first_response'`, `lockRecord: true`, `maxRevisions: 3`).
215221

@@ -383,6 +389,20 @@ and is audited under the admin's own id. Prefer a guaranteed-staffed fallback
383389
approver so the set is never empty in the first place.
384390
</Callout>
385391

392+
<Callout type="info">
393+
**A dead run releases its own lock.** If the flow run that opened an approval
394+
reaches a terminal state without a decision — it failed, was cancelled, timed
395+
out, or the process hosting it crashed — nothing is left to decide the request,
396+
so a periodic sweep finalizes it as `recalled` and releases the record. The
397+
audit row records the actor `system:dead-run` and names the run and its status,
398+
so it reads distinctly from a submitter's own recall.
399+
400+
The sweep only ever acts on a run it can positively confirm is terminal: a
401+
paused run (the normal state of a live approval), an unknown run, or an
402+
unreachable automation engine all count as *alive* and are left untouched. It
403+
frees orphaned records; it never cancels a live approval.
404+
</Callout>
405+
386406
### Progress and notification deep links
387407

388408
A pending multi-approver request also carries a **server-computed

content/docs/references/kernel/execution-context.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const result = ExecutionContext.parse(data);
6666
| **accessible_org_ids** | `string[]` | optional | |
6767
| **rlsMembership** | `Record<string, string[]>` | optional | |
6868
| **isSystem** | `boolean` || |
69+
| **flowRunId** | `string` | optional | |
6970
| **skipTriggers** | `boolean` | optional | |
7071
| **skipAutomations** | `boolean` | optional | |
7172
| **seedReplay** | `boolean` | optional | |

packages/objectql/src/engine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,12 @@ export class ObjectQL implements IDataEngine {
783783
// Propagate system-elevated flag so hooks can distinguish engine
784784
// self-writes (e.g. approval status mirror) from genuine user writes.
785785
...((execCtx as any).isSystem ? { isSystem: true } : {}),
786+
// Propagate the owning flow run so a hook can recognize writes made BY a
787+
// run it already knows about — the approvals record lock lets the run that
788+
// opened a pending approval write its own target record (#3456). Pure
789+
// provenance: it grants nothing, and unlike `isSystem` it does not widen
790+
// the write's authorization, so a `runAs:'user'` run stays RLS-scoped.
791+
...((execCtx as any).flowRunId ? { flowRunId: String((execCtx as any).flowRunId) } : {}),
786792
// Propagate the automation-suppression flag so the record-change trigger
787793
// can skip flow dispatch for seed/bulk writes (ADR: seed loads end-state
788794
// data, not user events). `skipAutomations` implies `skipTriggers` —

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,12 +1561,178 @@ describe('record-lock hook (node era)', () => {
15611561
).resolves.toBeUndefined();
15621562
});
15631563

1564+
// ── #3456 prevention half: the lock must not kill the run that owns it ──
1565+
1566+
it('allows the OWNING run to write its own target record', async () => {
1567+
await expect(
1568+
engine.fire('beforeUpdate', {
1569+
object: 'opportunity',
1570+
input: { id: 'opp1', data: { amount: 200 } },
1571+
// Neither elevated nor admin — the exemption rides on run identity
1572+
// alone, so a `runAs:'user'` run stays RLS-scoped while it writes.
1573+
session: { isSystem: false, positions: [], userId: 'u1', flowRunId: 'run_1' },
1574+
}),
1575+
).resolves.toBeUndefined();
1576+
});
1577+
1578+
it('still blocks a DIFFERENT run writing the locked record', async () => {
1579+
await expect(
1580+
engine.fire('beforeUpdate', {
1581+
object: 'opportunity',
1582+
input: { id: 'opp1', data: { amount: 200 } },
1583+
session: { isSystem: false, positions: [], userId: 'u1', flowRunId: 'run_other' },
1584+
}),
1585+
).rejects.toThrow(/RECORD_LOCKED/);
1586+
});
1587+
1588+
it('does not exempt anyone when the pending request carries no run id', async () => {
1589+
// A request with no owning run has nothing to match against — a stray
1590+
// `flowRunId` must not become a skeleton key.
1591+
engine._tables['sys_approval_request'][0].flow_run_id = null;
1592+
await expect(
1593+
engine.fire('beforeUpdate', {
1594+
object: 'opportunity',
1595+
input: { id: 'opp1', data: { amount: 200 } },
1596+
session: { isSystem: false, positions: [], userId: 'u1', flowRunId: 'run_1' },
1597+
}),
1598+
).rejects.toThrow(/RECORD_LOCKED/);
1599+
});
1600+
15641601
it('unbindAllHooks removes the lock hook', () => {
15651602
expect(unbindAllHooks(engine as any)).toBe(1);
15661603
expect(engine._hooks['beforeUpdate']).toHaveLength(0);
15671604
});
15681605
});
15691606

1607+
// ── #3456 recovery half: release records held by a dead approval run ──
1608+
//
1609+
// The prevention half above stops a run from dying on its own lock. This sweep
1610+
// covers the runs that die anyway — including a process crash, which no in-band
1611+
// handler can clean up because the process that would run it is gone.
1612+
//
1613+
// The load-bearing property is what it must NOT do: a run merely *paused* on its
1614+
// approval is the normal state of every live request, so anything short of an
1615+
// explicit terminal status has to be read as "alive".
1616+
describe('ApprovalService — dead-run release (#3456)', () => {
1617+
let engine: ReturnType<typeof makeFakeEngine>;
1618+
let svc: ApprovalService;
1619+
let n = 0;
1620+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
1621+
1622+
/** Attach an automation surface whose `getRun` answers with `status`. */
1623+
const withRunStatus = (status: string | null) =>
1624+
svc.attachAutomation({ getRun: async () => (status == null ? null : { status }) } as any);
1625+
1626+
const requestRow = () => engine._tables['sys_approval_request'][0];
1627+
1628+
beforeEach(async () => {
1629+
engine = makeFakeEngine();
1630+
n = 0;
1631+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
1632+
bindApprovalLockHook(engine as any);
1633+
await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
1634+
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
1635+
});
1636+
1637+
it('releases a pending request whose owning run failed', async () => {
1638+
withRunStatus('failed');
1639+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 1 });
1640+
expect(requestRow().status).toBe('recalled');
1641+
expect(requestRow().pending_approvers).toBeNull();
1642+
expect(requestRow().completed_at).toBeTruthy();
1643+
});
1644+
1645+
it('audits the release as a dead-run abandonment, not a submitter recall', async () => {
1646+
withRunStatus('failed');
1647+
await svc.releaseDeadRunRequests();
1648+
const action = engine._tables['sys_approval_action'].find((a: any) => a.actor_id === 'system:dead-run');
1649+
expect(action).toBeTruthy();
1650+
expect(action.action).toBe('recall');
1651+
expect(action.comment).toMatch(/run_1/);
1652+
expect(action.comment).toMatch(/failed/);
1653+
});
1654+
1655+
it('actually unlocks the record — a plain user edit succeeds afterwards', async () => {
1656+
// The end-to-end point of the whole sweep.
1657+
const edit = () => engine.fire('beforeUpdate', {
1658+
object: 'opportunity',
1659+
input: { id: 'opp1', data: { amount: 200 } },
1660+
session: { isSystem: false, positions: [], userId: 'u1' },
1661+
});
1662+
await expect(edit()).rejects.toThrow(/RECORD_LOCKED/); // held by the dead run
1663+
withRunStatus('failed');
1664+
await svc.releaseDeadRunRequests();
1665+
await expect(edit()).resolves.toBeUndefined(); // released
1666+
});
1667+
1668+
it('mirrors the configured status field on release', async () => {
1669+
withRunStatus('failed');
1670+
await svc.releaseDeadRunRequests();
1671+
expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
1672+
});
1673+
1674+
it('leaves a PAUSED run alone — that is a live approval', async () => {
1675+
withRunStatus('paused');
1676+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
1677+
expect(requestRow().status).toBe('pending');
1678+
});
1679+
1680+
it.each([
1681+
['an unknown run (null)', null],
1682+
['an unrecognised status', 'reticulating_splines'],
1683+
['a still-running run', 'running'],
1684+
])('leaves the request pending for %s', async (_label, status) => {
1685+
withRunStatus(status as any);
1686+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
1687+
expect(requestRow().status).toBe('pending');
1688+
});
1689+
1690+
it('leaves the request pending when getRun throws', async () => {
1691+
svc.attachAutomation({ getRun: async () => { throw new Error('engine unreachable'); } } as any);
1692+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
1693+
expect(requestRow().status).toBe('pending');
1694+
});
1695+
1696+
it('is a no-op with no automation engine attached', async () => {
1697+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 0, released: 0 });
1698+
expect(requestRow().status).toBe('pending');
1699+
});
1700+
1701+
it('is a no-op when the surface has no getRun (older engine)', async () => {
1702+
svc.attachAutomation({ resume: async () => undefined } as any);
1703+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 0, released: 0 });
1704+
expect(requestRow().status).toBe('pending');
1705+
});
1706+
1707+
it('skips a request with no owning run', async () => {
1708+
requestRow().flow_run_id = null;
1709+
withRunStatus('failed');
1710+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
1711+
expect(requestRow().status).toBe('pending');
1712+
});
1713+
1714+
it.each(['completed', 'cancelled', 'timed_out'])(
1715+
'releases on the other terminal status %s', async (status) => {
1716+
// A terminal run can never decide its request, whatever ended it — a
1717+
// `completed` one means someone resumed the run out of band.
1718+
withRunStatus(status);
1719+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 1 });
1720+
expect(requestRow().status).toBe('recalled');
1721+
},
1722+
);
1723+
1724+
it('one unreadable request does not stop the sweep', async () => {
1725+
await svc.openNodeRequest(
1726+
{ ...openInput(['u9']), recordId: 'opp2', runId: 'run_2' } as any, CTX,
1727+
);
1728+
let call = 0;
1729+
svc.attachAutomation({
1730+
getRun: async () => { call++; if (call === 1) throw new Error('boom'); return { status: 'failed' }; },
1731+
} as any);
1732+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 2, released: 1 });
1733+
});
1734+
});
1735+
15701736
// ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
15711737
//
15721738
// When a resolved individual approver has declared an active OOO delegation,

0 commit comments

Comments
 (0)