@objectstack/plugin-approvals 7.3.0 → 7.4.1

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.
@@ -1,24 +1,33 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
- import { ApprovalProcessSchema } from '@objectstack/spec/automation';
3
+ import {
4
+ APPROVAL_BRANCH_LABELS,
5
+ type ApprovalNodeConfig,
6
+ } from '@objectstack/spec/automation';
4
7
  import type {
5
8
  IApprovalService,
6
- ApprovalProcessRow,
7
9
  ApprovalRequestRow,
8
10
  ApprovalActionRow,
9
11
  ApprovalDecisionInput,
10
12
  ApprovalDecisionResult,
11
13
  ApprovalStatus,
12
- DefineApprovalProcessInput,
13
- SubmitApprovalInput,
14
14
  SharingExecutionContext,
15
15
  } from '@objectstack/spec/contracts';
16
- import type { MetadataRepository } from '@objectstack/metadata-core';
17
- import { executeActions, type ApprovalTrigger, type FetchLike } from './action-executor.js';
18
16
 
19
17
  /**
20
- * Narrow engine surface — keeps the service testable without booting
21
- * a real ObjectQL kernel.
18
+ * Node-era approval runtime (ADR-0019).
19
+ *
20
+ * Approval is no longer a standalone engine — it is a **flow node**. A flow's
21
+ * Approval node opens a request via {@link ApprovalService.openNodeRequest} and
22
+ * the run suspends; a human decision via {@link ApprovalService.decide}
23
+ * finalises the request and resumes the owning run down the matching
24
+ * `approve` / `reject` edge.
25
+ *
26
+ * This service owns the durable approval *state* — `sys_approval_request` /
27
+ * `sys_approval_action`, approver resolution (team / department / role /
28
+ * manager graph), and the optional status-field mirror — plus the decision
29
+ * API. It does not author processes, submit, or walk multi-step machinery
30
+ * anymore; that orchestration lives on the one automation engine.
22
31
  */
