@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +1023 -0
  3. package/dist/index.d.mts +650 -535
  4. package/dist/index.d.ts +650 -535
  5. package/dist/index.js +1713 -207
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1711 -197
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -7
  10. package/scripts/i18n-extract.config.ts +6 -1
  11. package/src/approval-actor-impersonation.test.ts +330 -0
  12. package/src/approval-node.test.ts +160 -0
  13. package/src/approval-node.ts +57 -0
  14. package/src/approval-revise.test.ts +41 -34
  15. package/src/approval-service.test.ts +1408 -40
  16. package/src/approval-service.ts +1364 -107
  17. package/src/approvals-plugin.ts +36 -5
  18. package/src/approver-cross-org.integration.test.ts +206 -0
  19. package/src/approver-org-scope.test.ts +201 -0
  20. package/src/approver-org-scope.ts +261 -0
  21. package/src/index.ts +3 -0
  22. package/src/lifecycle-hooks.ts +22 -0
  23. package/src/record-lock-schedule-run.integration.test.ts +206 -0
  24. package/src/status-mirror-cascade.integration.test.ts +224 -0
  25. package/src/sys-approval-action.object.ts +9 -0
  26. package/src/sys-approval-delegation.object.test.ts +42 -0
  27. package/src/sys-approval-delegation.object.ts +3 -3
  28. package/src/sys-approval-request.object.test.ts +13 -0
  29. package/src/sys-approval-request.object.ts +17 -5
  30. package/src/translations/bundle-ownership.test.ts +48 -0
  31. package/src/translations/en.objects.generated.ts +111 -5
  32. package/src/translations/es-ES.objects.generated.ts +111 -5
  33. package/src/translations/ja-JP.objects.generated.ts +111 -5
  34. package/src/translations/zh-CN.objects.generated.ts +110 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-approvals",
3
- "version": "16.1.0",
3
+ "version": "17.0.0-rc.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.",
6
6
  "main": "dist/index.js",
@@ -13,17 +13,19 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "16.1.0",
17
- "@objectstack/formula": "16.1.0",
18
- "@objectstack/metadata-core": "16.1.0",
19
- "@objectstack/platform-objects": "16.1.0",
20
- "@objectstack/spec": "16.1.0"
16
+ "@objectstack/core": "17.0.0-rc.0",
17
+ "@objectstack/formula": "17.0.0-rc.0",
18
+ "@objectstack/platform-objects": "17.0.0-rc.0",
19
+ "@objectstack/spec": "17.0.0-rc.0",
20
+ "@objectstack/metadata-core": "17.0.0-rc.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/node": "^26.1.1",
24
24
  "typescript": "^6.0.3",
25
25
  "vitest": "^4.1.10",
26
- "@objectstack/service-automation": "16.1.0"
26
+ "@objectstack/objectql": "17.0.0-rc.0",
27
+ "@objectstack/trigger-record-change": "17.0.0-rc.0",
28
+ "@objectstack/service-automation": "17.0.0-rc.0"
27
29
  },
28
30
  "keywords": [
29
31
  "objectstack",
@@ -8,8 +8,13 @@
8
8
  * strings were seeded from @objectstack/platform-objects.)
9
9
  *
10
10
  * os i18n extract packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts \
11
- * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \
11
+ * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
12
12
  * --out=packages/plugins/plugin-approvals/src/translations
13
+ *
14
+ * `--no-metadata-forms` because the Studio metadata-form baseline is owned by
15
+ * `@objectstack/platform-objects` — this plugin translates only the objects it
16
+ * owns, so it must not commit a second copy. Add `--check` to run the same
17
+ * command as a drift gate.
13
18
  */
14
19
 
15
20
  import { defineStack } from '@objectstack/spec';
