@objectstack/plugin-approvals 5.1.0 → 6.0.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.
@@ -13,6 +13,7 @@ import type {
13
13
  SubmitApprovalInput,
14
14
  SharingExecutionContext,
15
15
  } from '@objectstack/spec/contracts';
16
+ import type { MetadataRepository } from '@objectstack/metadata-core';
16
17
  import { executeActions, type ApprovalTrigger, type FetchLike } from './action-executor.js';
17
18
 
18
19
  /**
@@ -69,6 +70,7 @@ function rowFromRequest(row: any): ApprovalRequestRow {
69
70
  id: String(row.id),
70
71
  organization_id: row.organization_id ?? undefined,
71
72
  process_name: String(row.process_name ?? ''),
73
+ process_hash: row.process_hash ?? undefined,
72
74
  object_name: String(row.object_name ?? ''),
73
75
  record_id: String(row.record_id ?? ''),
74
76
  submitter_id: row.submitter_id ?? undefined,
@@ -114,6 +116,19 @@ export interface ApprovalServiceOptions {
114
116
  * The plugin uses this to re-bind lifecycle hooks for auto-trigger / lock.
115
117
  */
116
118
  onRegistryChange?: () => void | Promise<void>;
119
+ /**
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).
130
+ */
131
+ metadataRepo?: MetadataRepository;
117
132
  }
118
133
 
119
134
  export class ApprovalService implements IApprovalService {
@@ -123,6 +138,7 @@ export class ApprovalService implements IApprovalService {
123
138
  private readonly fetchImpl?: FetchLike;
124
139
  private readonly webhookTimeoutMs?: number;
125
140
  private readonly onRegistryChange?: () => void | Promise<void>;
141
+ private readonly metadataRepo?: MetadataRepository;
126
142
 
127
143
  constructor(opts: ApprovalServiceOptions) {
128
144
  this.engine = opts.engine;
@@ -131,6 +147,7 @@ export class ApprovalService implements IApprovalService {
131
147
  this.fetchImpl = opts.fetch;
132
148
  this.webhookTimeoutMs = opts.webhookTimeoutMs;
133
149
  this.onRegistryChange = opts.onRegistryChange;
150
+ this.metadataRepo = opts.metadataRepo;
134
151
  }
135
152
 
136
153
  /** Allow the plugin to attach a hook re-binding callback after construction. */
@@ -272,6 +289,72 @@ export class ApprovalService implements IApprovalService {
272
289
  catch (err: any) { this.logger?.warn?.('[approvals] onRegistryChange handler failed', { error: err?.message }); }
273
290
  }
274
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 };
302
+ try {
303
+ const head = await this.metadataRepo.get(orgRef);
304
+ return head?.hash ?? null;
305
+ } catch (err: any) {
306
+ this.logger?.debug?.('[approvals] metadataRepo.get failed', { name: processName, error: err?.message });
307
+ return null;
308
+ }
309
+ }
310
+
311
+ /**
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)`.
320
+ */
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
+
275
358
  /** Mirror request status onto `process.approvalStatusField` if configured. */
276
359
  private async syncStatusField(process: ApprovalProcessRow, request: ApprovalRequestRow): Promise<void> {
277
360
  const field = (process.definition as any)?.approvalStatusField;
@@ -432,9 +515,11 @@ export class ApprovalService implements IApprovalService {
432
515
 
433
516
  const now = this.clock.now().toISOString();
434
517
  const id = uid('areq');
518
+ const processHash = await this.resolveProcessHash(process.name, ctxOrg);
435
519
  const row: any = {
436
520
  id,
437
521
  process_name: process.name,
522
+ process_hash: processHash,
438
523
  object_name: input.object,
439
524
  record_id: input.recordId,
440
525
  submitter_id: input.submitterId ?? context.userId ?? null,
@@ -541,7 +626,7 @@ export class ApprovalService implements IApprovalService {
541
626
  throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
542
627
  }
543
628
 
544
- const process = await this.getProcess(req.process_name, context);
629
+ const process = await this.loadProcessForRequest(req, context);
545
630
  if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
546
631
  const steps: any[] = process.definition?.steps ?? [];
547
632
  const stepIndex = req.current_step_index ?? 0;
@@ -624,7 +709,7 @@ export class ApprovalService implements IApprovalService {
624
709
  throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
625
710
  }
626
711
 
627
- const process = await this.getProcess(req.process_name, context);
712
+ const process = await this.loadProcessForRequest(req, context);
628
713
  if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
629
714
  const steps: any[] = process.definition?.steps ?? [];
630
715
  const stepIndex = req.current_step_index ?? 0;
@@ -705,7 +790,7 @@ export class ApprovalService implements IApprovalService {
705
790
  }, { context: SYSTEM_CTX });
706
791
  const fresh = await this.getRequest(req.id, context);
707
792
  // Phase B: process.onRecall + status mirror.
708
- const process = await this.getProcess(req.process_name, context);
793
+ const process = await this.loadProcessForRequest(req, context);
709
794
  if (process) {
710
795
  await this.syncStatusField(process, fresh!);
711
796
  await this.runActions((process.definition as any)?.onRecall, 'recall', process, fresh!, undefined, input.actorId, input.comment);
@@ -67,11 +67,25 @@ export class ApprovalsServicePlugin implements Plugin {
67
67
  this.engine = engine;
68
68
  this.logger = ctx.logger;
69
69
 
70
+ // ADR-0009: try to wire the metadata repository for execution pinning.
71
+ // The approvals service degrades to the projection-table path if no
72
+ // metadata service is registered (e.g. in tests or minimal setups).
73
+ let metadataRepo: any;
74
+ try {
75
+ const meta = ctx.getService<any>('metadata');
76
+ metadataRepo = meta?.getRepository?.();
77
+ } catch { /* metadata plugin not loaded — fall back */ }
78
+
70
79
  this.service = new ApprovalService({
71
80
  engine: engine as ApprovalEngine,
72
81
  logger: ctx.logger,
82
+ metadataRepo,
73
83
  });
74
84
 
85
+ if (metadataRepo) {
86
+ ctx.logger.info('ApprovalsServicePlugin: execution pinning enabled (ADR-0009)');
87
+ }
88
+
75
89
  if (!this.options.disableAutoHooks) {
76
90
  // Re-bind hooks on every registry mutation.
77
91
  this.service.setRegistryChangeHandler(() => this.rebindHooks());