hierarchical-approval 2.0.0 → 2.2.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 (49) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +60 -0
  3. package/dist/{ApprovalEngine-B8bPZq01.d.ts → ApprovalEngine-B3ChZxS7.d.ts} +109 -8
  4. package/dist/{ApprovalEngine-C7p9cOA3.d.cts → ApprovalEngine-CbFBAMlO.d.cts} +109 -8
  5. package/dist/{IAuditAdapter-UJNdJanq.d.cts → IAuditAdapter-BHVKMMv6.d.cts} +1 -1
  6. package/dist/{IAuditAdapter-DDV4Rf9F.d.ts → IAuditAdapter-C4OASS6u.d.ts} +1 -1
  7. package/dist/{IAuthorizationPolicy-CFJ-gXKl.d.ts → IAuthorizationPolicy-BA_m2Dg9.d.ts} +1 -1
  8. package/dist/{IAuthorizationPolicy-CN6LAaKg.d.cts → IAuthorizationPolicy-CtLkwQ8I.d.cts} +1 -1
  9. package/dist/{INotificationAdapter-Dv_MuYwS.d.ts → INotificationAdapter-BdgfUrIn.d.ts} +12 -2
  10. package/dist/{INotificationAdapter-Dy2d0JKy.d.cts → INotificationAdapter-JJAiMyMd.d.cts} +12 -2
  11. package/dist/{IOperationMiddleware-DWYXVuJD.d.ts → IOperationMiddleware-BGYrAqep.d.ts} +1 -1
  12. package/dist/{IOperationMiddleware-DGG-guxK.d.cts → IOperationMiddleware-D0ksiFM3.d.cts} +1 -1
  13. package/dist/{IStorageAdapter-Bgd6p8XL.d.cts → IStorageAdapter-Bk7ybd3z.d.cts} +1 -1
  14. package/dist/{IStorageAdapter-B8aRYeGI.d.ts → IStorageAdapter-DjRvHUF0.d.ts} +1 -1
  15. package/dist/adapters/MemoryAdapter.d.cts +2 -2
  16. package/dist/adapters/MemoryAdapter.d.ts +2 -2
  17. package/dist/adapters/PostgresAdapter.d.cts +2 -2
  18. package/dist/adapters/PostgresAdapter.d.ts +2 -2
  19. package/dist/index.cjs +385 -13
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +8 -8
  22. package/dist/index.d.ts +8 -8
  23. package/dist/index.js +385 -14
  24. package/dist/index.js.map +1 -1
  25. package/dist/{instance-BE0uJmg3.d.cts → instance-DUJY_Axf.d.cts} +41 -3
  26. package/dist/{instance-BE0uJmg3.d.ts → instance-DUJY_Axf.d.ts} +41 -3
  27. package/dist/nestjs.cjs +384 -13
  28. package/dist/nestjs.cjs.map +1 -1
  29. package/dist/nestjs.d.cts +7 -7
  30. package/dist/nestjs.d.ts +7 -7
  31. package/dist/nestjs.js +384 -13
  32. package/dist/nestjs.js.map +1 -1
  33. package/dist/plugins/audit.d.cts +2 -2
  34. package/dist/plugins/audit.d.ts +2 -2
  35. package/dist/plugins/notify.d.cts +2 -2
  36. package/dist/plugins/notify.d.ts +2 -2
  37. package/dist/plugins/resilience.d.cts +3 -3
  38. package/dist/plugins/resilience.d.ts +3 -3
  39. package/dist/plugins/tracing.d.cts +2 -2
  40. package/dist/plugins/tracing.d.ts +2 -2
  41. package/dist/plugins/webhook.d.cts +2 -2
  42. package/dist/plugins/webhook.d.ts +2 -2
  43. package/dist/testing.cjs +384 -13
  44. package/dist/testing.cjs.map +1 -1
  45. package/dist/testing.d.cts +7 -7
  46. package/dist/testing.d.ts +7 -7
  47. package/dist/testing.js +384 -13
  48. package/dist/testing.js.map +1 -1
  49. package/package.json +1 -1