@@ -0,0 +1,330 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * The acting identity on an approval is the AUTHENTICATED CALLER, never a
5
+ * request-body field.
6
+ *
7
+ * Every mutating entrypoint on the service takes an `actorId` and — before this
8
+ * suite — authorized *that value* rather than the caller behind it. The REST
9
+ * routes fill it from `body.actorId ?? body.actor_id ?? context.userId`, so the
10
+ * body won. An authenticated user could therefore name any pending approver and
11
+ * have that approver's decision recorded, finalized, and the owning flow resumed
12
+ * — or name a request's submitter and recall it.
13
+ *
14
+ * #3783 drew exactly this line for the *data-write* identity (see the
15
+ * `actingUserId` docblock in the service) and left the authorization side
16
+ * body-driven, calling a mislabelled audit row "tolerable". It is not merely a
17
+ * label: `pending_approvers.includes(input.actorId)` is the authorization gate
18
+ * itself, so naming someone else does not just misattribute the row — it is how
19
+ * you get through the door.
20
+ *
21
+ * The tests below are all "mallory is logged in, names someone else". Each one
22
+ * must be FORBIDDEN. The final block is the load-bearing negative: the two
23
+ * legitimate callers that supply an actor with no session behind them — the SLA
24
+ * sweep (a reserved sentinel) and the ADR-0043 action link (a single-use token
25
+ * cryptographically bound to one approver) — must keep working, or the fix has
26
+ * simply broken the feature instead of securing it.
27
+ */
28
+
29
+ import { describe, it, expect, beforeEach } from 'vitest';
30
+ import { ApprovalService, SLA_ACTOR_ID } from './approval-service.js';
31
+
32
+ interface FakeRow { [k: string]: any }
33
+
34
+ /** Equality/`$in`/`$ne`/`$contains` WHERE matcher — mirrors approval-service.test.ts. */
35
+ function makeFakeEngine() {
36
+ const tables: Record<string, FakeRow[]> = {};
37
+ const ensure = (n: string) => (tables[n] ??= []);
38
+
39
+ function matches(row: FakeRow, filter: any): boolean {
40
+ if (!filter || typeof filter !== 'object') return true;
41
+ for (const [k, v] of Object.entries(filter)) {
42
+ if (k === '$or') {
43
+ if (!(v as any[]).some(sub => matches(row, sub))) return false;
44
+ continue;
45
+ }
46
+ const rv = row[k];
47
+ if (v != null && typeof v === 'object' && '$in' in (v as any)) {
48
+ if (!(v as any).$in.includes(rv)) return false;
49
+ continue;
50
+ }
51
+ if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
52
+ if (rv === (v as any).$ne) return false;
53
+ continue;
54
+ }
55
+ if (v != null && typeof v === 'object' && '$contains' in (v as any)) {
56
+ if (!String(rv ?? '').includes(String((v as any).$contains))) return false;
57
+ continue;
58
+ }
59
+ if (rv !== v) return false;
60
+ }
61
+ return true;
62
+ }
63
+
64
+ return {
65
+ _tables: tables,
66
+ async find(object: string, options?: any) {
67
+ const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
68
+ if (options?.orderBy?.[0]) {
69
+ const { field, order } = options.orderBy[0];
70
+ rows.sort((a, b) => {
71
+ const av = a[field]; const bv = b[field];
72
+ if (av === bv) return 0;
73
+ const cmp = av > bv ? 1 : -1;
74
+ return order === 'desc' ? -cmp : cmp;
75
+ });
76
+ }
77
+ const start = options?.offset ?? 0;
78
+ return rows.slice(start, start + (options?.limit ?? 1000));
79
+ },
80
+ async insert(object: string, data: any) {
81
+ ensure(object).push({ ...data });
82
+ return { ...data };
83
+ },
84
+ async update(object: string, idOrData: any, _opts?: any) {
85
+ const data = typeof idOrData === 'object' ? idOrData : _opts;
86
+ const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
87
+ const table = ensure(object);
88
+ const i = table.findIndex(r => r.id === id);
89
+ if (i >= 0) table[i] = { ...table[i], ...data };
90
+ return table[i];
91
+ },
92
+ async delete(object: string, options?: any) {
93
+ const table = ensure(object);
94
+ const id = options?.where?.id ?? options?.id;
95
+ const i = table.findIndex(r => r.id === id);
96
+ if (i >= 0) table.splice(i, 1);
97
+ return { id };
98
+ },
99
+ registerHook() {},
100
+ unregisterHooksByPackage() { return 0; },
101
+ };
102
+ }
103
+
104
+ /** The submitter, and the approver whose slot is up for grabs. */
105
+ const SUBMITTER = 'alice';
106
+ const APPROVER = 'bob';
107
+ /** Authenticated, ordinary, and on neither side of the request. */
108
+ const MALLORY = { userId: 'mallory', tenantId: 't1', positions: [], permissions: [] } as any;
109
+ const ALICE = { userId: SUBMITTER, tenantId: 't1', positions: [], permissions: [] } as any;
110
+ const SYS = { isSystem: true, positions: [], permissions: [] } as any;
111
+
112
+ function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
113
+ return {
114
+ approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
115
+ behavior: 'first_response' as const,
116
+ ...extra,
117
+ };
118
+ }
119
+
120
+ function openInput(approvers: string[], extra: Record<string, any> = {}, configExtra: Record<string, any> = {}) {
121
+ return {
122
+ object: 'opportunity',
123
+ recordId: 'opp1',
124
+ runId: 'run_1',
125
+ nodeId: 'approve_step',
126
+ flowName: 'deal_approval',
127
+ config: nodeConfig(approvers, configExtra),
128
+ record: { id: 'opp1', amount: 100 },
129
+ ...extra,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Enough automation surface for the send-back path's ADR-0044 guards to pass,
135
+ * so a `sendBack` / `resubmit` test fails on the ACTOR check or not at all —
136
+ * never on a missing `revise` out-edge.
137
+ */
138
+ function makeAutomationStub() {
139
+ const resumed: any[] = [];
140
+ const cancelled: string[] = [];
141
+ return {
142
+ resumed,
143
+ cancelled,
144
+ async getFlow() {
145
+ return {
146
+ name: 'deal_approval',
147
+ nodes: [{ id: 'approve_step', type: 'approval' }, { id: 'wait_revision', type: 'wait' }],
148
+ edges: [
149
+ { id: 'e1', source: 'approve_step', target: 'ok', label: 'approve' },
150
+ { id: 'e2', source: 'approve_step', target: 'no', label: 'reject' },
151
+ { id: 'e3', source: 'approve_step', target: 'wait_revision', label: 'revise' },
152
+ { id: 'e4', source: 'wait_revision', target: 'approve_step', label: 'resubmit', type: 'back' },
153
+ ],
154
+ };
155
+ },
156
+ async resume(runId: string, signal: any) { resumed.push({ runId, signal }); },
157
+ async cancelRun(runId: string) { cancelled.push(runId); },
158
+ };
159
+ }
160
+
161
+ describe('approvals: the actor is the authenticated caller, not a body field', () => {
162
+ let engine: ReturnType<typeof makeFakeEngine>;
163
+ let svc: ApprovalService;
164
+ let n = 0;
165
+ const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
166
+
167
+ beforeEach(() => {
168
+ engine = makeFakeEngine();
169
+ n = 0;
170
+ svc = new ApprovalService({
171
+ engine: engine as any,
172
+ clock: { now: () => new Date(baseTime + (n++) * 1000) },
173
+ });
174
+ });
175
+
176
+ /** Open a request submitted by alice and pending on bob. */
177
+ const open = (approvers = [APPROVER], configExtra: Record<string, any> = {}) =>
178
+ svc.openNodeRequest(openInput(approvers, {}, configExtra), ALICE);
179
+
180
+ // ── the decision itself ─────────────────────────────────────────
181
+
182
+ it('decideNode: mallory cannot approve by naming the pending approver', async () => {
183
+ const req = await open();
184
+ await expect(
185
+ svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
186
+ ).rejects.toThrow(/FORBIDDEN/);
187
+ });
188
+
189
+ it('decideNode: a refused impersonation writes no audit row and leaves the request pending', async () => {
190
+ const req = await open();
191
+ await expect(
192
+ svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
193
+ ).rejects.toThrow(/FORBIDDEN/);
194
+
195
+ const decisions = (engine._tables['sys_approval_action'] ?? [])
196
+ .filter((a: any) => a.action === 'approve' || a.action === 'reject');
197
+ expect(decisions).toHaveLength(0);
198
+ expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
199
+ });
200
+
201
+ it('decideNode: mallory cannot reject by naming the pending approver', async () => {
202
+ const req = await open();
203
+ await expect(
204
+ svc.decideNode(req.id, { decision: 'reject', actorId: APPROVER, comment: 'no' }, MALLORY),
205
+ ).rejects.toThrow(/FORBIDDEN/);
206
+ });
207
+
208
+ it('decide: the flow is not resumed by an impersonated decision', async () => {
209
+ const resumed: any[] = [];
210
+ svc.attachAutomation({ async resume(runId: string, signal: any) { resumed.push({ runId, signal }); } } as any);
211
+ const req = await open();
212
+ await expect(
213
+ svc.decide(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
214
+ ).rejects.toThrow(/FORBIDDEN/);
215
+ expect(resumed).toHaveLength(0);
216
+ });
217
+
218
+ it('decideNode: a unanimous slate cannot be filled by one user naming the others', async () => {
219
+ const req = await open(['bob', 'carol'], { behavior: 'unanimous' });
220
+ const asBob = { userId: 'bob', tenantId: 't1', positions: [], permissions: [] } as any;
221
+ const first = await svc.decideNode(req.id, { decision: 'approve', actorId: 'bob' }, asBob);
222
+ expect(first.finalized).toBe(false);
223
+ // Bob holds a slot, so he clears the "is a pending approver" gate — but the
224
+ // slot he clears it with is his own, not carol's.
225
+ await expect(
226
+ svc.decideNode(req.id, { decision: 'approve', actorId: 'carol' }, asBob),
227
+ ).rejects.toThrow(/FORBIDDEN/);
228
+ expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
229
+ });
230
+
231
+ // ── the submitter-only moves ────────────────────────────────────
232
+
233
+ it('recall: mallory cannot withdraw the request by naming its submitter', async () => {
234
+ const req = await open();
235
+ await expect(
236
+ svc.recall(req.id, { actorId: SUBMITTER, comment: 'gone' }, MALLORY),
237
+ ).rejects.toThrow(/FORBIDDEN/);
238
+ expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
239
+ });
240
+
241
+ it('resubmit: mallory cannot resubmit a returned request by naming its submitter', async () => {
242
+ svc.attachAutomation(makeAutomationStub() as any);
243
+ const req = await open();
244
+ const asBob = { userId: APPROVER, tenantId: 't1', positions: [], permissions: [] } as any;
245
+ await svc.sendBack(req.id, { actorId: APPROVER, comment: 'fix it' }, asBob);
246
+ expect(engine._tables['sys_approval_request'][0].status).toBe('returned');
247
+ await expect(
248
+ svc.resubmit(req.id, { actorId: SUBMITTER, comment: 'done' }, MALLORY),
249
+ ).rejects.toThrow(/FORBIDDEN/);
250
+ });
251
+
252
+ it('sendBack: mallory cannot return the request by naming the pending approver', async () => {
253
+ const req = await open();
254
+ await expect(
255
+ svc.sendBack(req.id, { actorId: APPROVER, comment: 'revise' }, MALLORY),
256
+ ).rejects.toThrow(/FORBIDDEN/);
257
+ expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
258
+ });
259
+
260
+ // ── the thread moves ────────────────────────────────────────────
261
+
262
+ it('reassign: mallory cannot move the slot by naming its holder', async () => {
263
+ const req = await open();
264
+ await expect(
265
+ svc.reassign(req.id, { actorId: APPROVER, to: 'mallory' }, MALLORY),
266
+ ).rejects.toThrow(/FORBIDDEN/);
267
+ expect(engine._tables['sys_approval_request'][0].pending_approvers).toBe(APPROVER);
268
+ });
269
+
270
+ it('requestInfo: mallory cannot post as the pending approver', async () => {
271
+ const req = await open();
272
+ await expect(
273
+ svc.requestInfo(req.id, { actorId: APPROVER, comment: 'send the contract' }, MALLORY),
274
+ ).rejects.toThrow(/FORBIDDEN/);
275
+ });
276
+
277
+ it('comment: mallory cannot post to the thread as the submitter', async () => {
278
+ const req = await open();
279
+ await expect(
280
+ svc.comment(req.id, { actorId: SUBMITTER, comment: 'looks fine to me' }, MALLORY),
281
+ ).rejects.toThrow(/FORBIDDEN/);
282
+ });
283
+
284
+ it('remind: mallory cannot nudge as the submitter', async () => {
285
+ const req = await open();
286
+ await expect(
287
+ svc.remind(req.id, { actorId: SUBMITTER }, MALLORY),
288
+ ).rejects.toThrow(/FORBIDDEN/);
289
+ });
290
+
291
+ // ── the legitimate paths must survive ───────────────────────────
292
+ //
293
+ // Without these, "reject every actorId that isn't the caller" would pass the
294
+ // suite above by breaking the SLA sweep and the emailed action link — the two
295
+ // callers that hold a trustworthy actor with no session behind it.
296
+
297
+ it('the real approver still decides their own slot', async () => {
298
+ const req = await open();
299
+ const asBob = { userId: APPROVER, tenantId: 't1', positions: [], permissions: [] } as any;
300
+ const out = await svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, asBob);
301
+ expect(out.finalized).toBe(true);
302
+ expect(out.request.status).toBe('approved');
303
+ const row = (engine._tables['sys_approval_action'] ?? []).find((a: any) => a.action === 'approve');
304
+ expect(row.actor_id).toBe(APPROVER);
305
+ });
306
+
307
+ it('the real submitter still recalls their own request', async () => {
308
+ const req = await open();
309
+ const out = await svc.recall(req.id, { actorId: SUBMITTER }, ALICE);
310
+ expect(out.request.status).toBe('recalled');
311
+ });
312
+
313
+ it('a system context may still name an actor with no session behind it (SLA sweep)', async () => {
314
+ const req = await open();
315
+ const out = await svc.decideNode(req.id, { decision: 'approve', actorId: SLA_ACTOR_ID }, SYS);
316
+ expect(out.finalized).toBe(true);
317
+ const row = (engine._tables['sys_approval_action'] ?? []).find((a: any) => a.action === 'approve');
318
+ expect(row.actor_id).toBe(SLA_ACTOR_ID);
319
+ });
320
+
321
+ it('a privileged admin may still override a stuck request', async () => {
322
+ const req = await open(['position:cfo']);
323
+ const admin = {
324
+ userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'],
325
+ } as any;
326
+ const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'root' }, admin);
327
+ expect(out.finalized).toBe(true);
328
+ expect(out.request.status).toBe('approved');
329
+ });
330
+ });
@@ -154,6 +154,46 @@ describe('Approval node bridge (ADR-0019)', () => {
154
154
  expect(paused.runId).toBeDefined();
155
155
  });
