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
package/dist/index.cjs CHANGED
@@ -914,6 +914,7 @@ function computeTimingStats(samples) {
914
914
  }
915
915
 
916
916
  // src/engine/ApprovalEngine.ts
917
+ var MAX_SUBWORKFLOW_DEPTH = 5;
917
918
  var DEFAULT_MAX_REMINDERS = 3;
918
919
  var DEFAULT_MAX_ATTEMPTS = 3;
919
920
  var DEFAULT_BASE_DELAY_MS = 50;
@@ -925,6 +926,7 @@ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
925
926
  ]);
926
927
  var CYCLE_TIME_STATUSES = ["approved", "rejected", "cancelled"];
927
928
  var CYCLE_TIME_FETCH_BATCH_SIZE = 500;
929
+ var TEMPLATE_BUNDLE_VERSION = 1;
928
930
  var ApprovalEngine = class _ApprovalEngine {
929
931
  constructor(opts) {
930
932
  this.opts = opts;
@@ -1018,12 +1020,32 @@ var ApprovalEngine = class _ApprovalEngine {
1018
1020
  });
1019
1021
  }
1020
1022
  levelNums.add(l.level);
1021
- if (!l.approvers || l.approvers.length === 0) {
1023
+ if (!l.subWorkflow && (!l.approvers || l.approvers.length === 0)) {
1022
1024
  errors.push({
1023
1025
  field: `levels[${i}].approvers`,
1024
1026
  message: `Level ${l.level} must have at least one approver.`
1025
1027
  });
1026
1028
  }
1029
+ if (l.subWorkflow) {
1030
+ if (!l.subWorkflow.templateName) {
1031
+ errors.push({
1032
+ field: `levels[${i}].subWorkflow.templateName`,
1033
+ message: `Level ${l.level} declares a subWorkflow without a templateName.`
1034
+ });
1035
+ }
1036
+ if (l.subWorkflow.templateName === config.name) {
1037
+ errors.push({
1038
+ field: `levels[${i}].subWorkflow.templateName`,
1039
+ message: `Level ${l.level} would spawn a sub-workflow of its own template ("${config.name}"), which cannot terminate.`
1040
+ });
1041
+ }
1042
+ if (l.approvers && l.approvers.length > 0) {
1043
+ errors.push({
1044
+ field: `levels[${i}].approvers`,
1045
+ 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.`
1046
+ });
1047
+ }
1048
+ }
1027
1049
  if (l.reminderAfterDays !== void 0 && l.reminderAfterDays <= 0) {
1028
1050
  errors.push({
1029
1051
  field: `levels[${i}].reminderAfterDays`,
@@ -1166,7 +1188,7 @@ var ApprovalEngine = class _ApprovalEngine {
1166
1188
  return this.registry.list();
1167
1189
  }
1168
1190
  // ─── Lifecycle ────────────────────────────────────────────────────────────
1169
- async submit(raw, auditCtx) {
1191
+ async submit(raw, auditCtx, link) {
1170
1192
  const opts = parseOrThrow(() => SubmitOptionsSchema.parse(raw));
1171
1193
  const startMs = this.clock.now().getTime();
1172
1194
  const template = await this.registry.get(opts.templateName);
@@ -1226,6 +1248,7 @@ var ApprovalEngine = class _ApprovalEngine {
1226
1248
  weights: cfg.weights,
1227
1249
  escalationAfterDays: cfg.escalationAfterDays,
1228
1250
  escalationDueAt: inFirstGroup && cfg.escalationAfterDays ? this.deadlineFrom(now, cfg.escalationAfterDays) : void 0,
1251
+ subWorkflowTemplate: cfg.subWorkflow?.templateName,
1229
1252
  reminderAfterDays: cfg.reminderAfterDays,
1230
1253
  reminderEveryDays: cfg.reminderEveryDays,
1231
1254
  maxReminders: cfg.maxReminders,
@@ -1234,6 +1257,10 @@ var ApprovalEngine = class _ApprovalEngine {
1234
1257
  };
1235
1258
  });
1236
1259
  for (const lvl of levels.filter((l) => l.status === "pending")) {
1260
+ if (lvl.subWorkflowTemplate) {
1261
+ lvl.approverIds = [];
1262
+ continue;
1263
+ }
1237
1264
  lvl.approverIds = await this.resolver.resolveApprovers(
1238
1265
  lvl.approverConfigs,
1239
1266
  opts.submittedBy,
@@ -1254,6 +1281,9 @@ var ApprovalEngine = class _ApprovalEngine {
1254
1281
  const instance = {
1255
1282
  id: instanceId,
1256
1283
  tenantId: this.tenantId,
1284
+ parentInstanceId: link?.parentInstanceId,
1285
+ parentLevel: link?.parentLevel,
1286
+ subWorkflowDepth: link?.depth,
1257
1287
  templateId: template.id,
1258
1288
  templateName: template.name,
1259
1289
  documentId: opts.documentId,
@@ -1318,12 +1348,13 @@ var ApprovalEngine = class _ApprovalEngine {
1318
1348
  { operation: "submit", actorId: opts.submittedBy, tenantId: this.tenantId, input: opts },
1319
1349
  instance
1320
1350
  );
1351
+ await this.startSubWorkflows(instance);
1321
1352
  return instance;
1322
1353
  }
1323
1354
  async approve(instanceId, raw, auditCtx) {
1324
1355
  const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
1325
1356
  const startMs = this.clock.now().getTime();
1326
- return this.withOptimisticRetry(instanceId, async (instance) => {
1357
+ const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
1327
1358
  assertStatus(instance, "pending");
1328
1359
  if (opts.approverId === instance.submittedBy) {
1329
1360
  throw new ApprovalForbiddenError(
@@ -1536,10 +1567,12 @@ var ApprovalEngine = class _ApprovalEngine {
1536
1567
  }
1537
1568
  return instance;
1538
1569
  });
1570
+ await this.afterDecision(decided);
1571
+ return decided;
1539
1572
  }
1540
1573
  async reject(instanceId, raw, auditCtx) {
1541
1574
  const opts = parseOrThrow(() => RejectOptionsSchema.parse(raw));
1542
- return this.withOptimisticRetry(instanceId, async (instance) => {
1575
+ const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
1543
1576
  assertStatus(instance, "pending");
1544
1577
  if (opts.approverId === instance.submittedBy) {
1545
1578
  throw new ApprovalForbiddenError(
@@ -1658,6 +1691,8 @@ var ApprovalEngine = class _ApprovalEngine {
1658
1691
  }
1659
1692
  return instance;
1660
1693
  });
1694
+ await this.afterDecision(decided);
1695
+ return decided;
1661
1696
  }
1662
1697
  async delegate(instanceId, raw, auditCtx) {
1663
1698
  const opts = parseOrThrow(() => DelegateOptionsSchema.parse(raw));
@@ -1835,7 +1870,8 @@ var ApprovalEngine = class _ApprovalEngine {
1835
1870
  }
1836
1871
  async cancel(instanceId, raw, auditCtx) {
1837
1872
  const opts = parseOrThrow(() => CancelOptionsSchema.parse(raw));
1838
- return this.withOptimisticRetry(instanceId, async (instance) => {
1873
+ let cancelled = null;
1874
+ const result = await this.withOptimisticRetry(instanceId, async (instance) => {
1839
1875
  if (instance.status === "approved" || instance.status === "rejected") {
1840
1876
  throw new ApprovalError(`Cannot cancel a "${instance.status}" approval.`, "CANNOT_CANCEL");
1841
1877
  }
@@ -1889,8 +1925,11 @@ var ApprovalEngine = class _ApprovalEngine {
1889
1925
  },
1890
1926
  instance
1891
1927
  );
1928
+ cancelled = instance;
1892
1929
  return instance;
1893
1930
  });
1931
+ if (cancelled) await this.propagateToParent(cancelled);
1932
+ return result;
1894
1933
  }
1895
1934
  async escalate(instanceId, raw, auditCtx) {
1896
1935
  parseOrThrow(() => EscalateOptionsSchema.parse(raw));
@@ -2969,6 +3008,118 @@ var ApprovalEngine = class _ApprovalEngine {
2969
3008
  oldestAgeMs: Number.isFinite(row.oldest) ? now.getTime() - row.oldest : 0
2970
3009
  })).sort((a, b) => b.pending - a.pending || a.approverId.localeCompare(b.approverId));
2971
3010
  }
3011
+ /**
3012
+ * Export templates as a portable bundle.
3013
+ *
3014
+ * Approval configuration is written once and then has to travel — authored in
3015
+ * a sandbox, reviewed, promoted to production. Reading `listTemplates()` and
3016
+ * re-posting the rows carried each environment's own `id`, `tenantId` and
3017
+ * version lineage with it, which either collided on arrival or silently
3018
+ * claimed a history the target never had. This strips all of it.
3019
+ *
3020
+ * @param names - Templates to include; omit for all of them.
3021
+ */
3022
+ async exportTemplates(names) {
3023
+ const all = await this.registry.list();
3024
+ const wanted = names ? all.filter((t) => names.includes(t.name)) : all;
3025
+ if (names) {
3026
+ const missing = names.filter((n) => !all.some((t) => t.name === n));
3027
+ if (missing.length > 0) {
3028
+ throw new ApprovalTemplateNotFoundError(missing.join(", "));
3029
+ }
3030
+ }
3031
+ return {
3032
+ bundleVersion: TEMPLATE_BUNDLE_VERSION,
3033
+ exportedAt: this.clock.now(),
3034
+ templates: wanted.map((t) => {
3035
+ const {
3036
+ id: _id,
3037
+ tenantId: _tenantId,
3038
+ createdAt: _createdAt,
3039
+ version: _version,
3040
+ previousVersionId: _previousVersionId,
3041
+ ...config
3042
+ } = t;
3043
+ return config;
3044
+ })
3045
+ };
3046
+ }
3047
+ /**
3048
+ * Import a bundle produced by {@link exportTemplates}.
3049
+ *
3050
+ * **Every template is validated before any is written.** A bundle that is
3051
+ * half-applied is worse than one rejected outright: the tenant is left in a
3052
+ * state matching neither environment, and the operator has no way to tell
3053
+ * which half landed. Per-template failures during the write phase are still
3054
+ * reported individually, since a storage error can occur after validation
3055
+ * passes.
3056
+ *
3057
+ * @param bundle - The bundle to apply.
3058
+ * @param opts - `mode: 'create'` (default) refuses to touch existing
3059
+ * templates; `'upsert'` updates them. `dryRun` reports without writing.
3060
+ */
3061
+ async importTemplates(bundle, opts = {}) {
3062
+ const mode = opts.mode ?? "create";
3063
+ const dryRun = opts.dryRun ?? false;
3064
+ if (bundle.bundleVersion !== TEMPLATE_BUNDLE_VERSION) {
3065
+ throw new ApprovalValidationError(
3066
+ `Unsupported template bundle version ${bundle.bundleVersion}; this engine reads version ${TEMPLATE_BUNDLE_VERSION}.`
3067
+ );
3068
+ }
3069
+ if (!Array.isArray(bundle.templates) || bundle.templates.length === 0) {
3070
+ throw new ApprovalValidationError("Template bundle contains no templates.");
3071
+ }
3072
+ const duplicates = bundle.templates.map((t) => t.name).filter((name, i, all) => all.indexOf(name) !== i);
3073
+ if (duplicates.length > 0) {
3074
+ throw new ApprovalValidationError(
3075
+ `Template bundle contains duplicate names: ${[...new Set(duplicates)].join(", ")}.`
3076
+ );
3077
+ }
3078
+ const invalid = [];
3079
+ for (const config of bundle.templates) {
3080
+ const result2 = this.validateTemplate(config);
3081
+ if (!result2.valid) {
3082
+ invalid.push({
3083
+ name: config.name,
3084
+ message: result2.errors[0]?.message ?? "unknown validation error"
3085
+ });
3086
+ }
3087
+ }
3088
+ if (invalid.length > 0) {
3089
+ throw new ApprovalValidationError(
3090
+ `Template bundle failed validation and was not applied: ${invalid.map((e) => `${e.name}: ${e.message}`).join("; ")}`
3091
+ );
3092
+ }
3093
+ const result = { created: [], updated: [], skipped: [], errors: [], dryRun };
3094
+ for (const config of bundle.templates) {
3095
+ const existing = await this.opts.adapter.getTemplate(this.tenantId, config.name);
3096
+ try {
3097
+ if (existing && mode === "create") {
3098
+ result.skipped.push(config.name);
3099
+ continue;
3100
+ }
3101
+ if (existing) {
3102
+ if (!dryRun) await this.registry.update(config);
3103
+ result.updated.push(config.name);
3104
+ } else {
3105
+ if (!dryRun) await this.registry.define(config);
3106
+ result.created.push(config.name);
3107
+ }
3108
+ } catch (err) {
3109
+ result.errors.push({ name: config.name, message: err.message });
3110
+ }
3111
+ }
3112
+ this.logger.info("importTemplates: bundle applied", {
3113
+ tenantId: this.tenantId,
3114
+ mode,
3115
+ dryRun,
3116
+ created: result.created.length,
3117
+ updated: result.updated.length,
3118
+ skipped: result.skipped.length,
3119
+ errors: result.errors.length
3120
+ });
3121
+ return result;
3122
+ }
2972
3123
  async getStatistics(filter = {}) {
2973
3124
  const statuses = [
2974
3125
  "pending",
@@ -3407,14 +3558,18 @@ var ApprovalEngine = class _ApprovalEngine {
3407
3558
  /** Resolve approvers for a group, set its deadlines, and mark it pending. */
3408
3559
  async activateGroup(instance, group, now) {
3409
3560
  for (const lvl of group) {
3410
- lvl.approverIds = await this.resolver.resolveApprovers(
3411
- lvl.approverConfigs,
3412
- instance.submittedBy,
3413
- instance.data,
3414
- this.opts.orgProvider,
3415
- this.opts.outOfOfficeProvider,
3416
- now
3417
- );
3561
+ if (lvl.subWorkflowTemplate) {
3562
+ lvl.approverIds = [];
3563
+ } else {
3564
+ lvl.approverIds = await this.resolver.resolveApprovers(
3565
+ lvl.approverConfigs,
3566
+ instance.submittedBy,
3567
+ instance.data,
3568
+ this.opts.orgProvider,
3569
+ this.opts.outOfOfficeProvider,
3570
+ now
3571
+ );
3572
+ }
3418
3573
  if (lvl.escalationAfterDays) {
3419
3574
  lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);
3420
3575
  }
@@ -3501,6 +3656,222 @@ var ApprovalEngine = class _ApprovalEngine {
3501
3656
  this.logger.error("reminder: failed to send", err, { tenantId: this.tenantId, instanceId });
3502
3657
  }
3503
3658
  }
3659
+ /**
3660
+ * Start child approvals for any open level that delegates to a sub-workflow.
3661
+ *
3662
+ * Run after the parent has been persisted, never inside the same optimistic
3663
+ * write: the child's own `submit()` performs its own reads and writes, and
3664
+ * nesting them under the parent's compare-and-set would make a slow child
3665
+ * template a source of spurious version conflicts on the parent.
3666
+ */
3667
+ async startSubWorkflows(instance) {
3668
+ const pendingSubs = instance.levels.filter(
3669
+ (l) => l.status === "pending" && l.subWorkflowTemplate && !l.childInstanceId
3670
+ );
3671
+ if (pendingSubs.length === 0) return;
3672
+ const depth = (instance.subWorkflowDepth ?? 0) + 1;
3673
+ if (depth > MAX_SUBWORKFLOW_DEPTH) {
3674
+ throw new ApprovalValidationError(
3675
+ `Sub-workflow nesting exceeded ${MAX_SUBWORKFLOW_DEPTH} levels at template "${instance.templateName}". Check for a template that reaches itself.`
3676
+ );
3677
+ }
3678
+ for (const level of pendingSubs) {
3679
+ const templateName = level.subWorkflowTemplate;
3680
+ const childTemplate = await this.registry.get(templateName);
3681
+ const child = await this.submit(
3682
+ {
3683
+ templateName,
3684
+ // Unique per parent level, so a resubmitted parent does not collide
3685
+ // with the child it spawned last time.
3686
+ documentId: `${instance.documentId}#L${level.level}`,
3687
+ documentType: childTemplate.documentType,
3688
+ submittedBy: instance.submittedBy,
3689
+ data: instance.data,
3690
+ metadata: { ...instance.metadata, parentInstanceId: instance.id }
3691
+ },
3692
+ void 0,
3693
+ { parentInstanceId: instance.id, parentLevel: level.level, depth }
3694
+ );
3695
+ await this.withOptimisticRetry(instance.id, async (parent) => {
3696
+ const lvl = parent.levels.find((l) => l.level === level.level);
3697
+ if (!lvl || lvl.childInstanceId) return parent;
3698
+ lvl.childInstanceId = child.id;
3699
+ parent.updatedAt = this.clock.now();
3700
+ await this.opts.adapter.updateInstance(parent, parent.version);
3701
+ return parent;
3702
+ });
3703
+ const payload = {
3704
+ instanceId: instance.id,
3705
+ documentId: instance.documentId,
3706
+ documentType: instance.documentType,
3707
+ timestamp: this.clock.now(),
3708
+ level: level.level,
3709
+ childInstanceId: child.id,
3710
+ childTemplateName: templateName
3711
+ };
3712
+ this.bus.emit("approval:subworkflow_started", payload);
3713
+ await this.notifyAdapters("approval:subworkflow_started", instance, payload);
3714
+ this.logger.info("subWorkflow: child approval started", {
3715
+ tenantId: this.tenantId,
3716
+ instanceId: instance.id,
3717
+ level: level.level,
3718
+ childInstanceId: child.id
3719
+ });
3720
+ }
3721
+ }
3722
+ /**
3723
+ * Return a finished child's outcome to the parent level that is waiting on it.
3724
+ *
3725
+ * An approved child approves its parent level and lets the chain advance; any
3726
+ * other terminal outcome — rejected, cancelled, expired — rejects the parent,
3727
+ * because the approval the parent was waiting for did not happen. Collapsing
3728
+ * those into one rejection is deliberate: a parent that treated a cancelled
3729
+ * child as "carry on" would advance past a gate nobody cleared.
3730
+ */
3731
+ async propagateToParent(child) {
3732
+ if (!child.parentInstanceId || child.parentLevel === void 0) return;
3733
+ const outcome = child.status;
3734
+ const parentId = child.parentInstanceId;
3735
+ const parentLevelNumber = child.parentLevel;
3736
+ try {
3737
+ const parent = await this.opts.adapter.getInstance(this.tenantId, parentId);
3738
+ if (!parent || parent.status !== "pending") return;
3739
+ const payload = {
3740
+ instanceId: parentId,
3741
+ documentId: parent.documentId,
3742
+ documentType: parent.documentType,
3743
+ timestamp: this.clock.now(),
3744
+ level: parentLevelNumber,
3745
+ childInstanceId: child.id,
3746
+ childTemplateName: child.templateName,
3747
+ outcome
3748
+ };
3749
+ if (outcome === "approved") {
3750
+ await this.completeSubWorkflowLevel(parentId, parentLevelNumber, child);
3751
+ } else {
3752
+ await this.rejectFromSubWorkflow(parentId, parentLevelNumber, child);
3753
+ }
3754
+ const refreshed = await this.opts.adapter.getInstance(this.tenantId, parentId);
3755
+ this.bus.emit("approval:subworkflow_completed", payload);
3756
+ if (refreshed) {
3757
+ await this.notifyAdapters("approval:subworkflow_completed", refreshed, payload);
3758
+ }
3759
+ } catch (err) {
3760
+ this.logger.error("subWorkflow: failed to propagate outcome to parent", err, {
3761
+ tenantId: this.tenantId,
3762
+ childInstanceId: child.id,
3763
+ parentInstanceId: parentId
3764
+ });
3765
+ }
3766
+ }
3767
+ /** Mark a sub-workflow level approved and advance the parent chain. */
3768
+ async completeSubWorkflowLevel(parentId, levelNumber, child) {
3769
+ let advanced = null;
3770
+ await this.withOptimisticRetry(parentId, async (parent) => {
3771
+ const level = parent.levels.find((l) => l.level === levelNumber);
3772
+ if (!level || level.status !== "pending") return parent;
3773
+ const now = this.clock.now();
3774
+ level.status = "approved";
3775
+ level.reminderDueAt = void 0;
3776
+ level.escalationDueAt = void 0;
3777
+ const auditEntry = {
3778
+ action: "subworkflow_completed",
3779
+ actorId: "system",
3780
+ level: levelNumber,
3781
+ timestamp: now,
3782
+ newValue: { childInstanceId: child.id, outcome: "approved" }
3783
+ };
3784
+ parent.auditLog.push(auditEntry);
3785
+ parent.updatedAt = now;
3786
+ const siblingsOpen = this.groupMembers(parent, level).some(
3787
+ (l) => l.status === "pending" || l.status === "waiting"
3788
+ );
3789
+ if (!siblingsOpen) {
3790
+ const nextGroup = this.findNextGroup(parent);
3791
+ if (nextGroup.length === 0) {
3792
+ parent.status = "approved";
3793
+ } else {
3794
+ await this.activateGroup(parent, nextGroup, now);
3795
+ }
3796
+ }
3797
+ await this.opts.adapter.updateInstance(parent, parent.version);
3798
+ await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
3799
+ await this.runExternalAudit(parent, auditEntry);
3800
+ advanced = parent;
3801
+ return parent;
3802
+ });
3803
+ if (advanced) {
3804
+ const parent = advanced;
3805
+ if (parent.status === "approved") {
3806
+ this.bus.emit("approval:completed", parent);
3807
+ await this.notifyAdapters("approval:completed", parent, parent);
3808
+ await this.propagateToParent(parent);
3809
+ } else {
3810
+ await this.startSubWorkflows(parent);
3811
+ }
3812
+ }
3813
+ }
3814
+ /** Reject a parent because the child approval it was waiting on did not succeed. */
3815
+ async rejectFromSubWorkflow(parentId, levelNumber, child) {
3816
+ let rejected = null;
3817
+ await this.withOptimisticRetry(parentId, async (parent) => {
3818
+ const level = parent.levels.find((l) => l.level === levelNumber);
3819
+ if (!level || level.status !== "pending") return parent;
3820
+ const now = this.clock.now();
3821
+ level.status = "rejected";
3822
+ level.reminderDueAt = void 0;
3823
+ level.escalationDueAt = void 0;
3824
+ parent.status = "rejected";
3825
+ parent.updatedAt = now;
3826
+ const auditEntry = {
3827
+ action: "subworkflow_completed",
3828
+ actorId: "system",
3829
+ level: levelNumber,
3830
+ timestamp: now,
3831
+ reason: `Child approval ${child.id} ended as "${child.status}".`,
3832
+ newValue: { childInstanceId: child.id, outcome: child.status }
3833
+ };
3834
+ parent.auditLog.push(auditEntry);
3835
+ await this.opts.adapter.updateInstance(parent, parent.version);
3836
+ await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
3837
+ await this.runExternalAudit(parent, auditEntry);
3838
+ rejected = parent;
3839
+ return parent;
3840
+ });
3841
+ if (rejected) {
3842
+ const parent = rejected;
3843
+ const payload = {
3844
+ instanceId: parent.id,
3845
+ documentId: parent.documentId,
3846
+ documentType: parent.documentType,
3847
+ timestamp: this.clock.now(),
3848
+ approverId: "system",
3849
+ level: levelNumber,
3850
+ reason: `Child approval ${child.id} ended as "${child.status}".`,
3851
+ returnTo: null
3852
+ };
3853
+ this.bus.emit("approval:rejected", payload);
3854
+ await this.notifyAdapters("approval:rejected", parent, payload);
3855
+ await this.propagateToParent(parent);
3856
+ }
3857
+ }
3858
+ /**
3859
+ * Work that must happen after a decision is durably recorded, not inside it.
3860
+ *
3861
+ * Both branches touch other instances — a newly opened level may spawn a
3862
+ * child approval, and a finished instance may be a child that owes its
3863
+ * outcome to a parent. Doing either inside the deciding instance's
3864
+ * compare-and-set would nest writes under a version guard that knows nothing
3865
+ * about them, so a slow child template would surface as a spurious conflict
3866
+ * on the decision the user just made.
3867
+ */
3868
+ async afterDecision(instance) {
3869
+ if (TERMINAL_STATUSES.has(instance.status)) {
3870
+ await this.propagateToParent(instance);
3871
+ return;
3872
+ }
3873
+ await this.startSubWorkflows(instance);
3874
+ }
3504
3875
  findNextLevel(instance) {
3505
3876
  return instance.levels.find((l) => l.level > instance.currentLevel && l.status === "waiting") ?? null;
3506
3877
  }
@@ -3848,6 +4219,7 @@ exports.ApprovalTemplateNotFoundError = ApprovalTemplateNotFoundError;
3848
4219
  exports.ApprovalValidationError = ApprovalValidationError;
3849
4220
  exports.EscalationScheduler = EscalationScheduler;
3850
4221
  exports.MemoryAdapter = MemoryAdapter;
4222
+ exports.TEMPLATE_BUNDLE_VERSION = TEMPLATE_BUNDLE_VERSION;
3851
4223
  exports.defaultIdGenerator = defaultIdGenerator;
3852
4224
  exports.noopLogger = noopLogger;
3853
4225
  exports.systemClock = systemClock;