@@ -43,6 +43,21 @@ interface ApprovalLevelConfig {
43
43
  * own group of one.
44
44
  */
45
45
  group?: string;
46
+ /**
47
+ * Delegate this level to a whole separate approval, rather than to a list of
48
+ * approvers.
49
+ *
50
+ * When the level opens, a child instance is submitted against
51
+ * {@link SubWorkflowConfig.templateName}. The parent level stays open until
52
+ * that child finishes, then takes its outcome: approved advances the parent,
53
+ * rejected rejects it. Models "a capital request over 1M needs its own board
54
+ * approval before this purchase order can proceed" without flattening the
55
+ * board's chain into the purchase order's.
56
+ *
57
+ * A level with `subWorkflow` needs no `approvers` — nobody approves it
58
+ * directly; the child does.
59
+ */
60
+ subWorkflow?: SubWorkflowConfig;
46
61
  escalationAfterDays?: number;
47
62
  /**
48
63
  * Send a reminder to this level's pending approvers this many days after the
@@ -103,6 +118,18 @@ type ConditionGroup = {
103
118
  * explicit combinator.
104
119
  */
105
120
  type ConditionExpression = Condition | ConditionExpression[] | ConditionGroup;
121
+ /** How a level hands off to a child approval. See {@link ApprovalLevelConfig.subWorkflow}. */
122
+ interface SubWorkflowConfig {
123
+ /** Template the child instance is submitted against. */
124
+ templateName: string;
125
+ /**
126
+ * Copy the parent's document data into the child (default `true`), so the
127
+ * child's own conditions can be evaluated against the same document.
128
+ */
129
+ carryData?: boolean;
130
+ /** Document type for the child; defaults to the child template's own. */
131
+ documentType?: string;
132
+ }
106
133
  interface ConditionRule {
107
134
  /**
108
135
  * The test that decides whether this rule's mutations apply. An array means
@@ -154,7 +181,7 @@ interface ApprovalTemplate extends ApprovalTemplateConfig {
154
181
 
155
182
  type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'cancelled' | 'expired';
156
183
  type LevelStatus = 'waiting' | 'pending' | 'approved' | 'rejected' | 'skipped';
157
- type AuditAction = 'submitted' | 'approved' | 'rejected' | 'delegated' | 'reassigned' | 'escalated' | 'cancelled' | 'level_advanced' | 'commented' | 'resubmitted' | 'overridden' | 'data_updated' | 'reminded' | 'info_requested' | 'info_provided' | 'attachment_added' | 'attachment_removed' | 'expired';
184
+ type AuditAction = 'submitted' | 'approved' | 'rejected' | 'delegated' | 'reassigned' | 'escalated' | 'cancelled' | 'level_advanced' | 'commented' | 'resubmitted' | 'overridden' | 'data_updated' | 'reminded' | 'info_requested' | 'info_provided' | 'attachment_added' | 'attachment_removed' | 'subworkflow_started' | 'subworkflow_completed' | 'expired';
158
185
  interface AuditEntry {
159
186
  action: AuditAction;
160
187
  actorId: string;
@@ -182,6 +209,10 @@ interface ApprovalLevelInstance {
182
209
  name: string;
183
210
  /** Parallel branch group this level belongs to; absent for sequential levels. */
184
211
  group?: string;
212
+ /** Id of the child instance this level is waiting on, when it delegates to a sub-workflow. */
213
+ childInstanceId?: string;
214
+ /** Template the child was submitted against; kept for display and diagnostics. */
215
+ subWorkflowTemplate?: string;
185
216
  mode: ApprovalMode;
186
217
  approverConfigs: ApproverConfig[];
187
218
  approverIds: string[];
@@ -272,8 +303,15 @@ interface ApprovalInstance {
272
303
  updatedAt: Date;
273
304
  /** Snapshot of template config at submit time — prevents template changes from affecting in-flight instances. */
274
305
  templateSnapshot?: TemplateSnapshot;
275
- /** ID of the rejected instance this was resubmitted from. */
306
+ /**
307
+ * ID of the parent instance — set both when this was resubmitted from a
308
+ * rejected instance and when it is a sub-workflow child.
309
+ */
276
310
  parentInstanceId?: string;
311
+ /** Parent level this instance was spawned for, when it is a sub-workflow child. */
312
+ parentLevel?: number;
313
+ /** How many sub-workflow hops deep this instance sits. 0 for a top-level approval. */
314
+ subWorkflowDepth?: number;
277
315
  /** Auto-cancel or auto-reject if not resolved by this time. */
278
316
  expiresAt?: Date;
279
317
  /** What happens when expiresAt is reached (default: 'cancel'). */
@@ -291,4 +329,4 @@ interface ApprovalInstance {
291
329
  attachments?: Attachment[];
292
330
  }
293
331
 
294
- export type { ApprovalTemplate as A, Condition as C, EscalationConfig as E, InfoRequest as I, LevelStatus as L, ResolvedApprover as R, TemplateSnapshot as T, ApprovalInstance as a, AuditEntry as b, ApprovalLevelConfig as c, ApprovalLevelInstance as d, ApprovalMode as e, ApprovalStatus as f, ApprovalTemplateConfig as g, ApproverConfig as h, Attachment as i, AuditAction as j, AuditContext as k, ConditionExpression as l, ConditionGroup as m, ConditionOperator as n, ConditionRule as o, ResolverFn as p };
332
+ export type { ApprovalTemplate as A, Condition as C, EscalationConfig as E, InfoRequest as I, LevelStatus as L, ResolvedApprover as R, SubWorkflowConfig as S, TemplateSnapshot as T, ApprovalInstance as a, AuditEntry as b, ApprovalLevelConfig as c, ApprovalLevelInstance as d, ApprovalMode as e, ApprovalStatus as f, ApprovalTemplateConfig as g, ApproverConfig as h, Attachment as i, AuditAction as j, AuditContext as k, ConditionExpression as l, ConditionGroup as m, ConditionOperator as n, ConditionRule as o, ResolverFn as p };
@@ -43,6 +43,21 @@ interface ApprovalLevelConfig {
43
43
  * own group of one.
44
44
  */
45
45
  group?: string;
46
+ /**
47
+ * Delegate this level to a whole separate approval, rather than to a list of
48
+ * approvers.
49
+ *
50
+ * When the level opens, a child instance is submitted against
51
+ * {@link SubWorkflowConfig.templateName}. The parent level stays open until
52
+ * that child finishes, then takes its outcome: approved advances the parent,
53
+ * rejected rejects it. Models "a capital request over 1M needs its own board
54
+ * approval before this purchase order can proceed" without flattening the
55
+ * board's chain into the purchase order's.
56
+ *
57
+ * A level with `subWorkflow` needs no `approvers` — nobody approves it
58
+ * directly; the child does.
59
+ */
60
+ subWorkflow?: SubWorkflowConfig;
46
61
  escalationAfterDays?: number;
47
62
  /**
48
63
  * Send a reminder to this level's pending approvers this many days after the
@@ -103,6 +118,18 @@ type ConditionGroup = {
103
118
  * explicit combinator.
104
119
  */
105
120
  type ConditionExpression = Condition | ConditionExpression[] | ConditionGroup;
121
+ /** How a level hands off to a child approval. See {@link ApprovalLevelConfig.subWorkflow}. */
122
+ interface SubWorkflowConfig {
123
+ /** Template the child instance is submitted against. */
124
+ templateName: string;
125
+ /**
126
+ * Copy the parent's document data into the child (default `true`), so the
127
+ * child's own conditions can be evaluated against the same document.
128
+ */
129
+ carryData?: boolean;
130
+ /** Document type for the child; defaults to the child template's own. */
131
+ documentType?: string;
132
+ }
106
133
  interface ConditionRule {
107
134
  /**
108
135
  * The test that decides whether this rule's mutations apply. An array means
@@ -154,7 +181,7 @@ interface ApprovalTemplate extends ApprovalTemplateConfig {
154
181
 
155
182
  type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'cancelled' | 'expired';
156
183
  type LevelStatus = 'waiting' | 'pending' | 'approved' | 'rejected' | 'skipped';
157
- type AuditAction = 'submitted' | 'approved' | 'rejected' | 'delegated' | 'reassigned' | 'escalated' | 'cancelled' | 'level_advanced' | 'commented' | 'resubmitted' | 'overridden' | 'data_updated' | 'reminded' | 'info_requested' | 'info_provided' | 'attachment_added' | 'attachment_removed' | 'expired';
184
+ type AuditAction = 'submitted' | 'approved' | 'rejected' | 'delegated' | 'reassigned' | 'escalated' | 'cancelled' | 'level_advanced' | 'commented' | 'resubmitted' | 'overridden' | 'data_updated' | 'reminded' | 'info_requested' | 'info_provided' | 'attachment_added' | 'attachment_removed' | 'subworkflow_started' | 'subworkflow_completed' | 'expired';
158
185
  interface AuditEntry {
159
186
  action: AuditAction;
160
187
  actorId: string;
@@ -182,6 +209,10 @@ interface ApprovalLevelInstance {
182
209
  name: string;
183
210
  /** Parallel branch group this level belongs to; absent for sequential levels. */
184
211
  group?: string;
212
+ /** Id of the child instance this level is waiting on, when it delegates to a sub-workflow. */
213
+ childInstanceId?: string;
214
+ /** Template the child was submitted against; kept for display and diagnostics. */
215
+ subWorkflowTemplate?: string;
185
216
  mode: ApprovalMode;
186
217
  approverConfigs: ApproverConfig[];
187
218
  approverIds: string[];
@@ -272,8 +303,15 @@ interface ApprovalInstance {
272
303
  updatedAt: Date;
273
304
  /** Snapshot of template config at submit time — prevents template changes from affecting in-flight instances. */
274
305
  templateSnapshot?: TemplateSnapshot;
275
- /** ID of the rejected instance this was resubmitted from. */
306
+ /**
307
+ * ID of the parent instance — set both when this was resubmitted from a
308
+ * rejected instance and when it is a sub-workflow child.
309
+ */
276
310
  parentInstanceId?: string;
311
+ /** Parent level this instance was spawned for, when it is a sub-workflow child. */
312
+ parentLevel?: number;
313
+ /** How many sub-workflow hops deep this instance sits. 0 for a top-level approval. */
314
+ subWorkflowDepth?: number;
277
315
  /** Auto-cancel or auto-reject if not resolved by this time. */
278
316
  expiresAt?: Date;
279
317
  /** What happens when expiresAt is reached (default: 'cancel'). */
@@ -291,4 +329,4 @@ interface ApprovalInstance {
291
329
  attachments?: Attachment[];
292
330
  }
293
331
 
294
- export type { ApprovalTemplate as A, Condition as C, EscalationConfig as E, InfoRequest as I, LevelStatus as L, ResolvedApprover as R, TemplateSnapshot as T, ApprovalInstance as a, AuditEntry as b, ApprovalLevelConfig as c, ApprovalLevelInstance as d, ApprovalMode as e, ApprovalStatus as f, ApprovalTemplateConfig as g, ApproverConfig as h, Attachment as i, AuditAction as j, AuditContext as k, ConditionExpression as l, ConditionGroup as m, ConditionOperator as n, ConditionRule as o, ResolverFn as p };
332
+ export type { ApprovalTemplate as A, Condition as C, EscalationConfig as E, InfoRequest as I, LevelStatus as L, ResolvedApprover as R, SubWorkflowConfig as S, TemplateSnapshot as T, ApprovalInstance as a, AuditEntry as b, ApprovalLevelConfig as c, ApprovalLevelInstance as d, ApprovalMode as e, ApprovalStatus as f, ApprovalTemplateConfig as g, ApproverConfig as h, Attachment as i, AuditAction as j, AuditContext as k, ConditionExpression as l, ConditionGroup as m, ConditionOperator as n, ConditionRule as o, ResolverFn as p };
package/dist/nestjs.cjs CHANGED
@@ -915,6 +915,7 @@ function computeTimingStats(samples) {
915
915
  }
916
916
 
917
917
  // src/engine/ApprovalEngine.ts
918
+ var MAX_SUBWORKFLOW_DEPTH = 5;
918
919
  var DEFAULT_MAX_REMINDERS = 3;
919
920
  var DEFAULT_MAX_ATTEMPTS = 3;
920
921
  var DEFAULT_BASE_DELAY_MS = 50;
@@ -926,6 +927,7 @@ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
926
927
  ]);
927
928
  var CYCLE_TIME_STATUSES = ["approved", "rejected", "cancelled"];
928
929
  var CYCLE_TIME_FETCH_BATCH_SIZE = 500;
930
+ var TEMPLATE_BUNDLE_VERSION = 1;
929
931
  var ApprovalEngine = class _ApprovalEngine {
930
932
  constructor(opts) {
931
933
  this.opts = opts;
@@ -1019,12 +1021,32 @@ var ApprovalEngine = class _ApprovalEngine {
1019
1021
  });
1020
1022
  }
1021
1023
  levelNums.add(l.level);
1022
- if (!l.approvers || l.approvers.length === 0) {
1024
+ if (!l.subWorkflow && (!l.approvers || l.approvers.length === 0)) {
1023
1025
  errors.push({
1024
1026
  field: `levels[${i}].approvers`,
1025
1027
  message: `Level ${l.level} must have at least one approver.`
1026
1028
  });
1027
1029
  }
1030
+ if (l.subWorkflow) {
1031
+ if (!l.subWorkflow.templateName) {
1032
+ errors.push({
1033
+ field: `levels[${i}].subWorkflow.templateName`,
1034
+ message: `Level ${l.level} declares a subWorkflow without a templateName.`
1035
+ });
1036
+ }
1037
+ if (l.subWorkflow.templateName === config.name) {
1038
+ errors.push({
1039
+ field: `levels[${i}].subWorkflow.templateName`,
1040
+ message: `Level ${l.level} would spawn a sub-workflow of its own template ("${config.name}"), which cannot terminate.`
1041
+ });
1042
+ }
1043
+ if (l.approvers && l.approvers.length > 0) {
1044
+ errors.push({
1045
+ field: `levels[${i}].approvers`,
1046
+ message: `Level ${l.level} sets both approvers and subWorkflow; a sub-workflow level is decided by its child approval, so its approvers would never be asked.`
1047
+ });
1048
+ }
1049
+ }
1028
1050
  if (l.reminderAfterDays !== void 0 && l.reminderAfterDays <= 0) {
1029
1051
  errors.push({
1030
1052
  field: `levels[${i}].reminderAfterDays`,
@@ -1167,7 +1189,7 @@ var ApprovalEngine = class _ApprovalEngine {
1167
1189
  return this.registry.list();
1168
1190
  }
1169
1191
  // ─── Lifecycle ────────────────────────────────────────────────────────────
1170
- async submit(raw, auditCtx) {
1192
+ async submit(raw, auditCtx, link) {
1171
1193
  const opts = parseOrThrow(() => SubmitOptionsSchema.parse(raw));
1172
1194
  const startMs = this.clock.now().getTime();
1173
1195
  const template = await this.registry.get(opts.templateName);
@@ -1227,6 +1249,7 @@ var ApprovalEngine = class _ApprovalEngine {
1227
1249
  weights: cfg.weights,
1228
1250
  escalationAfterDays: cfg.escalationAfterDays,
1229
1251
  escalationDueAt: inFirstGroup && cfg.escalationAfterDays ? this.deadlineFrom(now, cfg.escalationAfterDays) : void 0,
1252
+ subWorkflowTemplate: cfg.subWorkflow?.templateName,
1230
1253
  reminderAfterDays: cfg.reminderAfterDays,
1231
1254
  reminderEveryDays: cfg.reminderEveryDays,
1232
1255
  maxReminders: cfg.maxReminders,
@@ -1235,6 +1258,10 @@ var ApprovalEngine = class _ApprovalEngine {
1235
1258
  };
1236
1259
  });
1237
1260
  for (const lvl of levels.filter((l) => l.status === "pending")) {
1261
+ if (lvl.subWorkflowTemplate) {
1262
+ lvl.approverIds = [];
1263
+ continue;
1264
+ }
1238
1265
  lvl.approverIds = await this.resolver.resolveApprovers(
1239
1266
  lvl.approverConfigs,
1240
1267
  opts.submittedBy,
@@ -1255,6 +1282,9 @@ var ApprovalEngine = class _ApprovalEngine {
1255
1282
  const instance = {
1256
1283
  id: instanceId,
1257
1284
  tenantId: this.tenantId,
1285
+ parentInstanceId: link?.parentInstanceId,
1286
+ parentLevel: link?.parentLevel,
1287
+ subWorkflowDepth: link?.depth,
1258
1288
  templateId: template.id,
1259
1289
  templateName: template.name,
1260
1290
  documentId: opts.documentId,
@@ -1319,12 +1349,13 @@ var ApprovalEngine = class _ApprovalEngine {
1319
1349
  { operation: "submit", actorId: opts.submittedBy, tenantId: this.tenantId, input: opts },
1320
1350
  instance
1321
1351
  );
1352
+ await this.startSubWorkflows(instance);
1322
1353
  return instance;
1323
1354
  }
1324
1355
  async approve(instanceId, raw, auditCtx) {
1325
1356
  const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
1326
1357
  const startMs = this.clock.now().getTime();
1327
- return this.withOptimisticRetry(instanceId, async (instance) => {
1358
+ const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
1328
1359
  assertStatus(instance, "pending");
1329
1360
  if (opts.approverId === instance.submittedBy) {
1330
1361
  throw new ApprovalForbiddenError(
@@ -1537,10 +1568,12 @@ var ApprovalEngine = class _ApprovalEngine {
1537
1568
  }
1538
1569
  return instance;
1539
1570
  });
1571
+ await this.afterDecision(decided);
1572
+ return decided;
1540
1573
  }
1541
1574
  async reject(instanceId, raw, auditCtx) {
1542
1575
  const opts = parseOrThrow(() => RejectOptionsSchema.parse(raw));
1543
- return this.withOptimisticRetry(instanceId, async (instance) => {
1576
+ const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
1544
1577
  assertStatus(instance, "pending");
1545
1578
  if (opts.approverId === instance.submittedBy) {
1546
1579
  throw new ApprovalForbiddenError(
@@ -1659,6 +1692,8 @@ var ApprovalEngine = class _ApprovalEngine {
1659
1692
  }
1660
1693
  return instance;
1661
1694
  });
1695
+ await this.afterDecision(decided);
1696
+ return decided;
1662
1697
  }
1663
1698
  async delegate(instanceId, raw, auditCtx) {
1664
1699
  const opts = parseOrThrow(() => DelegateOptionsSchema.parse(raw));
@@ -1836,7 +1871,8 @@ var ApprovalEngine = class _ApprovalEngine {
1836
1871
  }
1837
1872
  async cancel(instanceId, raw, auditCtx) {
1838
1873
  const opts = parseOrThrow(() => CancelOptionsSchema.parse(raw));
1839
- return this.withOptimisticRetry(instanceId, async (instance) => {
1874
+ let cancelled = null;
1875
+ const result = await this.withOptimisticRetry(instanceId, async (instance) => {
1840
1876
  if (instance.status === "approved" || instance.status === "rejected") {
1841
1877
  throw new ApprovalError(`Cannot cancel a "${instance.status}" approval.`, "CANNOT_CANCEL");
1842
1878
  }
@@ -1890,8 +1926,11 @@ var ApprovalEngine = class _ApprovalEngine {
1890
1926
  },
1891
1927
  instance
1892
1928
  );
1929
+ cancelled = instance;
1893
1930
  return instance;
1894
1931
  });
1932
+ if (cancelled) await this.propagateToParent(cancelled);
1933
+ return result;
1895
1934
  }
1896
1935
  async escalate(instanceId, raw, auditCtx) {
1897
1936
  parseOrThrow(() => EscalateOptionsSchema.parse(raw));
@@ -2970,6 +3009,118 @@ var ApprovalEngine = class _ApprovalEngine {
2970
3009
  oldestAgeMs: Number.isFinite(row.oldest) ? now.getTime() - row.oldest : 0
2971
3010
  })).sort((a, b) => b.pending - a.pending || a.approverId.localeCompare(b.approverId));
2972
3011
  }
3012
+ /**
3013
+ * Export templates as a portable bundle.
3014
+ *
3015
+ * Approval configuration is written once and then has to travel — authored in
3016
+ * a sandbox, reviewed, promoted to production. Reading `listTemplates()` and
3017
+ * re-posting the rows carried each environment's own `id`, `tenantId` and
3018
+ * version lineage with it, which either collided on arrival or silently
3019
+ * claimed a history the target never had. This strips all of it.
3020
+ *
3021
+ * @param names - Templates to include; omit for all of them.
3022
+ */
3023
+ async exportTemplates(names) {
3024
+ const all = await this.registry.list();
3025
+ const wanted = names ? all.filter((t) => names.includes(t.name)) : all;
3026
+ if (names) {
3027
+ const missing = names.filter((n) => !all.some((t) => t.name === n));
3028
+ if (missing.length > 0) {
3029
+ throw new ApprovalTemplateNotFoundError(missing.join(", "));
3030
+ }
3031
+ }
3032
+ return {
3033
+ bundleVersion: TEMPLATE_BUNDLE_VERSION,
3034
+ exportedAt: this.clock.now(),
3035
+ templates: wanted.map((t) => {
3036
+ const {
3037
+ id: _id,
3038
+ tenantId: _tenantId,
3039
+ createdAt: _createdAt,
3040
+ version: _version,
3041
+ previousVersionId: _previousVersionId,
3042
+ ...config
3043
+ } = t;
3044
+ return config;
3045
+ })
3046
+ };
3047
+ }
3048
+ /**
3049
+ * Import a bundle produced by {@link exportTemplates}.
3050
+ *
3051
+ * **Every template is validated before any is written.** A bundle that is
3052
+ * half-applied is worse than one rejected outright: the tenant is left in a
3053
+ * state matching neither environment, and the operator has no way to tell
3054
+ * which half landed. Per-template failures during the write phase are still
3055
+ * reported individually, since a storage error can occur after validation
3056
+ * passes.
3057
+ *
3058
+ * @param bundle - The bundle to apply.
3059
+ * @param opts - `mode: 'create'` (default) refuses to touch existing
3060
+ * templates; `'upsert'` updates them. `dryRun` reports without writing.
3061
+ */
3062
+ async importTemplates(bundle, opts = {}) {
3063
+ const mode = opts.mode ?? "create";
3064
+ const dryRun = opts.dryRun ?? false;
3065
+ if (bundle.bundleVersion !== TEMPLATE_BUNDLE_VERSION) {
3066
+ throw new ApprovalValidationError(
3067
+ `Unsupported template bundle version ${bundle.bundleVersion}; this engine reads version ${TEMPLATE_BUNDLE_VERSION}.`
3068
+ );
3069
+ }
3070
+ if (!Array.isArray(bundle.templates) || bundle.templates.length === 0) {
3071
+ throw new ApprovalValidationError("Template bundle contains no templates.");
3072
+ }
3073
+ const duplicates = bundle.templates.map((t) => t.name).filter((name, i, all) => all.indexOf(name) !== i);
3074
+ if (duplicates.length > 0) {
3075
+ throw new ApprovalValidationError(
3076
+ `Template bundle contains duplicate names: ${[...new Set(duplicates)].join(", ")}.`
3077
+ );
3078
+ }
3079
+ const invalid = [];
3080
+ for (const config of bundle.templates) {
3081
+ const result2 = this.validateTemplate(config);
3082
+ if (!result2.valid) {
3083
+ invalid.push({
3084
+ name: config.name,
3085
+ message: result2.errors[0]?.message ?? "unknown validation error"
3086
+ });
3087
+ }
3088
+ }
3089
+ if (invalid.length > 0) {
3090
+ throw new ApprovalValidationError(
3091
+ `Template bundle failed validation and was not applied: ${invalid.map((e) => `${e.name}: ${e.message}`).join("; ")}`
3092
+ );
3093
+ }
3094
+ const result = { created: [], updated: [], skipped: [], errors: [], dryRun };
3095
+ for (const config of bundle.templates) {
3096
+ const existing = await this.opts.adapter.getTemplate(this.tenantId, config.name);
3097
+ try {
3098
+ if (existing && mode === "create") {
3099
+ result.skipped.push(config.name);
3100
+ continue;
3101
+ }
3102
+ if (existing) {
3103
+ if (!dryRun) await this.registry.update(config);
3104
+ result.updated.push(config.name);
3105
+ } else {
3106
+ if (!dryRun) await this.registry.define(config);
3107
+ result.created.push(config.name);
3108
+ }
3109
+ } catch (err) {
3110
+ result.errors.push({ name: config.name, message: err.message });
3111
+ }
3112
+ }
3113
+ this.logger.info("importTemplates: bundle applied", {
3114
+ tenantId: this.tenantId,
3115
+ mode,
3116
+ dryRun,
3117
+ created: result.created.length,
3118
+ updated: result.updated.length,
3119
+ skipped: result.skipped.length,
3120
+ errors: result.errors.length
3121
+ });
3122
+ return result;
3123
+ }
2973
3124
  async getStatistics(filter = {}) {
2974
3125
  const statuses = [
2975
3126
  "pending",
@@ -3408,14 +3559,18 @@ var ApprovalEngine = class _ApprovalEngine {
3408
3559
  /** Resolve approvers for a group, set its deadlines, and mark it pending. */
3409
3560
  async activateGroup(instance, group, now) {
3410
3561
  for (const lvl of group) {
3411
- lvl.approverIds = await this.resolver.resolveApprovers(
3412
- lvl.approverConfigs,
3413
- instance.submittedBy,
3414
- instance.data,
3415
- this.opts.orgProvider,
3416
- this.opts.outOfOfficeProvider,
3417
- now
3418
- );
3562
+ if (lvl.subWorkflowTemplate) {
3563
+ lvl.approverIds = [];
3564
+ } else {
3565
+ lvl.approverIds = await this.resolver.resolveApprovers(
3566
+ lvl.approverConfigs,
3567
+ instance.submittedBy,
3568
+ instance.data,
3569
+ this.opts.orgProvider,
3570
+ this.opts.outOfOfficeProvider,
3571
+ now
3572
+ );
3573
+ }
3419
3574
  if (lvl.escalationAfterDays) {
3420
3575
  lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);
3421
3576
  }
@@ -3502,6 +3657,222 @@ var ApprovalEngine = class _ApprovalEngine {
3502
3657
  this.logger.error("reminder: failed to send", err, { tenantId: this.tenantId, instanceId });
3503
3658
  }
3504
3659
  }
3660
+ /**
3661
+ * Start child approvals for any open level that delegates to a sub-workflow.
3662
+ *
3663
+ * Run after the parent has been persisted, never inside the same optimistic
3664
+ * write: the child's own `submit()` performs its own reads and writes, and
3665
+ * nesting them under the parent's compare-and-set would make a slow child
3666
+ * template a source of spurious version conflicts on the parent.
3667
+ */
3668
+ async startSubWorkflows(instance) {
3669
+ const pendingSubs = instance.levels.filter(
3670
+ (l) => l.status === "pending" && l.subWorkflowTemplate && !l.childInstanceId
3671
+ );
3672
+ if (pendingSubs.length === 0) return;
3673
+ const depth = (instance.subWorkflowDepth ?? 0) + 1;
3674
+ if (depth > MAX_SUBWORKFLOW_DEPTH) {
3675
+ throw new ApprovalValidationError(
3676
+ `Sub-workflow nesting exceeded ${MAX_SUBWORKFLOW_DEPTH} levels at template "${instance.templateName}". Check for a template that reaches itself.`
3677
+ );
3678
+ }
3679
+ for (const level of pendingSubs) {
3680
+ const templateName = level.subWorkflowTemplate;
3681
+ const childTemplate = await this.registry.get(templateName);
3682
+ const child = await this.submit(
3683
+ {
3684
+ templateName,
3685
+ // Unique per parent level, so a resubmitted parent does not collide
3686
+ // with the child it spawned last time.
3687
+ documentId: `${instance.documentId}#L${level.level}`,
3688
+ documentType: childTemplate.documentType,
3689
+ submittedBy: instance.submittedBy,
3690
+ data: instance.data,
3691
+ metadata: { ...instance.metadata, parentInstanceId: instance.id }
3692
+ },
3693
+ void 0,
3694
+ { parentInstanceId: instance.id, parentLevel: level.level, depth }
3695
+ );
3696
+ await this.withOptimisticRetry(instance.id, async (parent) => {
3697
+ const lvl = parent.levels.find((l) => l.level === level.level);
3698
+ if (!lvl || lvl.childInstanceId) return parent;
3699
+ lvl.childInstanceId = child.id;
3700
+ parent.updatedAt = this.clock.now();
3701
+ await this.opts.adapter.updateInstance(parent, parent.version);
3702
+ return parent;
3703
+ });
3704
+ const payload = {
3705
+ instanceId: instance.id,
3706
+ documentId: instance.documentId,
3707
+ documentType: instance.documentType,
3708
+ timestamp: this.clock.now(),
3709
+ level: level.level,
3710
+ childInstanceId: child.id,
3711
+ childTemplateName: templateName
3712
+ };
3713
+ this.bus.emit("approval:subworkflow_started", payload);
3714
+ await this.notifyAdapters("approval:subworkflow_started", instance, payload);
3715
+ this.logger.info("subWorkflow: child approval started", {
3716
+ tenantId: this.tenantId,
3717
+ instanceId: instance.id,
3718
+ level: level.level,
3719
+ childInstanceId: child.id
3720
+ });
3721
+ }
3722
+ }
3723
+ /**
3724
+ * Return a finished child's outcome to the parent level that is waiting on it.
3725
+ *
3726
+ * An approved child approves its parent level and lets the chain advance; any
3727
+ * other terminal outcome — rejected, cancelled, expired — rejects the parent,
3728
+ * because the approval the parent was waiting for did not happen. Collapsing
3729
+ * those into one rejection is deliberate: a parent that treated a cancelled
3730
+ * child as "carry on" would advance past a gate nobody cleared.
3731
+ */
3732
+ async propagateToParent(child) {
3733
+ if (!child.parentInstanceId || child.parentLevel === void 0) return;
3734
+ const outcome = child.status;
3735
+ const parentId = child.parentInstanceId;
3736
+ const parentLevelNumber = child.parentLevel;
3737
+ try {
3738
+ const parent = await this.opts.adapter.getInstance(this.tenantId, parentId);
3739
+ if (!parent || parent.status !== "pending") return;
3740
+ const payload = {
3741
+ instanceId: parentId,
3742
+ documentId: parent.documentId,
3743
+ documentType: parent.documentType,
3744
+ timestamp: this.clock.now(),
3745
+ level: parentLevelNumber,
3746
+ childInstanceId: child.id,
3747
+ childTemplateName: child.templateName,
3748
+ outcome
3749
+ };
3750
+ if (outcome === "approved") {
3751
+ await this.completeSubWorkflowLevel(parentId, parentLevelNumber, child);
3752
+ } else {
3753
+ await this.rejectFromSubWorkflow(parentId, parentLevelNumber, child);
3754
+ }
3755
+ const refreshed = await this.opts.adapter.getInstance(this.tenantId, parentId);
3756
+ this.bus.emit("approval:subworkflow_completed", payload);
3757
+ if (refreshed) {
3758
+ await this.notifyAdapters("approval:subworkflow_completed", refreshed, payload);
3759
+ }
3760
+ } catch (err) {
3761
+ this.logger.error("subWorkflow: failed to propagate outcome to parent", err, {
3762
+ tenantId: this.tenantId,
3763
+ childInstanceId: child.id,
3764
+ parentInstanceId: parentId
3765
+ });
3766
+ }
3767
+ }
3768
+ /** Mark a sub-workflow level approved and advance the parent chain. */
3769
+ async completeSubWorkflowLevel(parentId, levelNumber, child) {
3770
+ let advanced = null;
3771
+ await this.withOptimisticRetry(parentId, async (parent) => {
3772
+ const level = parent.levels.find((l) => l.level === levelNumber);
3773
+ if (!level || level.status !== "pending") return parent;
3774
+ const now = this.clock.now();
3775
+ level.status = "approved";
3776
+ level.reminderDueAt = void 0;
3777
+ level.escalationDueAt = void 0;
3778
+ const auditEntry = {
3779
+ action: "subworkflow_completed",
3780
+ actorId: "system",
3781
+ level: levelNumber,
3782
+ timestamp: now,
3783
+ newValue: { childInstanceId: child.id, outcome: "approved" }
3784
+ };
3785
+ parent.auditLog.push(auditEntry);
3786
+ parent.updatedAt = now;
3787
+ const siblingsOpen = this.groupMembers(parent, level).some(
3788
+ (l) => l.status === "pending" || l.status === "waiting"
3789
+ );
3790
+ if (!siblingsOpen) {
3791
+ const nextGroup = this.findNextGroup(parent);
3792
+ if (nextGroup.length === 0) {
3793
+ parent.status = "approved";
3794
+ } else {
3795
+ await this.activateGroup(parent, nextGroup, now);
3796
+ }
3797
+ }
3798
+ await this.opts.adapter.updateInstance(parent, parent.version);
3799
+ await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
3800
+ await this.runExternalAudit(parent, auditEntry);
3801
+ advanced = parent;
3802
+ return parent;
3803
+ });
3804
+ if (advanced) {
3805
+ const parent = advanced;
3806
+ if (parent.status === "approved") {
3807
+ this.bus.emit("approval:completed", parent);
3808
+ await this.notifyAdapters("approval:completed", parent, parent);
3809
+ await this.propagateToParent(parent);
3810
+ } else {
3811
+ await this.startSubWorkflows(parent);
3812
+ }
3813
+ }
3814
+ }
3815
+ /** Reject a parent because the child approval it was waiting on did not succeed. */
3816
+ async rejectFromSubWorkflow(parentId, levelNumber, child) {
3817
+ let rejected = null;
3818
+ await this.withOptimisticRetry(parentId, async (parent) => {
3819
+ const level = parent.levels.find((l) => l.level === levelNumber);
3820
+ if (!level || level.status !== "pending") return parent;
3821
+ const now = this.clock.now();
3822
+ level.status = "rejected";
3823
+ level.reminderDueAt = void 0;
3824
+ level.escalationDueAt = void 0;
3825
+ parent.status = "rejected";
3826
+ parent.updatedAt = now;
3827
+ const auditEntry = {
3828
+ action: "subworkflow_completed",
3829
+ actorId: "system",
3830
+ level: levelNumber,
3831
+ timestamp: now,
3832
+ reason: `Child approval ${child.id} ended as "${child.status}".`,
3833
+ newValue: { childInstanceId: child.id, outcome: child.status }
3834
+ };
3835
+ parent.auditLog.push(auditEntry);
3836
+ await this.opts.adapter.updateInstance(parent, parent.version);
3837
+ await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
3838
+ await this.runExternalAudit(parent, auditEntry);
3839
+ rejected = parent;
3840
+ return parent;
3841
+ });
3842
+ if (rejected) {
3843
+ const parent = rejected;
3844
+ const payload = {
3845
+ instanceId: parent.id,
3846
+ documentId: parent.documentId,
3847
+ documentType: parent.documentType,
3848
+ timestamp: this.clock.now(),
3849
+ approverId: "system",
3850
+ level: levelNumber,
3851
+ reason: `Child approval ${child.id} ended as "${child.status}".`,
3852
+ returnTo: null
3853
+ };
3854
+ this.bus.emit("approval:rejected", payload);
3855
+ await this.notifyAdapters("approval:rejected", parent, payload);
3856
+ await this.propagateToParent(parent);
3857
+ }
3858
+ }
3859
+ /**
3860
+ * Work that must happen after a decision is durably recorded, not inside it.
3861
+ *
3862
+ * Both branches touch other instances — a newly opened level may spawn a
3863
+ * child approval, and a finished instance may be a child that owes its
3864
+ * outcome to a parent. Doing either inside the deciding instance's
3865
+ * compare-and-set would nest writes under a version guard that knows nothing
3866
+ * about them, so a slow child template would surface as a spurious conflict
3867
+ * on the decision the user just made.
3868
+ */
3869
+ async afterDecision(instance) {
3870
+ if (TERMINAL_STATUSES.has(instance.status)) {
3871
+ await this.propagateToParent(instance);
3872
+ return;
3873
+ }
3874
+ await this.startSubWorkflows(instance);
3875
+ }
3505
3876
  findNextLevel(instance) {
3506
3877
  return instance.levels.find((l) => l.level > instance.currentLevel && l.status === "waiting") ?? null;
3507
3878
  }