156
156
 
157
+ // ── resume authorization gate (#3801) ───────────────────────────────
158
+ //
159
+ // `POST /automation/:name/runs/:runId/resume` reaches
160
+ // `AutomationEngine.resume` with a caller-supplied signal. Before the gate,
161
+ // the only thing between that route and the approvals rules was convention —
162
+ // a comment in the showcase. These pin the enforcement.
163
+
164
+ it('declares the approval node resumable only by its owning service', () => {
165
+ const approval = automation.getActionDescriptors().find(d => d.type === 'approval');
166
+ expect(approval!.resumeAuthority).toBe('service');
167
+ });
168
+
169
+ it('refuses a raw engine resume of an approval pause, leaving the request untouched', async () => {
170
+ registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
171
+ const paused = await automation.execute('deal_approval', {
172
+ object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
173
+ });
174
+ const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
175
+
176
+ // Exactly the signal the resume route builds from `{ branchLabel }`.
177
+ const refused = await automation.resume(paused.runId!, {
178
+ branchLabel: 'approve', output: { decision: 'approve' },
179
+ });
180
+
181
+ expect(refused).toMatchObject({ success: false, code: 'forbidden' });
182
+ // The approve branch did NOT run…
183
+ expect(marks).toHaveLength(0);
184
+ // …the request is still pending, with no decision recorded…
185
+ const stillPending = (await fake.find('sys_approval_request', { where: { id: request.id } }))[0];
186
+ expect(stillPending.status).toBe('pending');
187
+ const actions = await fake.find('sys_approval_action', { where: { request_id: request.id } });
188
+ expect(actions.map((a: any) => a.action)).toEqual(['submit']);
189
+ // …and the run is still parked, so the real decision can still land.
190
+ expect(automation.listSuspendedRuns()).toHaveLength(1);
191
+
192
+ const out = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
193
+ expect(out).toMatchObject({ finalized: true, resumed: true });
194
+ expect(marks).toEqual(['on_approved']);
195
+ });
196
+
157
197
  it('resumes down the reject branch on rejection', async () => {
158
198
  registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
159
199
  await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
@@ -193,4 +233,124 @@ describe('Approval node bridge (ADR-0019)', () => {
193
233
  service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, positions: [], permissions: [] } as any),
194
234
  ).rejects.toThrow(/FORBIDDEN/);
195
235
  });