23
32
  export interface ApprovalEngine {
24
33
  find(object: string, options?: any): Promise<any[]>;
@@ -29,6 +38,15 @@ export interface ApprovalEngine {
29
38
 
30
39
  export interface ApprovalClock { now(): Date }
31
40
 
41
+ /**
42
+ * Minimal automation surface the service uses to resume a suspended flow run
43
+ * once a decision finalises a node-driven request. Optional — attached by the
44
+ * plugin when an automation engine is present (see `approval-node.ts`).
45
+ */
46
+ export interface ApprovalResumeSurface {
47
+ resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
48
+ }
49
+
32
50
  const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
33
51
 
34
52
  function uid(prefix: string): string {
@@ -51,26 +69,11 @@ function csvSplit(raw: unknown): string[] {
51
69
  return String(raw).split(',').map(s => s.trim()).filter(Boolean);
52
70
  }
53
71
 
54
- function rowFromProcess(row: any): ApprovalProcessRow {
55
- return {
56
- id: String(row.id),
57
- name: String(row.name ?? ''),
58
- label: String(row.label ?? ''),
59
- object_name: String(row.object_name ?? ''),
60
- description: row.description ?? undefined,
61
- active: row.active !== false,
62
- definition: parseJson(row.definition_json, {}),
63
- created_at: row.created_at ?? undefined,
64
- updated_at: row.updated_at ?? undefined,
65
- };
66
- }
67
-
68
72
  function rowFromRequest(row: any): ApprovalRequestRow {
69
73
  return {
70
74
  id: String(row.id),
71
75
  organization_id: row.organization_id ?? undefined,
72
76
  process_name: String(row.process_name ?? ''),
73
- process_hash: row.process_hash ?? undefined,
74
77
  object_name: String(row.object_name ?? ''),
75
78
  record_id: String(row.record_id ?? ''),
76
79
  submitter_id: row.submitter_id ?? undefined,
@@ -80,6 +83,8 @@ function rowFromRequest(row: any): ApprovalRequestRow {
80
83
  current_step_index: row.current_step_index ?? undefined,
81
84
  pending_approvers: csvSplit(row.pending_approvers),
82
85
  payload: parseJson(row.payload_json, undefined),
86
+ flow_run_id: row.flow_run_id ?? undefined,
87
+ flow_node_id: row.flow_node_id ?? undefined,
83
88
  completed_at: row.completed_at ?? undefined,
84
89
  created_at: row.created_at ?? undefined,
85
90
  updated_at: row.updated_at ?? undefined,
@@ -99,70 +104,45 @@ function rowFromAction(row: any): ApprovalActionRow {
99
104
  };
100
105
  }
101
106
 
102
- // Note: legacy synchronous `resolveApprovers` removed in M10.17.1 — replaced
103
- // by the async `expandApprovers` member which routes through the team/dept
104
- // graph tables (with prefixed-literal fallback for back-compat).
105
-
106
107
  export interface ApprovalServiceOptions {
107
108
  engine: ApprovalEngine;
108
109
  clock?: ApprovalClock;
109
110
  logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void; debug?: (msg: any, ...rest: any[]) => void };
110
- /** Optional fetch impl for `webhook` actions; defaults to global. */
111
- fetch?: FetchLike;
112
- /** Webhook timeout in ms; default 5000. */
113
- webhookTimeoutMs?: number;
114
- /**
115
- * Called after the process registry changes (defineProcess / deleteProcess).
116
- * The plugin uses this to re-bind lifecycle hooks for auto-trigger / lock.
117
- */
118
- onRegistryChange?: () => void | Promise<void>;
119
111
  /**
120
- * Optional metadata repository for execution-pinned process resolution
121
- * (ADR-0009). When provided:
122
- *
123
- * - `submit()` records the process body's sha256 on the request row.
124
- * - `approve` / `reject` / `recall` resolve the pinned body via
125
- * `MetadataRepository.getByHash` so process upgrades don't affect
126
- * in-flight requests.
127
- *
128
- * When omitted, the service reads the current process from the
129
- * `sys_approval_process` projection (pre-ADR-0009 behaviour).
112
+ * Optional automation surface used to resume a suspended flow run when a
113
+ * decision finalises a request. Usually attached after construction via
114
+ * {@link ApprovalService.attachAutomation} once the automation engine is
115
+ * available.
130
116
  */
131
- metadataRepo?: MetadataRepository;
117
+ automation?: ApprovalResumeSurface;
132
118
  }
133
119
 
134
120
  export class ApprovalService implements IApprovalService {
135
121
  private readonly engine: ApprovalEngine;
136
122
  private readonly clock: ApprovalClock;
137
123
  private readonly logger?: ApprovalServiceOptions['logger'];
138
- private readonly fetchImpl?: FetchLike;
139
- private readonly webhookTimeoutMs?: number;
140
- private readonly onRegistryChange?: () => void | Promise<void>;
141
- private readonly metadataRepo?: MetadataRepository;
124
+ private automation?: ApprovalResumeSurface;
142
125
 
143
126
  constructor(opts: ApprovalServiceOptions) {
144
127
  this.engine = opts.engine;
145
128
  this.clock = opts.clock ?? { now: () => new Date() };
146
129
  this.logger = opts.logger;
147
- this.fetchImpl = opts.fetch;
148
- this.webhookTimeoutMs = opts.webhookTimeoutMs;
149
- this.onRegistryChange = opts.onRegistryChange;
150
- this.metadataRepo = opts.metadataRepo;
130
+ this.automation = opts.automation;
151
131
  }
152
132
 
153
- /** Allow the plugin to attach a hook re-binding callback after construction. */
154
- setRegistryChangeHandler(handler: () => void | Promise<void>): void {
155
- (this as any).onRegistryChange = handler;
133
+ /** Attach (or replace) the automation surface used to resume flow runs. */
134
+ attachAutomation(automation: ApprovalResumeSurface): void {
135
+ this.automation = automation;
156
136
  }
157
137
 
158
138
  /**
159
- * Expand the approvers on a step into user IDs by querying the graph
160
- * tables for `team:` / `department:` / `role:` / `manager:` approver
161
- * types. Falls back to a prefixed literal (`type:value`) when graph
162
- * lookups produce nothing — so existing test fixtures and approver
163
- * flows that rely on substring matching keep working.
139
+ * Expand the approvers on an Approval node into user IDs by querying the
140
+ * graph tables for `team:` / `department:` / `role:` / `manager:` approver
141
+ * types. Falls back to a prefixed literal (`type:value`) when graph lookups
142
+ * produce nothing — so existing fixtures and flows that rely on substring
143
+ * matching keep working.
164
144
  *
165
- * **Graph semantics (M10.17.1):**
145
+ * **Graph semantics:**
166
146
  * - `team` → flat members of `sys_team` (better-auth; no BFS)
167
147
  * - `department` → recursive BFS of `sys_department.parent_department_id`
168
148
  * → members of every descendant via `sys_department_member`
@@ -281,222 +261,47 @@ export class ApprovalService implements IApprovalService {
281
261
  } catch { return null; }
282
262
  }
283
263
 
284
-
285
- private async notifyRegistryChanged(): Promise<void> {
286
- const cb = this.onRegistryChange ?? ((this as any).onRegistryChange as (() => void | Promise<void>) | undefined);
287
- if (!cb) return;
288
- try { await cb(); }
289
- catch (err: any) { this.logger?.warn?.('[approvals] onRegistryChange handler failed', { error: err?.message }); }
290
- }
291
-
292
- /**
293
- * Look up the HEAD checksum of an approval process from the metadata repo
294
- * (ADR-0009). Returns null when no repo is wired, no metadata exists for
295
- * the name, or the lookup fails — callers MUST treat null as "do not pin"
296
- * and fall back to the projection table.
297
- */
298
- private async resolveProcessHash(processName: string, organizationId?: string | null): Promise<string | null> {
299
- if (!this.metadataRepo) return null;
300
- if (!processName) return null;
301
- const orgRef = { org: organizationId || 'system', type: 'approval' as const, name: processName };
264
+ /** Mirror a request status onto a business-object field, if configured. */
265
+ private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise<void> {
302
266
  try {
303
- const head = await this.metadataRepo.get(orgRef);
304
- return head?.hash ?? null;
267
+ await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX });
305
268
  } catch (err: any) {
306
- this.logger?.debug?.('[approvals] metadataRepo.get failed', { name: processName, error: err?.message });
307
- return null;
269
+ this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
308
270
  }
309
271
  }
310
272
 
273
+ // ── ADR-0019: Approval-as-flow-node ──────────────────────────
274
+ //
275
+ // A flow's Approval node opens a request via `openNodeRequest` (carrying its
276
+ // own approvers/behavior config and the suspended run id), then suspends. A
277
+ // later `decide` finalizes it and resumes the flow run down the matching
278
+ // `approve`/`reject` edge. The record lock is enforced by a beforeUpdate hook
279
+ // keyed on a *pending* request, so finalizing auto-releases it.
280
+
311
281
  /**
312
- * Resolve the approval process for an in-flight request, honouring
313
- * ADR-0009 execution pinning when a `process_hash` is recorded.
314
- *
315
- * Resolution order:
316
- * 1. If `req.process_hash` AND `metadataRepo` are set, try
317
- * `getByHash` — return a row whose `definition` is the pinned body.
318
- * 2. Otherwise (or on lookup failure) fall back to the current
319
- * projection via `getProcess(req.process_name)`.
282
+ * Open a pending approval request on behalf of a flow's Approval node. The
283
+ * node config (approvers / behavior / status field) is snapshotted on the row
284
+ * so a decision can be made without any process to resolve against.
320
285
  */
321
- private async loadProcessForRequest(req: ApprovalRequestRow, context: SharingExecutionContext): Promise<ApprovalProcessRow | null> {
322
- const hash = req.process_hash;
323
- if (hash && this.metadataRepo) {
324
- const orgId = (req as any).organization_id ?? null;
325
- const orgRef = { org: orgId || 'system', type: 'approval' as const, name: req.process_name };
326
- try {
327
- const pinned = await this.metadataRepo.getByHash(orgRef, hash);
328
- if (pinned?.body) {
329
- // Use the pinned body for the definition; pull identity/state
330
- // fields from the current projection if available so display
331
- // labels and active-flag stay fresh. Synthesize if absent.
332
- const current = await this.getProcess(req.process_name, context);
333
- const body: any = pinned.body;
334
- return {
335
- id: current?.id ?? `pinned_${hash.slice(7, 19)}`,
336
- name: req.process_name,
337
- label: body.label ?? current?.label ?? req.process_name,
338
- object_name: req.object_name,
339
- description: body.description ?? current?.description,
340
- active: current?.active ?? true,
341
- definition: body,
342
- created_at: current?.created_at,
343
- updated_at: current?.updated_at,
344
- };
345
- }
346
- this.logger?.warn?.('[approvals] pinned process body not found; falling back to current', {
347
- request: req.id, process: req.process_name, hash,
348
- });
349
- } catch (err: any) {
350
- this.logger?.warn?.('[approvals] getByHash failed; falling back to current', {
351
- request: req.id, error: err?.message,
352
- });
353
- }
354
- }
355
- return this.getProcess(req.process_name, context);
356
- }
357
-
358
- /** Mirror request status onto `process.approvalStatusField` if configured. */
359
- private async syncStatusField(process: ApprovalProcessRow, request: ApprovalRequestRow): Promise<void> {
360
- const field = (process.definition as any)?.approvalStatusField;
361
- if (!field) return;
362
- try {
363
- await this.engine.update(
364
- process.object_name,
365
- { id: request.record_id, [field]: request.status },
366
- { context: SYSTEM_CTX },
367
- );
368
- } catch (err: any) {
369
- this.logger?.warn?.(`[approvals] syncStatusField failed: ${err?.message ?? err}`);
370
- }
371
- }
372
-
373
- /** Convenience wrapper that funnels every action invocation through the executor. */
374
- private async runActions(
375
- actions: any[] | undefined | null,
376
- trigger: ApprovalTrigger,
377
- process: ApprovalProcessRow,
378
- request: ApprovalRequestRow,
379
- step: any | undefined,
380
- actorId: string | null | undefined,
381
- comment: string | null | undefined,
382
- ): Promise<void> {
383
- if (!actions || actions.length === 0) return;
384
- await executeActions(actions, {
385
- trigger,
386
- process: { ...process, object: process.object_name },
387
- request,
388
- step,
389
- actorId: actorId ?? null,
390
- comment: comment ?? null,
391
- }, {
392
- engine: this.engine,
393
- logger: this.logger,
394
- fetch: this.fetchImpl,
395
- webhookTimeoutMs: this.webhookTimeoutMs,
396
- });
397
- }
398
-
399
- // ── Process definitions ──────────────────────────────────────
400
-
401
- async defineProcess(input: DefineApprovalProcessInput, _context: SharingExecutionContext): Promise<ApprovalProcessRow> {
402
- if (!input.name) throw new Error('VALIDATION_FAILED: name is required');
403
- if (!input.label) throw new Error('VALIDATION_FAILED: label is required');
404
- if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
405
- if (!input.definition) throw new Error('VALIDATION_FAILED: definition is required');
406
-
407
- const parsed = ApprovalProcessSchema.safeParse(input.definition);
408
- if (!parsed.success) {
409
- const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
410
- throw new Error(`VALIDATION_FAILED: ${msg}`);
411
- }
412
-
413
- const now = this.clock.now().toISOString();
414
- const payload: any = {
415
- name: input.name,
416
- label: input.label,
417
- object_name: input.object,
418
- description: input.description ?? null,
419
- active: input.active !== false,
420
- definition_json: JSON.stringify(parsed.data),
421
- updated_at: now,
422
- };
423
-
424
- // Upsert by name.
425
- const existing = await this.engine.find('sys_approval_process', {
426
- where: { name: input.name }, limit: 1, context: SYSTEM_CTX,
427
- });
428
- if (Array.isArray(existing) && existing[0]) {
429
- const id = existing[0].id;
430
- await this.engine.update('sys_approval_process', { id, ...payload }, { context: SYSTEM_CTX });
431
- const row = rowFromProcess({ ...existing[0], ...payload, id });
432
- await this.notifyRegistryChanged();
433
- return row;
434
- }
435
-
436
- const id = input.id ?? uid('apv');
437
- const row = { id, ...payload, created_at: now };
438
- await this.engine.insert('sys_approval_process', row, { context: SYSTEM_CTX });
439
- const out = rowFromProcess(row);
440
- await this.notifyRegistryChanged();
441
- return out;
442
- }
443
-
444
- async listProcesses(
445
- filter: { object?: string; activeOnly?: boolean } | undefined,
446
- _context: SharingExecutionContext,
447
- ): Promise<ApprovalProcessRow[]> {
448
- const f: any = {};
449
- if (filter?.object) f.object_name = filter.object;
450
- if (filter?.activeOnly) f.active = true;
451
- const rows = await this.engine.find('sys_approval_process', {
452
- where: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX,
453
- });
454
- return Array.isArray(rows) ? rows.map(rowFromProcess) : [];
455
- }
456
-
457
- async getProcess(idOrName: string, _context: SharingExecutionContext): Promise<ApprovalProcessRow | null> {
458
- if (!idOrName) return null;
459
- let rows = await this.engine.find('sys_approval_process', {
460
- where: { id: idOrName }, limit: 1, context: SYSTEM_CTX,
461
- });
462
- if (!Array.isArray(rows) || !rows[0]) {
463
- rows = await this.engine.find('sys_approval_process', {
464
- where: { name: idOrName }, limit: 1, context: SYSTEM_CTX,
465
- });
466
- }
467
- return Array.isArray(rows) && rows[0] ? rowFromProcess(rows[0]) : null;
468
- }
469
-
470
- async deleteProcess(idOrName: string, context: SharingExecutionContext): Promise<void> {
471
- if (!idOrName) throw new Error('VALIDATION_FAILED: idOrName is required');
472
- const proc = await this.getProcess(idOrName, context);
473
- if (!proc) return;
474
- await this.engine.delete('sys_approval_process', { where: { id: proc.id }, context: SYSTEM_CTX });
475
- await this.notifyRegistryChanged();
476
- }
477
-
478
- // ── Requests ─────────────────────────────────────────────────
479
-
480
- async submit(input: SubmitApprovalInput, context: SharingExecutionContext): Promise<ApprovalRequestRow> {
286
+ async openNodeRequest(
287
+ input: {
288
+ object: string;
289
+ recordId: string;
290
+ runId: string;
291
+ nodeId: string;
292
+ config: ApprovalNodeConfig;
293
+ flowName?: string;
294
+ submitterId?: string | null;
295
+ record?: any;
296
+ organizationId?: string | null;
297
+ },
298
+ context: SharingExecutionContext,
299
+ ): Promise<ApprovalRequestRow> {
481
300
  if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
482
301
  if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required');
302
+ if (!input.runId) throw new Error('VALIDATION_FAILED: runId is required');
483
303
 
484
- // Find active process for the object (or by name when supplied).
485
- let process: ApprovalProcessRow | null = null;
486
- if (input.processName) {
487
- process = await this.getProcess(input.processName, context);
488
- if (process && !process.active) {
489
- throw new Error(`NO_ACTIVE_PROCESS: process '${input.processName}' is not active`);
490
- }
491
- } else {
492
- const list = await this.listProcesses({ object: input.object, activeOnly: true }, context);
493
- process = list[0] ?? null;
494
- }
495
- if (!process) {
496
- throw new Error(`NO_ACTIVE_PROCESS: no active approval process for object '${input.object}'`);
497
- }
498
-
499
- // De-duplicate: only one pending request per (object, record).
304
+ // One pending request per (object, record).
500
305
  const existing = await this.engine.find('sys_approval_request', {
501
306
  where: { object_name: input.object, record_id: input.recordId, status: 'pending' },
502
307
  limit: 1, context: SYSTEM_CTX,
@@ -505,67 +310,161 @@ export class ApprovalService implements IApprovalService {
505
310
  throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
506
311
  }
507
312
 
508
- const steps: any[] = process.definition?.steps ?? [];
509
- if (steps.length === 0) {
510
- throw new Error('VALIDATION_FAILED: process definition has no steps');
511
- }
512
- const step0 = steps[0];
513
- const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null;
514
- const approvers = await this.expandApprovers(step0, input.payload, ctxOrg);
313
+ const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? input.organizationId ?? null;
314
+ const approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg);
515
315
 
516
316
  const now = this.clock.now().toISOString();
517
317
  const id = uid('areq');
518
- const processHash = await this.resolveProcessHash(process.name, ctxOrg);
318
+ const processName = `flow:${input.flowName ?? input.nodeId}`;
519
319
  const row: any = {
520
320
  id,
521
- process_name: process.name,
522
- process_hash: processHash,
321
+ process_name: processName,
523
322
  object_name: input.object,
524
323
  record_id: input.recordId,
525
324
  submitter_id: input.submitterId ?? context.userId ?? null,
526
- submitter_comment: input.comment ?? null,
527
325
  status: 'pending',
528
- current_step: step0.name,
326
+ current_step: input.nodeId,
529
327
  current_step_index: 0,
530
328
  pending_approvers: approvers.join(','),
531
- payload_json: input.payload != null ? JSON.stringify(input.payload) : null,
329
+ payload_json: input.record != null ? JSON.stringify(input.record) : null,
330
+ flow_run_id: input.runId,
331
+ flow_node_id: input.nodeId,
332
+ node_config_json: JSON.stringify(input.config),
532
333
  organization_id: ctxOrg,
533
334
  created_at: now,
534
335
  updated_at: now,
535
336
  };
536
337
  await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX });
338
+ await this.engine.insert('sys_approval_action', {
339
+ id: uid('aact'), request_id: id, organization_id: ctxOrg,
340
+ step_name: input.nodeId, step_index: 0, action: 'submit',
341
+ actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now,
342
+ }, { context: SYSTEM_CTX });
343
+
344
+ // Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate
345
+ // hook keyed on the now-pending request; no extra write needed here.
346
+ if (input.config.approvalStatusField) {
347
+ await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, 'pending');
348
+ }
349
+
350
+ return rowFromRequest(row);
351
+ }
352
+
353
+ /**
354
+ * Record a decision on a node-driven request. Honours the node's `unanimous`
355
+ * behavior (holds until every approver has approved). When the request
356
+ * finalizes, returns the suspended run id + node id so the caller (or
357
+ * {@link ApprovalService.decide}) can resume the flow down the matching
358
+ * branch.
359
+ */
360
+ async decideNode(
361
+ requestId: string,
362
+ input: { decision: 'approve' | 'reject'; actorId: string; comment?: string },
363
+ context: SharingExecutionContext,
364
+ ): Promise<{ request: ApprovalRequestRow; runId: string | null; nodeId: string | null; finalized: boolean; decision: 'approve' | 'reject' }> {
365
+ if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
366
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
367
+ if (input.decision !== 'approve' && input.decision !== 'reject') {
368
+ throw new Error('VALIDATION_FAILED: decision must be approve|reject');
369
+ }
370
+
371
+ // Read the raw row to reach flow_* correlation + the node config snapshot.
372
+ const rawRows = await this.engine.find('sys_approval_request', {
373
+ where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
374
+ });
375
+ const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
376
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
377
+ if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
378
+
379
+ const pendingApprovers = csvSplit(raw.pending_approvers);
380
+ if (!context.isSystem && !pendingApprovers.includes(input.actorId)) {
381
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
382
+ }
537
383
 
538
- // Audit: submit.
384
+ const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
385
+ const org = raw.organization_id ?? null;
386
+ const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
387
+ const runId: string | null = raw.flow_run_id ?? null;
388
+ const now = this.clock.now().toISOString();
389
+
390
+ // Audit the decision first so the unanimous tally below sees it.
539
391
  await this.engine.insert('sys_approval_action', {
540
- id: uid('aact'),
541
- request_id: id,
542
- organization_id: ctxOrg,
543
- step_name: step0.name,
544
- step_index: 0,
545
- action: 'submit',
546
- actor_id: input.submitterId ?? context.userId ?? null,
547
- comment: input.comment ?? null,
548
- created_at: now,
392
+ id: uid('aact'), request_id: requestId, organization_id: org,
393
+ step_name: nodeId, step_index: 0, action: input.decision,
394
+ actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
549
395
  }, { context: SYSTEM_CTX });
550
396
 
551
- const requestRow = rowFromRequest(row);
552
-
553
- // Phase B: status mirror + onSubmit actions.
554
- await this.syncStatusField(process, requestRow);
555
- const definition: any = process.definition ?? {};
556
- await this.runActions(
557
- definition.onSubmit,
558
- 'submit',
559
- process,
560
- requestRow,
561
- step0,
562
- input.submitterId ?? context.userId ?? null,
563
- input.comment ?? null,
564
- );
565
-
566
- return requestRow;
397
+ // Unanimous approve: advance only once every approver has approved.
398
+ if (input.decision === 'approve' && config.behavior === 'unanimous') {
399
+ const original = await this.expandApprovers(
400
+ { approvers: config.approvers }, parseJson(raw.payload_json, undefined), org,
401
+ );
402
+ const acts = await this.engine.find('sys_approval_action', {
403
+ where: { request_id: requestId, step_index: 0, action: 'approve' }, limit: 500, context: SYSTEM_CTX,
404
+ });
405
+ const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
406
+ const stillPending = original.filter(a => !approved.has(a));
407
+ if (stillPending.length > 0) {
408
+ await this.engine.update('sys_approval_request', {
409
+ id: requestId, pending_approvers: stillPending.join(','), updated_at: now,
410
+ }, { context: SYSTEM_CTX });
411
+ const fresh = await this.getRequest(requestId, context);
412
+ return { request: fresh!, runId, nodeId, finalized: false, decision: input.decision };
413
+ }
414
+ }
415
+
416
+ const finalStatus = input.decision === 'approve' ? 'approved' : 'rejected';
417
+ await this.engine.update('sys_approval_request', {
418
+ id: requestId, status: finalStatus, pending_approvers: null, completed_at: now, updated_at: now,
419
+ }, { context: SYSTEM_CTX });
420
+ if (config.approvalStatusField) {
421
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
422
+ }
423
+ const fresh = await this.getRequest(requestId, context);
424
+ return { request: fresh!, runId, nodeId, finalized: true, decision: input.decision };
567
425
  }
568
426
 
427
+ /**
428
+ * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
429
+ * request via {@link ApprovalService.decideNode} and, when it finalizes,
430
+ * resumes the owning flow run down the matching `approve` / `reject` edge.
431
+ */
432
+ async decide(
433
+ requestId: string,
434
+ input: ApprovalDecisionInput,
435
+ context: SharingExecutionContext,
436
+ ): Promise<ApprovalDecisionResult> {
437
+ const result = await this.decideNode(requestId, input, context);
438
+
439
+ let resumed = false;
440
+ if (result.finalized && result.runId && typeof this.automation?.resume === 'function') {
441
+ const branchLabel = result.decision === 'approve'
442
+ ? APPROVAL_BRANCH_LABELS.approve
443
+ : APPROVAL_BRANCH_LABELS.reject;
444
+ try {
445
+ await this.automation.resume(result.runId, {
446
+ branchLabel,
447
+ output: { decision: result.decision, requestId },
448
+ });
449
+ resumed = true;
450
+ } catch (err: any) {
451
+ this.logger?.warn?.('[approvals] resume after decision failed', {
452
+ request: requestId, run: result.runId, error: err?.message ?? String(err),
453
+ });
454
+ }
455
+ }
456
+
457
+ return {
458
+ request: result.request,
459
+ finalized: result.finalized,
460
+ decision: result.decision,
461
+ runId: result.runId,
462
+ resumed,
463
+ };
464
+ }
465
+
466
+ // ── Read API ─────────────────────────────────────────────────
467
+
569
468
  async listRequests(
570
469
  filter: {
571
470
  object?: string;
@@ -616,188 +515,6 @@ export class ApprovalService implements IApprovalService {
616
515
  return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null;
617
516
  }
618
517
 
619
- async approve(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
620
- const req = await this.getRequest(requestId, context);
621
- if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
622
- if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
623
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
624
-
625
- if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
626
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
627
- }
628
-
629
- const process = await this.loadProcessForRequest(req, context);
630
- if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
631
- const steps: any[] = process.definition?.steps ?? [];
632
- const stepIndex = req.current_step_index ?? 0;
633
- const step = steps[stepIndex];
634
- if (!step) throw new Error(`INVALID_STATE: step index ${stepIndex} out of range`);
635
-
636
- const now = this.clock.now().toISOString();
637
- // Audit row first so unanimous tally sees it.
638
- await this.engine.insert('sys_approval_action', {
639
- id: uid('aact'),
640
- request_id: req.id,
641
- organization_id: (req as any).organization_id ?? null,
642
- step_name: step.name,
643
- step_index: stepIndex,
644
- action: 'approve',
645
- actor_id: input.actorId,
646
- comment: input.comment ?? null,
647
- created_at: now,
648
- }, { context: SYSTEM_CTX });
649
-
650
- // Unanimous: only advance once every original approver has approved at this step_index.
651
- if (step.behavior === 'unanimous') {
652
- const original = await this.expandApprovers(step, req.payload, (req as any).organization_id ?? null);
653
- const acts = await this.engine.find('sys_approval_action', {
654
- where: { request_id: req.id, step_index: stepIndex, action: 'approve' },
655
- limit: 500, context: SYSTEM_CTX,
656
- });
657
- const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
658
- const stillPending = original.filter(a => !approved.has(a));
659
- if (stillPending.length > 0) {
660
- // Update pending_approvers to those who haven't voted yet.
661
- await this.engine.update('sys_approval_request', {
662
- id: req.id,
663
- pending_approvers: stillPending.join(','),
664
- updated_at: now,
665
- }, { context: SYSTEM_CTX });
666
- const fresh = await this.getRequest(req.id, context);
667
- return { request: fresh!, finalized: false };
668
- }
669
- }
670
-
671
- // Advance the request — either to next step or to finalized=approved.
672
- if (stepIndex + 1 >= steps.length) {
673
- await this.engine.update('sys_approval_request', {
674
- id: req.id,
675
- status: 'approved',
676
- pending_approvers: null,
677
- completed_at: now,
678
- updated_at: now,
679
- }, { context: SYSTEM_CTX });
680
- const fresh = await this.getRequest(req.id, context);
681
- // Phase B: step.onApprove + process.onFinalApprove + status mirror.
682
- await this.runActions((step as any)?.onApprove, 'step_approve', process, fresh!, step, input.actorId, input.comment);
683
- await this.syncStatusField(process, fresh!);
684
- await this.runActions((process.definition as any)?.onFinalApprove, 'final_approve', process, fresh!, step, input.actorId, input.comment);
685
- return { request: fresh!, finalized: true };
686
- }
687
-
688
- const nextStep = steps[stepIndex + 1];
689
- const nextApprovers = await this.expandApprovers(nextStep, req.payload, (req as any).organization_id ?? null);
690
- await this.engine.update('sys_approval_request', {
691
- id: req.id,
692
- current_step: nextStep.name,
693
- current_step_index: stepIndex + 1,
694
- pending_approvers: nextApprovers.join(','),
695
- updated_at: now,
696
- }, { context: SYSTEM_CTX });
697
- const fresh = await this.getRequest(req.id, context);
698
- // Phase B: step.onApprove fires when transitioning out of this step.
699
- await this.runActions((step as any)?.onApprove, 'step_approve', process, fresh!, step, input.actorId, input.comment);
700
- return { request: fresh!, finalized: false };
701
- }
702
-
703
- async reject(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
704
- const req = await this.getRequest(requestId, context);
705
- if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
706
- if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
707
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
708
- if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
709
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
710
- }
711
-
712
- const process = await this.loadProcessForRequest(req, context);
713
- if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
714
- const steps: any[] = process.definition?.steps ?? [];
715
- const stepIndex = req.current_step_index ?? 0;
716
- const step = steps[stepIndex];
717
-
718
- const now = this.clock.now().toISOString();
719
- await this.engine.insert('sys_approval_action', {
720
- id: uid('aact'),
721
- request_id: req.id,
722
- organization_id: (req as any).organization_id ?? null,
723
- step_name: step?.name,
724
- step_index: stepIndex,
725
- action: 'reject',
726
- actor_id: input.actorId,
727
- comment: input.comment ?? null,
728
- created_at: now,
729
- }, { context: SYSTEM_CTX });
730
-
731
- if (step?.rejectionBehavior === 'back_to_previous' && stepIndex > 0) {
732
- const prev = steps[stepIndex - 1];
733
- const prevApprovers = await this.expandApprovers(prev, req.payload, (req as any).organization_id ?? null);
734
- await this.engine.update('sys_approval_request', {
735
- id: req.id,
736
- current_step: prev.name,
737
- current_step_index: stepIndex - 1,
738
- pending_approvers: prevApprovers.join(','),
739
- updated_at: now,
740
- }, { context: SYSTEM_CTX });
741
- const fresh = await this.getRequest(req.id, context);
742
- // Phase B: step-level onReject fires on non-final rejection too.
743
- await this.runActions((step as any)?.onReject, 'step_reject', process, fresh!, step, input.actorId, input.comment);
744
- return { request: fresh!, finalized: false };
745
- }
746
-
747
- await this.engine.update('sys_approval_request', {
748
- id: req.id,
749
- status: 'rejected',
750
- pending_approvers: null,
751
- completed_at: now,
752
- updated_at: now,
753
- }, { context: SYSTEM_CTX });
754
- const fresh = await this.getRequest(req.id, context);
755
- // Phase B: step.onReject + process.onFinalReject + status mirror.
756
- await this.runActions((step as any)?.onReject, 'step_reject', process, fresh!, step, input.actorId, input.comment);
757
- await this.syncStatusField(process, fresh!);
758
- await this.runActions((process.definition as any)?.onFinalReject, 'final_reject', process, fresh!, step, input.actorId, input.comment);
759
- return { request: fresh!, finalized: true };
760
- }
761
-
762
- async recall(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult> {
763
- const req = await this.getRequest(requestId, context);
764
- if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
765
- if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`);
766
- if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
767
- if (!context.isSystem && req.submitter_id && req.submitter_id !== input.actorId) {
768
- throw new Error(`FORBIDDEN: only the submitter can recall this request`);
769
- }
770
-
771
- const now = this.clock.now().toISOString();
772
- await this.engine.insert('sys_approval_action', {
773
- id: uid('aact'),
774
- request_id: req.id,
775
- organization_id: (req as any).organization_id ?? null,
776
- step_name: req.current_step,
777
- step_index: req.current_step_index,
778
- action: 'recall',
779
- actor_id: input.actorId,
780
- comment: input.comment ?? null,
781
- created_at: now,
782
- }, { context: SYSTEM_CTX });
783
-
784
- await this.engine.update('sys_approval_request', {
785
- id: req.id,
786
- status: 'recalled',
787
- pending_approvers: null,
788
- completed_at: now,
789
- updated_at: now,
790
- }, { context: SYSTEM_CTX });
791
- const fresh = await this.getRequest(req.id, context);
792
- // Phase B: process.onRecall + status mirror.
793
- const process = await this.loadProcessForRequest(req, context);
794
- if (process) {
795
- await this.syncStatusField(process, fresh!);
796
- await this.runActions((process.definition as any)?.onRecall, 'recall', process, fresh!, undefined, input.actorId, input.comment);
797
- }
798
- return { request: fresh!, finalized: true };
799
- }
800
-
801
518
  async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
802
519
  if (!requestId) return [];
803
520
  // Tenant gate: ensure the caller can see the parent request before