Skip to content

Commit d75edb9

Browse files
os-zhuangclaude
andauthored
fix(approvals): resolve field/manager approvers against the live record at node entry (#3447) (#3495)
Approval nodes resolved `field` / `manager` approvers against the trigger snapshot the flow froze into `$record` at submit time, so an earlier step could not set the field a later step routes on — dynamic routing / dynamic co-sign were impossible to express. Resolution was already lazy per-node; the defect was the data source (a frozen snapshot, never re-read), betrayed by graph approvers resolving live in the same node while field/manager read the snapshot. Re-read the record's live state by id at node entry under system identity, with a warn-and-fall-back-to-snapshot path when the record is gone. Also fan a multi-select user field into one approver slot per user (previously stringified to one bogus id) and apply OOO delegation per fanned-out user (previously silently skipped for multi-value fields). payload_json still snapshots the submitted record — its contract is unchanged. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 03b11e8 commit d75edb9

3 files changed

Lines changed: 133 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
---
4+
5+
Approval nodes now resolve `field` / `manager` approvers against the record's **live** state at node entry, not the trigger snapshot the flow froze at submit time (#3447). An earlier step — or the approver of an earlier step — can now write the field that routes a later step's approvers, enabling dynamic routing / dynamic co-sign (e.g. a lead reviewer picking which departments co-review, then those departments resolving as parallel approvers). Graph approvers (team / position / department / tier) already resolved live; this brings the in-record types into line.
6+
7+
Also fixes two latent defects on the same path: a multi-select user field now fans out into one approver slot per user (previously the array was stringified to a single bogus id), and out-of-office delegation is applied per fanned-out user (previously silently skipped for multi-value fields). When the record can't be re-read (hard-deleted mid-flow, or a backend that can't serve a point read), resolution falls back to the trigger snapshot and warns rather than wedging the flow.

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,65 @@ describe('ApprovalService (node era)', () => {
187187
expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
188188
});
189189

190+
// ── approver expansion: field (live re-read + fan-out, #3447) ────
191+
//
192+
// A `field` approver names WHO decides from a record field. It must bind to
193+
// the record's LIVE value at node entry — an earlier step (or the approver of
194+
// an earlier step) may have written it after submit — not the trigger snapshot
195+
// the run froze into `$record`. A multi-select user field fans out into one
196+
// slot per user.
197+
198+
const fieldInput = (extra: Record<string, any> = {}) => ({
199+
...openInput([]),
200+
config: {
201+
approvers: [{ type: 'field' as const, value: 'approvers_dynamic' }],
202+
behavior: 'unanimous' as const,
203+
lockRecord: true,
204+
},
205+
...extra,
206+
});
207+
208+
it('field approver: resolves against the LIVE record, not the trigger snapshot (#3447)', async () => {
209+
// The minimal repro: submitted with the routing field empty; a prior step
210+
// wrote the co-reviewers mid-flow. Resolution must see them.
211+
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100, approvers_dynamic: ['u2', 'u3'] }];
212+
const req = await svc.openNodeRequest(
213+
fieldInput({ record: { id: 'opp1', amount: 100, approvers_dynamic: [] } }), CTX,
214+
);
215+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3']);
216+
});
217+
218+
it('field approver: the live value WINS over a stale snapshot value (#3447)', async () => {
219+
// Snapshot named u1; the record now names u2. u2 decides.
220+
engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2'] }];
221+
const req = await svc.openNodeRequest(
222+
fieldInput({ record: { id: 'opp1', approvers_dynamic: ['u1'] } }), CTX,
223+
);
224+
expect(req.pending_approvers).toEqual(['u2']);
225+
});
226+
227+
it('field approver: fans a multi-select user field into one slot per user (#3447)', async () => {
228+
engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2', 'u3', 'u4'] }];
229+
const req = await svc.openNodeRequest(fieldInput({ record: { id: 'opp1' } }), CTX);
230+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3', 'u4']);
231+
});
232+
233+
it('field approver: fans a legacy CSV string field into multiple slots (#3447)', async () => {
234+
engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: 'u2,u3' }];
235+
const req = await svc.openNodeRequest(fieldInput({ record: { id: 'opp1' } }), CTX);
236+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3']);
237+
});
238+
239+
it('field approver: falls back to the trigger snapshot when the record is gone (#3447)', async () => {
240+
// No `opportunity` row — hard-deleted between submit and node entry. The
241+
// snapshot still carries a value, so the request opens against it (warn but
242+
// proceed) rather than wedging the flow.
243+
const req = await svc.openNodeRequest(
244+
fieldInput({ record: { id: 'opp1', approvers_dynamic: ['u7'] } }), CTX,
245+
);
246+
expect(req.pending_approvers).toEqual(['u7']);
247+
});
248+
190249
// ── approver expansion: position (ADR-0090 D3) ──────────────────
191250