236
+
237
+ // ── #3447 P2: dynamic approvers end-to-end ────────────────────────
238
+ //
239
+ // The issue's headline scenario as one flow: the first approver PICKS the
240
+ // next step's approvers in their decision, the next approval node resolves
241
+ // them from `vars.*` at entry — no record-field detour, no snapshot staleness.
242
+
243
+ it('decide outputs feed the NEXT approval node via vars.<nodeId>.<key> (#3447 P2)', async () => {
244
+ automation.registerFlow('two_stage', {
245
+ name: 'two_stage',
246
+ label: 'Two Stage',
247
+ type: 'autolaunched',
248
+ nodes: [
249
+ { id: 'start', type: 'start', label: 'Start' },
250
+ {
251
+ id: 'lead_review', type: 'approval', label: 'Lead Review',
252
+ config: { approvers: [{ type: 'user', value: 'lead' }], decisionOutputs: ['next_reviewers'] },
253
+ },
254
+ {
255
+ id: 'co_sign', type: 'approval', label: 'Co-sign',
256
+ config: {
257
+ approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],
258
+ behavior: 'unanimous',
259
+ },
260
+ },
261
+ { id: 'on_approved', type: 'mark', label: 'Approved' },
262
+ { id: 'on_rejected', type: 'mark', label: 'Rejected' },
263
+ ],
264
+ edges: [
265
+ { id: 'e1', source: 'start', target: 'lead_review' },
266
+ { id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },
267
+ { id: 'e3', source: 'lead_review', target: 'on_rejected', label: 'reject' },
268
+ { id: 'e4', source: 'co_sign', target: 'on_approved', label: 'approve' },
269
+ { id: 'e5', source: 'co_sign', target: 'on_rejected', label: 'reject' },
270
+ ],
271
+ });
272
+
273
+ const paused = await automation.execute('two_stage', {
274
+ object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
275
+ });
276
+ expect(paused.status).toBe('paused');
277
+
278
+ // The lead approves AND hands the co-reviewers to the flow.
279
+ const first = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
280
+ await service.decide(first.id, {
281
+ decision: 'approve', actorId: 'lead', outputs: { next_reviewers: ['u2', 'u3'] },
282
+ }, SYSTEM_CTX);
283
+
284
+ // The co-sign node resolved its slate from the lead's decision outputs.
285
+ const second = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
286
+ expect(second).toBeDefined();
287
+ expect(second.flow_node_id).toBe('co_sign');
288
+ expect(String(second.pending_approvers).split(',').sort()).toEqual(['u2', 'u3']);
289
+ expect(marks).toHaveLength(0);
290
+
291
+ // Both picked reviewers sign off → the run completes down `approve`.
292
+ await service.decide(second.id, { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX);
293
+ await service.decide(second.id, { decision: 'approve', actorId: 'u3' }, SYSTEM_CTX);
294
+ expect(marks).toEqual(['on_approved']);
295
+ expect(automation.listSuspendedRuns()).toHaveLength(0);
296
+ });
297
+
298
+ it('expression current.* resolves against the LIVE row at node entry (#3447 P2)', async () => {
299
+ fake.tables.set('crm_deal', [{ id: 'd1', reviewers: ['u7', 'u8'] }]);
300
+ registerDecisionFlow(automation, [{ type: 'expression', value: 'current.reviewers' }], 'unanimous');
301
+ // The trigger snapshot carries an EMPTY reviewers field — only the live
302
+ // row names them, exactly the mid-flow-written-field shape of the issue.
303
+ await automation.execute('deal_approval', {
304
+ object: 'crm_deal', record: { id: 'd1', reviewers: [] }, userId: 'submitter',
305
+ });
306
+ const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
307
+ expect(String(request.pending_approvers).split(',').sort()).toEqual(['u7', 'u8']);
308
+ });
309
+
310
+ it("onEmptyApprovers 'auto_approve' completes down the approve edge without suspending (#3447 P2)", async () => {
311
+ automation.registerFlow('auto_ok', {
312
+ name: 'auto_ok',
313
+ label: 'Auto OK',
314
+ type: 'autolaunched',
315
+ nodes: [
316
+ { id: 'start', type: 'start', label: 'Start' },
317
+ {
318
+ id: 'gate', type: 'approval', label: 'Gate',
319
+ config: {
320
+ // Present-but-empty (a missing key would fail loudly instead).
321
+ approvers: [{ type: 'expression', value: 'trigger.reviewers' }],
322
+ onEmptyApprovers: 'auto_approve',
323
+ },
324
+ },
325
+ { id: 'on_approved', type: 'mark', label: 'Approved' },
326
+ { id: 'on_rejected', type: 'mark', label: 'Rejected' },
327
+ ],
328
+ edges: [
329
+ { id: 'e1', source: 'start', target: 'gate' },
330
+ { id: 'e2', source: 'gate', target: 'on_approved', label: 'approve' },
331
+ { id: 'e3', source: 'gate', target: 'on_rejected', label: 'reject' },
332
+ ],
333
+ });
334
+
335
+ const result = await automation.execute('auto_ok', {
336
+ object: 'crm_deal', record: { id: 'd1', reviewers: [] }, userId: 'submitter',
337
+ });
338
+
339
+ // No pause, no request row — the empty slate waved through, down `approve`
340
+ // ONLY (the branchLabel wiring; unlabelled traversal would hit both marks).
341
+ expect(result.status).not.toBe('paused');
342
+ expect(marks).toEqual(['on_approved']);
343
+ expect(await fake.find('sys_approval_request', {})).toHaveLength(0);
344
+ expect(automation.listSuspendedRuns()).toHaveLength(0);
345
+ });
346
+
347
+ it('an expression referencing `record` fails the node loudly, not as an empty slate (#3447 P2)', async () => {
348
+ registerDecisionFlow(automation, [{ type: 'expression', value: 'record.reviewers' }]);
349
+ const result = await automation.execute('deal_approval', {
350
+ object: 'crm_deal', record: { id: 'd1', reviewers: ['u1'] }, userId: 'submitter',
351
+ });
352
+ expect(result.success).toBe(false);
353
+ expect(String(result.error ?? '')).toMatch(/current\.<field>|current\./);
354
+ expect(await fake.find('sys_approval_request', {})).toHaveLength(0);
355
+ });
196
356
  });
