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/nestjs.d.cts CHANGED
@@ -1,16 +1,16 @@
1
1
  import { OnModuleDestroy, DynamicModule, ModuleMetadata, InjectionToken, OptionalFactoryDependency, Inject } from '@nestjs/common';
2
- import { a as ApprovalEngine, b as ApprovalEngineOptions } from './ApprovalEngine-C7p9cOA3.cjs';
3
- import './IStorageAdapter-Bgd6p8XL.cjs';
4
- import './instance-BE0uJmg3.cjs';
5
- import './INotificationAdapter-Dy2d0JKy.cjs';
2
+ import { a as ApprovalEngine, b as ApprovalEngineOptions } from './ApprovalEngine-CbFBAMlO.cjs';
3
+ import './IStorageAdapter-Bk7ybd3z.cjs';
4
+ import './instance-DUJY_Axf.cjs';
5
+ import './INotificationAdapter-JJAiMyMd.cjs';
6
6
  import 'zod';
7
7
  import './Logger-BplhlU7l.cjs';
8
8
  import './Clock-3FnOczFJ.cjs';
9
- import './IOperationMiddleware-DGG-guxK.cjs';
10
- import './IAuditAdapter-UJNdJanq.cjs';
9
+ import './IOperationMiddleware-D0ksiFM3.cjs';
10
+ import './IAuditAdapter-BHVKMMv6.cjs';
11
11
  import './IMetricsAdapter-DWq8IFaf.cjs';
12
12
  import './ISchedulerAdapter-DKv_QjVN.cjs';
13
- import './IAuthorizationPolicy-CN6LAaKg.cjs';
13
+ import './IAuthorizationPolicy-CtLkwQ8I.cjs';
14
14
 
15
15
  /** Options for {@link HierarchicalApprovalModule.forRoot}. */
16
16
  interface HierarchicalApprovalModuleOptions extends ApprovalEngineOptions {
package/dist/nestjs.d.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  import { OnModuleDestroy, DynamicModule, ModuleMetadata, InjectionToken, OptionalFactoryDependency, Inject } from '@nestjs/common';
2
- import { a as ApprovalEngine, b as ApprovalEngineOptions } from './ApprovalEngine-B8bPZq01.js';
3
- import './IStorageAdapter-B8aRYeGI.js';
4
- import './instance-BE0uJmg3.js';
5
- import './INotificationAdapter-Dv_MuYwS.js';
2
+ import { a as ApprovalEngine, b as ApprovalEngineOptions } from './ApprovalEngine-B3ChZxS7.js';
3
+ import './IStorageAdapter-DjRvHUF0.js';
4
+ import './instance-DUJY_Axf.js';
5
+ import './INotificationAdapter-BdgfUrIn.js';
6
6
  import 'zod';
7
7
  import './Logger-BplhlU7l.js';
8
8
  import './Clock-3FnOczFJ.js';
9
- import './IOperationMiddleware-DWYXVuJD.js';
10
- import './IAuditAdapter-DDV4Rf9F.js';
9
+ import './IOperationMiddleware-BGYrAqep.js';
10
+ import './IAuditAdapter-C4OASS6u.js';
11
11
  import './IMetricsAdapter-DWq8IFaf.js';
12
12
  import './ISchedulerAdapter-DKv_QjVN.js';
13
- import './IAuthorizationPolicy-CFJ-gXKl.js';
13
+ import './IAuthorizationPolicy-BA_m2Dg9.js';
14
14
 
15
15
  /** Options for {@link HierarchicalApprovalModule.forRoot}. */
16
16
  interface HierarchicalApprovalModuleOptions extends ApprovalEngineOptions {
package/dist/nestjs.js CHANGED
@@ -909,6 +909,7 @@ function computeTimingStats(samples) {
909
909
  }
910
910
 
911
911
  // src/engine/ApprovalEngine.ts
912
+ var MAX_SUBWORKFLOW_DEPTH = 5;
912
913
  var DEFAULT_MAX_REMINDERS = 3;
913
914
  var DEFAULT_MAX_ATTEMPTS = 3;
914
915
  var DEFAULT_BASE_DELAY_MS = 50;
@@ -920,6 +921,7 @@ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
920
921
  ]);