192251
const positionInput = (extra: Record<string, any> = {}) => ({
@@ -1294,6 +1353,20 @@ describe('ApprovalService — out-of-office delegation (#1322)', () => {
12941353
expect(req.pending_approvers).toEqual(['bob']);
12951354
});
12961355

1356+
it('type:field (multi-select) — reroutes each out-of-office user independently (#3447)', async () => {
1357+
// Pre-#3447 the array was stringified to one bogus id ('alice,dave'), which
1358+
// matched no delegation — OOO silently no-op'd. Fanned out, alice → bob
1359+
// applies and dave is left untouched.
1360+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1361+
const input = {
1362+
...openInput([]),
1363+
record: { id: 'opp1', reviewers: ['alice', 'dave'] },
1364+
config: { approvers: [{ type: 'field', value: 'reviewers' }], behavior: 'unanimous', lockRecord: true },
1365+
};
1366+
const req = await svc.openNodeRequest(input as any, CTX);
1367+
expect(req.pending_approvers.sort()).toEqual(['bob', 'dave']);
1368+
});
1369+
12971370
it('type:manager — reroutes when the resolved manager is out of office', async () => {
12981371
engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
12991372
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);

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

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,15 @@ export class ApprovalService implements IApprovalService {
443443
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
444444
}
445445
if (type === 'field' && record) {
446-
return this.applyOooDelegation(String((record as any)[a.value] ?? ''), now, organizationId, substitutions);
446+
// #3447: a record field can name MANY approvers — a multi-select user
447+
// field arrives as an array (or a legacy CSV string). Fan each out into
448+
// its own slot and OOO-substitute per person; collapsing to `String(...)`
449+
// (→ `'u1,u2'`) would mint one bogus approver id and skip every delegate.
450+
const out: string[] = [];
451+
for (const id of csvSplit((record as any)[a.value])) {
452+
out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
453+
}
454+
return out;
447455
}
448456
try {
449457
if (type === 'team') {
@@ -662,6 +670,45 @@ export class ApprovalService implements IApprovalService {
662670
}
663671
}
664672

673+
/**
674+
* Re-read a business record's CURRENT state by id so approver resolution binds
675+
* to live data at node entry, not the trigger snapshot the flow froze into
676+
* `$record` at submit time (#3447). A `field` / `manager` approver names *who*
677+
* decides, and an earlier node — or the approver of an earlier step — may have
678+
* written that routing field after submit (e.g. a lead reviewer picking which
679+
* departments co-review). Graph approvers (team / position / …) already query
680+
* live; this brings the in-record types into line.
681+
*
682+
* Read under system identity: approver routing is a platform concern and the
683+
* record is the flow's own subject, so the submitter's RLS/FLS must not narrow
684+
* it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
685+
* hard-deleted between submit and node entry, or an object whose backend can't
686+
* serve a point read — warning rather than throwing so a transient miss can't
687+
* wedge an approval. That "warn but proceed" stance matches the
688+
* no-concrete-approver guard (#3424) and the "record is gone" enrichment path
689+
* that already falls back to the payload snapshot.
690+
*/
691+
private async loadLiveRecord(object: string, recordId: string, fallback?: any): Promise<any> {
692+
try {
693+
const rows = await this.engine.find(object, {
694+
where: { id: recordId }, limit: 1, context: SYSTEM_CTX,
695+
} as any);
696+
const live = Array.isArray(rows) ? rows[0] : rows;
697+
if (live) return live;
698+
this.logger?.warn?.(
699+
`[approvals] live record ${object}/${recordId} not found at node entry — `
700+
+ 'resolving approvers against the trigger snapshot (#3447 fallback).',
701+
{ object, recordId },
702+
);
703+
} catch (err: any) {
704+
this.logger?.warn?.(
705+
`[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} — `
706+
+ 'resolving approvers against the trigger snapshot (#3447 fallback).',
707+
);
708+
}
709+
return fallback ?? {};
710+
}
711+
665712
// ── ADR-0019: Approval-as-flow-node ──────────────────────────
666713
//
667714
// A flow's Approval node opens a request via `openNodeRequest` (carrying its
@@ -715,8 +762,12 @@ export class ApprovalService implements IApprovalService {
715762
// per_group finalization is decided against the slate resolved at OPEN time
716763
// (OOO-substituted), not re-resolved live at each decision.
717764
const groups: Record<string, string[]> = {};
765+
// #3447: resolve approvers against the record's LIVE state at node entry, not
766+
// the trigger snapshot carried in `input.record`. This is the whole fix — an
767+
// earlier step may have written the field this node routes on.
768+
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
718769
const approvers = await this.expandApprovers(
719-
{ approvers: input.config.approvers }, input.record, ctxOrg, { now: nowDate.getTime(), substitutions, groups },
770+
{ approvers: input.config.approvers }, liveRecord, ctxOrg, { now: nowDate.getTime(), substitutions, groups },
720771
);
721772

722773
// #3424: an approval routed to a target with no holders (e.g. an unstaffed

0 commit comments

Comments
 (0)