@@ -38,6 +38,12 @@ export interface ApprovalAutomationSurface {
38
38
  error?: string;
39
39
  suspend?: boolean;
40
40
  correlation?: string;
41
+ /**
42
+ * #3447 P2: walk this labelled out-edge on normal (non-suspend)
43
+ * completion — how an `onEmptyApprovers: 'auto_approve'` node continues
44
+ * down `approve` without a decision. Mirrors NodeExecutionResult.
45
+ */
46
+ branchLabel?: string;
41
47
  }>;
42
48
  }): void;
43
49
  resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
@@ -50,6 +56,29 @@ interface MinimalLogger {
50
56
 
51
57
  const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
52
58
 
59
+ /**
60
+ * Rebuild the nested object the engine's CEL conditions see from the flow's
61
+ * flat variable Map — dotted keys (`get_rec.record`) become nested paths, so an
62
+ * `expression` approver's `vars.get_rec.record.owner_id` reads exactly like a
63
+ * condition's. Mirrors the engine's own evaluateCondition rebuild; kept local
64
+ * because this plugin deliberately does not depend on service-automation.
65
+ */
66
+ function nestVariables(variables: Map<string, unknown>): Record<string, unknown> {
67
+ const vars: Record<string, unknown> = {};
68
+ for (const [key, value] of variables) {
69
+ const segs = key.split('.');
70
+ let cursor = vars;
71
+ for (let i = 0; i < segs.length - 1; i++) {
72
+ if (typeof cursor[segs[i]] !== 'object' || cursor[segs[i]] === null) {
73
+ cursor[segs[i]] = {};
74
+ }
75
+ cursor = cursor[segs[i]] as Record<string, unknown>;
76
+ }
77
+ cursor[segs[segs.length - 1]] = value;
78
+ }
79
+ return vars;
80
+ }
81
+
53
82
  /**
54
83
  * Register the `approval` node executor on the automation engine. Idempotent at
55
84
  * the engine level (re-registering replaces). Safe to skip when no automation
@@ -75,6 +104,15 @@ export function registerApprovalNode(
75
104
  // Human decision: the run suspends here awaiting an external reply.
76
105
  supportsPause: true,
77
106
  isAsync: true,
107
+ // #3801: this pause is NOT resumable through the generic run-resume
108
+ // route. Continuing an approval is a side effect of a DECISION, and the
109
+ // decision is the thing that must be authorized (the approver slate),
110
+ // recorded (`sys_approval_action`) and mirrored (the status field) —
111
+ // all of which lives in `ApprovalService.decide`. The engine now refuses
112
+ // any resume of an approval suspension that does not carry the service's
113
+ // in-process marker, so "decide via the approvals API, never a raw engine
114
+ // resume" is enforced rather than merely documented.
115
+ resumeAuthority: 'service',
78
116
  // Publish the node's config contract (ADR-0018 §configSchema) so the
79
117
  // Studio flow designer renders the Approval property form from the engine
80
118
  // rather than a hardcoded client form — the engine owns the shape.
@@ -116,6 +154,10 @@ export function registerApprovalNode(
116
154
  submitterId: context?.userId ?? null,
117
155
  record,
118
156
  organizationId: context?.organizationId ?? context?.tenantId ?? null,
157
+ // #3447 P2: flow variables (nested, as CEL conditions see them) — the
158
+ // `vars.*` root for `expression` approvers; `record` above doubles as
159
+ // their `trigger.*` snapshot root.
160
+ variables: nestVariables(variables),
119
161
  }, {
120
162
  ...SYSTEM_CTX,
121
163
  userId: context?.userId,
@@ -123,6 +165,21 @@ export function registerApprovalNode(
123
165
  tenantId: context?.tenantId,
124
166
  } as unknown as SharingExecutionContext);
125
167
 
168
+ // #3447 P2: empty slate + onEmptyApprovers: 'auto_approve' — nobody to
169
+ // ask, no request row. Complete (don't suspend) straight down the
170
+ // `approve` edge; `autoApproved` on the output keeps the waved-through
171
+ // hop distinguishable from a human decision in the run trace.
172
+ if ('autoApproved' in request) {
173
+ logger?.info?.('[approvals] approval node auto-approved (empty approver slate)', {
174
+ node: node.id, run: String(runId),
175
+ });
176
+ return {
177
+ success: true,
178
+ branchLabel: 'approve',
179
+ output: { decision: 'approve', autoApproved: true },
180
+ };
181
+ }
182
+
126
183
  logger?.info?.('[approvals] approval node suspended run', {
127
184
  node: node.id, request: request.id, run: String(runId),
128
185
  });