921
922
  var CYCLE_TIME_STATUSES = ["approved", "rejected", "cancelled"];
922
923
  var CYCLE_TIME_FETCH_BATCH_SIZE = 500;
924
+ var TEMPLATE_BUNDLE_VERSION = 1;
923
925
  var ApprovalEngine = class _ApprovalEngine {
924
926
  constructor(opts) {
925
927
  this.opts = opts;
@@ -1013,12 +1015,32 @@ var ApprovalEngine = class _ApprovalEngine {
1013
1015
  });
1014
1016
  }
1015
1017
  levelNums.add(l.level);
1016
- if (!l.approvers || l.approvers.length === 0) {
1018
+ if (!l.subWorkflow && (!l.approvers || l.approvers.length === 0)) {
1017
1019
  errors.push({
1018
1020
  field: `levels[${i}].approvers`,
1019
1021
  message: `Level ${l.level} must have at least one approver.`
1020
1022
  });
1021
1023
  }
1024
+ if (l.subWorkflow) {
1025
+ if (!l.subWorkflow.templateName) {
1026
+ errors.push({
1027
+ field: `levels[${i}].subWorkflow.templateName`,
1028
+ message: `Level ${l.level} declares a subWorkflow without a templateName.`
1029
+ });
1030
+ }
1031
+ if (l.subWorkflow.templateName === config.name) {
1032
+ errors.push({
1033
+ field: `levels[${i}].subWorkflow.templateName`,
1034
+ message: `Level ${l.level} would spawn a sub-workflow of its own template ("${config.name}"), which cannot terminate.`
1035
+ });
1036
+ }
1037
+ if (l.approvers && l.approvers.length > 0) {
1038
+ errors.push({
1039
+ field: `levels[${i}].approvers`,
1040
+ 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.`
1041
+ });
1042
+ }
1043
+ }
1022
1044
  if (l.reminderAfterDays !== void 0 && l.reminderAfterDays <= 0) {
1023
1045
  errors.push({
1024
1046
  field: `levels[${i}].reminderAfterDays`,
@@ -1161,7 +1183,7 @@ var ApprovalEngine = class _ApprovalEngine {
1161
1183
  return this.registry.list();
1162
1184
  }
1163
1185
  // ─── Lifecycle ────────────────────────────────────────────────────────────
1164
- async submit(raw, auditCtx) {
1186
+ async submit(raw, auditCtx, link) {
1165
1187
  const opts = parseOrThrow(() => SubmitOptionsSchema.parse(raw));
1166
1188
  const startMs = this.clock.now().getTime();
1167
1189
  const template = await this.registry.get(opts.templateName);
@@ -1221,6 +1243,7 @@ var ApprovalEngine = class _ApprovalEngine {
1221
1243
  weights: cfg.weights,
1222
1244
  escalationAfterDays: cfg.escalationAfterDays,
1223
1245
  escalationDueAt: inFirstGroup && cfg.escalationAfterDays ? this.deadlineFrom(now, cfg.escalationAfterDays) : void 0,
1246
+ subWorkflowTemplate: cfg.subWorkflow?.templateName,
1224
1247
  reminderAfterDays: cfg.reminderAfterDays,
1225
1248
  reminderEveryDays: cfg.reminderEveryDays,
1226
1249
  maxReminders: cfg.maxReminders,
@@ -1229,6 +1252,10 @@ var ApprovalEngine = class _ApprovalEngine {
1229
1252
  };
1230
1253
  });
1231
1254
  for (const lvl of levels.filter((l) => l.status === "pending")) {
1255
+ if (lvl.subWorkflowTemplate) {
1256
+ lvl.approverIds = [];
1257
+ continue;
1258
+ }
1232
1259
  lvl.approverIds = await this.resolver.resolveApprovers(
1233
1260
  lvl.approverConfigs,
1234
1261
  opts.submittedBy,
@@ -1249,6 +1276,9 @@ var ApprovalEngine = class _ApprovalEngine {
1249
1276
  const instance = {
1250
1277
  id: instanceId,
1251
1278
  tenantId: this.tenantId,
1279
+ parentInstanceId: link?.parentInstanceId,
1280
+ parentLevel: link?.parentLevel,
1281
+ subWorkflowDepth: link?.depth,
1252
1282
  templateId: template.id,
1253
1283
  templateName: template.name,
1254
1284
  documentId: opts.documentId,
@@ -1313,12 +1343,13 @@ var ApprovalEngine = class _ApprovalEngine {
1313
1343
  { operation: "submit", actorId: opts.submittedBy, tenantId: this.tenantId, input: opts },
1314
1344
  instance
1315
1345
  );
1346
+ await this.startSubWorkflows(instance);
1316
1347
  return instance;
1317
1348
  }
1318
1349
  async approve(instanceId, raw, auditCtx) {
1319
1350
  const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
1320
1351
  const startMs = this.clock.now().getTime();
1321
- return this.withOptimisticRetry(instanceId, async (instance) => {
1352
+ const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
1322
1353
  assertStatus(instance, "pending");
1323
1354
  if (opts.approverId === instance.submittedBy) {
1324
1355
  throw new ApprovalForbiddenError(
@@ -1531,10 +1562,12 @@ var ApprovalEngine = class _ApprovalEngine {
1531
1562
  }
1532
1563
  return instance;
1533
1564
  });
1565
+ await this.afterDecision(decided);
1566
+ return decided;
1534
1567
  }
1535
1568
  async reject(instanceId, raw, auditCtx) {
1536
1569
  const opts = parseOrThrow(() => RejectOptionsSchema.parse(raw));
1537
- return this.withOptimisticRetry(instanceId, async (instance) => {
1570
+ const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
1538
1571
  assertStatus(instance, "pending");
1539
1572
  if (opts.approverId === instance.submittedBy) {
1540
1573
  throw new ApprovalForbiddenError(
@@ -1653,6 +1686,8 @@ var ApprovalEngine = class _ApprovalEngine {
1653
1686
  }
1654
1687
  return instance;
1655
1688
  });
1689
+ await this.afterDecision(decided);
1690
+ return decided;
1656
1691
  }
1657
1692
  async delegate(instanceId, raw, auditCtx) {
1658
1693
  const opts = parseOrThrow(() => DelegateOptionsSchema.parse(raw));
@@ -1830,7 +1865,8 @@ var ApprovalEngine = class _ApprovalEngine {
1830
1865
  }
1831
1866
  async cancel(instanceId, raw, auditCtx) {
1832
1867
  const opts = parseOrThrow(() => CancelOptionsSchema.parse(raw));
1833
- return this.withOptimisticRetry(instanceId, async (instance) => {
1868
+ let cancelled = null;
1869
+ const result = await this.withOptimisticRetry(instanceId, async (instance) => {
1834
1870
  if (instance.status === "approved" || instance.status === "rejected") {
1835
1871
  throw new ApprovalError(`Cannot cancel a "${instance.status}" approval.`, "CANNOT_CANCEL");
1836
1872
  }
@@ -1884,8 +1920,11 @@ var ApprovalEngine = class _ApprovalEngine {
1884
1920
  },
1885
1921
  instance
1886
1922
  );
1923
+ cancelled = instance;
1887
1924
  return instance;
1888
1925
  });
1926
+ if (cancelled) await this.propagateToParent(cancelled);
1927
+ return result;
1889
1928
  }
1890
1929
  async escalate(instanceId, raw, auditCtx) {
1891
1930
  parseOrThrow(() => EscalateOptionsSchema.parse(raw));
@@ -2964,6 +3003,118 @@ var ApprovalEngine = class _ApprovalEngine {
2964
3003
  oldestAgeMs: Number.isFinite(row.oldest) ? now.getTime() - row.oldest : 0
2965
3004
  })).sort((a, b) => b.pending - a.pending || a.approverId.localeCompare(b.approverId));
2966
3005
  }
3006
+ /**
3007
+ * Export templates as a portable bundle.
3008
+ *
3009
+ * Approval configuration is written once and then has to travel — authored in
3010
+ * a sandbox, reviewed, promoted to production. Reading `listTemplates()` and
3011
+ * re-posting the rows carried each environment's own `id`, `tenantId` and
3012
+ * version lineage with it, which either collided on arrival or silently
3013
+ * claimed a history the target never had. This strips all of it.
3014
+ *
3015
+ * @param names - Templates to include; omit for all of them.
3016
+ */
3017
+ async exportTemplates(names) {
3018
+ const all = await this.registry.list();
3019
+ const wanted = names ? all.filter((t) => names.includes(t.name)) : all;
3020
+ if (names) {
3021
+ const missing = names.filter((n) => !all.some((t) => t.name === n));
3022
+ if (missing.length > 0) {
3023
+ throw new ApprovalTemplateNotFoundError(missing.join(", "));
3024
+ }
3025
+ }
3026
+ return {
3027
+ bundleVersion: TEMPLATE_BUNDLE_VERSION,
3028
+ exportedAt: this.clock.now(),
3029
+ templates: wanted.map((t) => {
3030
+ const {
3031
+ id: _id,
3032
+ tenantId: _tenantId,
3033
+ createdAt: _createdAt,
3034
+ version: _version,
3035
+ previousVersionId: _previousVersionId,
3036
+ ...config
3037
+ } = t;
3038
+ return config;
3039
+ })
3040
+ };
3041
+ }
3042
+ /**
3043
+ * Import a bundle produced by {@link exportTemplates}.
3044
+ *
3045
+ * **Every template is validated before any is written.** A bundle that is
3046
+ * half-applied is worse than one rejected outright: the tenant is left in a
3047
+ * state matching neither environment, and the operator has no way to tell
3048
+ * which half landed. Per-template failures during the write phase are still
3049
+ * reported individually, since a storage error can occur after validation
3050
+ * passes.
3051
+ *
3052
+ * @param bundle - The bundle to apply.
3053
+ * @param opts - `mode: 'create'` (default) refuses to touch existing
3054
+ * templates; `'upsert'` updates them. `dryRun` reports without writing.
3055
+ */
3056
+ async importTemplates(bundle, opts = {}) {
3057
+ const mode = opts.mode ?? "create";
3058
+ const dryRun = opts.dryRun ?? false;
3059
+ if (bundle.bundleVersion !== TEMPLATE_BUNDLE_VERSION) {
3060
+ throw new ApprovalValidationError(
3061
+ `Unsupported template bundle version ${bundle.bundleVersion}; this engine reads version ${TEMPLATE_BUNDLE_VERSION}.`
3062
+ );
3063
+ }
3064
+ if (!Array.isArray(bundle.templates) || bundle.templates.length === 0) {
3065
+ throw new ApprovalValidationError("Template bundle contains no templates.");
3066
+ }
3067
+ const duplicates = bundle.templates.map((t) => t.name).filter((name, i, all) => all.indexOf(name) !== i);
3068
+ if (duplicates.length > 0) {
3069
+ throw new ApprovalValidationError(
3070
+ `Template bundle contains duplicate names: ${[...new Set(duplicates)].join(", ")}.`
3071
+ );
3072
+ }
3073
+ const invalid = [];
3074
+ for (const config of bundle.templates) {
3075
+ const result2 = this.validateTemplate(config);
3076
+ if (!result2.valid) {
3077
+ invalid.push({
3078
+ name: config.name,
3079
+ message: result2.errors[0]?.message ?? "unknown validation error"
3080
+ });
3081
+ }
3082
+ }
3083
+ if (invalid.length > 0) {
3084
+ throw new ApprovalValidationError(
3085
+ `Template bundle failed validation and was not applied: ${invalid.map((e) => `${e.name}: ${e.message}`).join("; ")}`
3086
+ );
3087
+ }
3088
+ const result = { created: [], updated: [], skipped: [], errors: [], dryRun };
3089
+ for (const config of bundle.templates) {
3090
+ const existing = await this.opts.adapter.getTemplate(this.tenantId, config.name);
3091
+ try {
3092
+ if (existing && mode === "create") {
3093
+ result.skipped.push(config.name);
3094
+ continue;
3095
+ }
3096
+ if (existing) {
3097
+ if (!dryRun) await this.registry.update(config);
3098
+ result.updated.push(config.name);
3099
+ } else {
3100
+ if (!dryRun) await this.registry.define(config);
3101
+ result.created.push(config.name);
3102
+ }
3103
+ } catch (err) {
3104
+ result.errors.push({ name: config.name, message: err.message });
3105
+ }
3106
+ }
3107
+ this.logger.info("importTemplates: bundle applied", {
3108
+ tenantId: this.tenantId,
3109
+ mode,
3110
+ dryRun,
3111
+ created: result.created.length,
3112
+ updated: result.updated.length,
3113
+ skipped: result.skipped.length,
3114
+ errors: result.errors.length
3115
+ });
3116
+ return result;
3117
+ }
2967
3118
  async getStatistics(filter = {}) {
2968
3119
  const statuses = [
2969
3120
  "pending",
@@ -3402,14 +3553,18 @@ var ApprovalEngine = class _ApprovalEngine {
3402
3553
  /** Resolve approvers for a group, set its deadlines, and mark it pending. */
3403
3554
  async activateGroup(instance, group, now) {
3404
3555
  for (const lvl of group) {
3405
- lvl.approverIds = await this.resolver.resolveApprovers(
3406
- lvl.approverConfigs,
3407
- instance.submittedBy,
3408
- instance.data,
3409
- this.opts.orgProvider,
3410
- this.opts.outOfOfficeProvider,
3411
- now
3412
- );
3556
+ if (lvl.subWorkflowTemplate) {
3557
+ lvl.approverIds = [];
3558
+ } else {
3559
+ lvl.approverIds = await this.resolver.resolveApprovers(
3560
+ lvl.approverConfigs,
3561
+ instance.submittedBy,
3562
+ instance.data,
3563
+ this.opts.orgProvider,
3564
+ this.opts.outOfOfficeProvider,
3565
+ now
3566
+ );
3567
+ }
3413
3568
  if (lvl.escalationAfterDays) {
3414
3569
  lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);
3415
3570
  }
@@ -3496,6 +3651,222 @@ var ApprovalEngine = class _ApprovalEngine {
3496
3651
  this.logger.error("reminder: failed to send", err, { tenantId: this.tenantId, instanceId });
3497
3652
  }
3498
3653
  }
3654
+ /**
3655
+ * Start child approvals for any open level that delegates to a sub-workflow.
3656
+ *
3657
+ * Run after the parent has been persisted, never inside the same optimistic
3658
+ * write: the child's own `submit()` performs its own reads and writes, and
3659
+ * nesting them under the parent's compare-and-set would make a slow child
3660
+ * template a source of spurious version conflicts on the parent.
3661
+ */
3662
+ async startSubWorkflows(instance) {
3663
+ const pendingSubs = instance.levels.filter(
3664
+ (l) => l.status === "pending" && l.subWorkflowTemplate && !l.childInstanceId
3665
+ );
3666
+ if (pendingSubs.length === 0) return;
3667
+ const depth = (instance.subWorkflowDepth ?? 0) + 1;
3668
+ if (depth > MAX_SUBWORKFLOW_DEPTH) {
3669
+ throw new ApprovalValidationError(
3670
+ `Sub-workflow nesting exceeded ${MAX_SUBWORKFLOW_DEPTH} levels at template "${instance.templateName}". Check for a template that reaches itself.`
3671
+ );
3672
+ }
3673
+ for (const level of pendingSubs) {
3674
+ const templateName = level.subWorkflowTemplate;
3675
+ const childTemplate = await this.registry.get(templateName);
3676
+ const child = await this.submit(
3677
+ {
3678
+ templateName,
3679
+ // Unique per parent level, so a resubmitted parent does not collide
3680
+ // with the child it spawned last time.
3681
+ documentId: `${instance.documentId}#L${level.level}`,
3682
+ documentType: childTemplate.documentType,
3683
+ submittedBy: instance.submittedBy,
3684
+ data: instance.data,
3685
+ metadata: { ...instance.metadata, parentInstanceId: instance.id }
3686
+ },
3687
+ void 0,
3688
+ { parentInstanceId: instance.id, parentLevel: level.level, depth }
3689
+ );
3690
+ await this.withOptimisticRetry(instance.id, async (parent) => {
3691
+ const lvl = parent.levels.find((l) => l.level === level.level);
3692
+ if (!lvl || lvl.childInstanceId) return parent;
3693
+ lvl.childInstanceId = child.id;
3694
+ parent.updatedAt = this.clock.now();
3695
+ await this.opts.adapter.updateInstance(parent, parent.version);
3696
+ return parent;
3697
+ });
3698
+ const payload = {
3699
+ instanceId: instance.id,
3700
+ documentId: instance.documentId,
3701
+ documentType: instance.documentType,
3702
+ timestamp: this.clock.now(),
3703
+ level: level.level,
3704
+ childInstanceId: child.id,
3705
+ childTemplateName: templateName
3706
+ };
3707
+ this.bus.emit("approval:subworkflow_started", payload);
3708
+ await this.notifyAdapters("approval:subworkflow_started", instance, payload);
3709
+ this.logger.info("subWorkflow: child approval started", {
3710
+ tenantId: this.tenantId,
3711
+ instanceId: instance.id,
3712
+ level: level.level,
3713
+ childInstanceId: child.id
3714
+ });
3715
+ }
3716
+ }
3717
+ /**
3718
+ * Return a finished child's outcome to the parent level that is waiting on it.
3719
+ *
3720
+ * An approved child approves its parent level and lets the chain advance; any
3721
+ * other terminal outcome — rejected, cancelled, expired — rejects the parent,
3722
+ * because the approval the parent was waiting for did not happen. Collapsing
3723
+ * those into one rejection is deliberate: a parent that treated a cancelled
3724
+ * child as "carry on" would advance past a gate nobody cleared.
3725
+ */
3726
+ async propagateToParent(child) {
3727
+ if (!child.parentInstanceId || child.parentLevel === void 0) return;
3728
+ const outcome = child.status;
3729
+ const parentId = child.parentInstanceId;
3730
+ const parentLevelNumber = child.parentLevel;
3731
+ try {
3732
+ const parent = await this.opts.adapter.getInstance(this.tenantId, parentId);
3733
+ if (!parent || parent.status !== "pending") return;
3734
+ const payload = {
3735
+ instanceId: parentId,
3736
+ documentId: parent.documentId,
3737
+ documentType: parent.documentType,
3738
+ timestamp: this.clock.now(),
3739
+ level: parentLevelNumber,
3740
+ childInstanceId: child.id,
3741
+ childTemplateName: child.templateName,
3742
+ outcome
3743
+ };
3744
+ if (outcome === "approved") {
3745
+ await this.completeSubWorkflowLevel(parentId, parentLevelNumber, child);
3746
+ } else {
3747
+ await this.rejectFromSubWorkflow(parentId, parentLevelNumber, child);
3748
+ }
3749
+ const refreshed = await this.opts.adapter.getInstance(this.tenantId, parentId);
3750
+ this.bus.emit("approval:subworkflow_completed", payload);
3751
+ if (refreshed) {
3752
+ await this.notifyAdapters("approval:subworkflow_completed", refreshed, payload);
3753
+ }
3754
+ } catch (err) {
3755
+ this.logger.error("subWorkflow: failed to propagate outcome to parent", err, {
3756
+ tenantId: this.tenantId,
3757
+ childInstanceId: child.id,
3758
+ parentInstanceId: parentId
3759
+ });
3760
+ }
3761
+ }
3762
+ /** Mark a sub-workflow level approved and advance the parent chain. */
3763
+ async completeSubWorkflowLevel(parentId, levelNumber, child) {
3764
+ let advanced = null;
3765
+ await this.withOptimisticRetry(parentId, async (parent) => {
3766
+ const level = parent.levels.find((l) => l.level === levelNumber);
3767
+ if (!level || level.status !== "pending") return parent;
3768
+ const now = this.clock.now();
3769
+ level.status = "approved";
3770
+ level.reminderDueAt = void 0;
3771
+ level.escalationDueAt = void 0;
3772
+ const auditEntry = {
3773
+ action: "subworkflow_completed",
3774
+ actorId: "system",
3775
+ level: levelNumber,
3776
+ timestamp: now,
3777
+ newValue: { childInstanceId: child.id, outcome: "approved" }
3778
+ };
3779
+ parent.auditLog.push(auditEntry);
3780
+ parent.updatedAt = now;
3781
+ const siblingsOpen = this.groupMembers(parent, level).some(
3782
+ (l) => l.status === "pending" || l.status === "waiting"
3783
+ );
3784
+ if (!siblingsOpen) {
3785
+ const nextGroup = this.findNextGroup(parent);
3786
+ if (nextGroup.length === 0) {
3787
+ parent.status = "approved";
3788
+ } else {
3789
+ await this.activateGroup(parent, nextGroup, now);
3790
+ }
3791
+ }
3792
+ await this.opts.adapter.updateInstance(parent, parent.version);
3793
+ await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
3794
+ await this.runExternalAudit(parent, auditEntry);
3795
+ advanced = parent;
3796
+ return parent;
3797
+ });
3798
+ if (advanced) {
3799
+ const parent = advanced;
3800
+ if (parent.status === "approved") {
3801
+ this.bus.emit("approval:completed", parent);
3802
+ await this.notifyAdapters("approval:completed", parent, parent);
3803
+ await this.propagateToParent(parent);
3804
+ } else {
3805
+ await this.startSubWorkflows(parent);
3806
+ }
3807
+ }
3808
+ }
3809
+ /** Reject a parent because the child approval it was waiting on did not succeed. */
3810
+ async rejectFromSubWorkflow(parentId, levelNumber, child) {
3811
+ let rejected = null;
3812
+ await this.withOptimisticRetry(parentId, async (parent) => {
3813
+ const level = parent.levels.find((l) => l.level === levelNumber);
3814
+ if (!level || level.status !== "pending") return parent;
3815
+ const now = this.clock.now();
3816
+ level.status = "rejected";
3817
+ level.reminderDueAt = void 0;
3818
+ level.escalationDueAt = void 0;
3819
+ parent.status = "rejected";
3820
+ parent.updatedAt = now;
3821
+ const auditEntry = {
3822
+ action: "subworkflow_completed",
3823
+ actorId: "system",
3824
+ level: levelNumber,
3825
+ timestamp: now,
3826
+ reason: `Child approval ${child.id} ended as "${child.status}".`,
3827
+ newValue: { childInstanceId: child.id, outcome: child.status }
3828
+ };
3829
+ parent.auditLog.push(auditEntry);
3830
+ await this.opts.adapter.updateInstance(parent, parent.version);
3831
+ await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
3832
+ await this.runExternalAudit(parent, auditEntry);
3833
+ rejected = parent;
3834
+ return parent;
3835
+ });
3836
+ if (rejected) {
3837
+ const parent = rejected;
3838
+ const payload = {
3839
+ instanceId: parent.id,
3840
+ documentId: parent.documentId,
3841
+ documentType: parent.documentType,
3842
+ timestamp: this.clock.now(),
3843
+ approverId: "system",
3844
+ level: levelNumber,
3845
+ reason: `Child approval ${child.id} ended as "${child.status}".`,
3846
+ returnTo: null
3847
+ };
3848
+ this.bus.emit("approval:rejected", payload);
3849
+ await this.notifyAdapters("approval:rejected", parent, payload);
3850
+ await this.propagateToParent(parent);
3851
+ }
3852
+ }
3853
+ /**
3854
+ * Work that must happen after a decision is durably recorded, not inside it.
3855
+ *
3856
+ * Both branches touch other instances — a newly opened level may spawn a
3857
+ * child approval, and a finished instance may be a child that owes its
3858
+ * outcome to a parent. Doing either inside the deciding instance's
3859
+ * compare-and-set would nest writes under a version guard that knows nothing
3860
+ * about them, so a slow child template would surface as a spurious conflict
3861
+ * on the decision the user just made.
3862
+ */
3863
+ async afterDecision(instance) {
3864
+ if (TERMINAL_STATUSES.has(instance.status)) {
3865
+ await this.propagateToParent(instance);
3866
+ return;
3867
+ }
3868
+ await this.startSubWorkflows(instance);
3869
+ }
3499
3870
  findNextLevel(instance) {
3500
3871
  return instance.levels.find((l) => l.level > instance.currentLevel && l.status === "waiting") ?? null;
3501
3872
  }