hierarchical-approval 2.4.0 → 2.6.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 +71 -0
  2. package/README.md +54 -0
  3. package/dist/{ApprovalEngine-C1dUPnLM.d.cts → ApprovalEngine-BWrblNQ0.d.cts} +81 -7
  4. package/dist/{ApprovalEngine-Ci9urTDI.d.ts → ApprovalEngine-D4DdSx-M.d.ts} +81 -7
  5. package/dist/{IAuditAdapter-BHVKMMv6.d.cts → IAuditAdapter-2UU5edKG.d.cts} +1 -1
  6. package/dist/{IAuditAdapter-C4OASS6u.d.ts → IAuditAdapter-DSor54iY.d.ts} +1 -1
  7. package/dist/{IAuthorizationPolicy-BA_m2Dg9.d.ts → IAuthorizationPolicy-BOWg-3AH.d.ts} +1 -1
  8. package/dist/{IAuthorizationPolicy-CtLkwQ8I.d.cts → IAuthorizationPolicy-DPpiKzyh.d.cts} +1 -1
  9. package/dist/{INotificationAdapter-BdgfUrIn.d.ts → INotificationAdapter-BNGivLgU.d.ts} +1 -1
  10. package/dist/{INotificationAdapter-JJAiMyMd.d.cts → INotificationAdapter-C6HPrzh6.d.cts} +1 -1
  11. package/dist/{IOperationMiddleware-BGYrAqep.d.ts → IOperationMiddleware-CXtg2wzF.d.ts} +1 -1
  12. package/dist/{IOperationMiddleware-D0ksiFM3.d.cts → IOperationMiddleware-CxnPzpqL.d.cts} +1 -1
  13. package/dist/{IStorageAdapter-BU3sau5W.d.ts → IStorageAdapter-ChPT7ZDp.d.ts} +1 -1
  14. package/dist/{IStorageAdapter-DdHO4Rf1.d.cts → IStorageAdapter-yX9ERfQE.d.cts} +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 +179 -11
  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 +179 -12
  24. package/dist/index.js.map +1 -1
  25. package/dist/{instance-DUJY_Axf.d.cts → instance-weh2w7Ji.d.cts} +39 -1
  26. package/dist/{instance-DUJY_Axf.d.ts → instance-weh2w7Ji.d.ts} +39 -1
  27. package/dist/nestjs.cjs +111 -11
  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 +111 -11
  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 +111 -11
  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 +111 -11
  48. package/dist/testing.js.map +1 -1
  49. package/package.json +1 -1
@@ -59,6 +59,12 @@ interface ApprovalLevelConfig {
59
59
  */
60
60
  subWorkflow?: SubWorkflowConfig;
61
61
  escalationAfterDays?: number;
62
+ /**
63
+ * Escalate this level after this many **working hours**, for SLAs quoted in
64
+ * hours rather than days. Mutually exclusive with
65
+ * {@link escalationAfterDays}.
66
+ */
67
+ escalationAfterHours?: number;
62
68
  /**
63
69
  * Send a reminder to this level's pending approvers this many days after the
64
70
  * level opens. Escalation reassigns work; a reminder only nudges, which is
@@ -143,6 +149,15 @@ interface EscalationConfig {
143
149
  afterDays: number;
144
150
  escalateTo: ApproverConfig;
145
151
  }
152
+ /** One rung of an escalation ladder. See {@link ApprovalTemplateConfig.escalationSteps}. */
153
+ interface EscalationStep {
154
+ /** Delay from the level opening, in calendar or business days. */
155
+ afterDays?: number;
156
+ /** Delay from the level opening, in working hours. Mutually exclusive with {@link afterDays}. */
157
+ afterHours?: number;
158
+ /** Who is added to the level when this rung fires. */
159
+ escalateTo: ApproverConfig;
160
+ }
146
161
  interface ApprovalTemplateConfig {
147
162
  name: string;
148
163
  /**
@@ -164,8 +179,26 @@ interface ApprovalTemplateConfig {
164
179
  levels: ApprovalLevelConfig[];
165
180
  conditions?: ConditionRule[];
166
181
  escalation?: EscalationConfig;
182
+ /**
183
+ * An escalation ladder: successive rungs, each measured from the moment the
184
+ * level opened, each adding another approver.
185
+ *
186
+ * A single {@link escalation} could only ever fire once, so a request that
187
+ * stalled past its second deadline had nowhere further to go — the usual
188
+ * "chase the manager, then the director, then the VP" pattern had to be built
189
+ * outside the engine. Steps are sorted by delay and fire in order; each adds
190
+ * its approver to the level without removing anyone already there.
191
+ *
192
+ * Takes precedence over {@link escalation} when both are set.
193
+ */
194
+ escalationSteps?: EscalationStep[];
167
195
  /** Overall SLA for the entire workflow in days. Emits approval:sla_breached when elapsed. */
168
196
  slaDeadlineDays?: number;
197
+ /**
198
+ * Overall SLA in **working hours**. Mutually exclusive with
199
+ * {@link slaDeadlineDays}.
200
+ */
201
+ slaDeadlineHours?: number;
169
202
  /** Allow emergency override (bypass remaining levels). Must be true to use engine.override(). */
170
203
  allowOverride?: boolean;
171
204
  }
@@ -227,6 +260,9 @@ interface ApprovalLevelInstance {
227
260
  weights?: Record<string, number>;
228
261
  escalationDueAt?: Date;
229
262
  escalationAfterDays?: number;
263
+ escalationAfterHours?: number;
264
+ /** How many escalation rungs have fired on this level. */
265
+ escalationStep?: number;
230
266
  /** When the next reminder for this level is due; cleared when the level closes. */
231
267
  reminderDueAt?: Date;
232
268
  /** How many reminders have already been sent for this level. */
@@ -280,7 +316,9 @@ interface InfoRequest {
280
316
  /** Snapshot of template configuration captured at submit time to insulate in-flight instances from template updates. */
281
317
  interface TemplateSnapshot {
282
318
  escalation?: EscalationConfig;
319
+ escalationSteps?: EscalationStep[];
283
320
  slaDeadlineDays?: number;
321
+ slaDeadlineHours?: number;
284
322
  allowOverride?: boolean;
285
323
  }
286
324
  interface ApprovalInstance {
@@ -329,4 +367,4 @@ interface ApprovalInstance {
329
367
  attachments?: Attachment[];
330
368
  }
331
369
 
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 };
370
+ 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, EscalationStep as p, ResolverFn as q };
@@ -59,6 +59,12 @@ interface ApprovalLevelConfig {
59
59
  */
60
60
  subWorkflow?: SubWorkflowConfig;
61
61
  escalationAfterDays?: number;
62
+ /**
63
+ * Escalate this level after this many **working hours**, for SLAs quoted in
64
+ * hours rather than days. Mutually exclusive with
65
+ * {@link escalationAfterDays}.
66
+ */
67
+ escalationAfterHours?: number;
62
68
  /**
63
69
  * Send a reminder to this level's pending approvers this many days after the
64
70
  * level opens. Escalation reassigns work; a reminder only nudges, which is
@@ -143,6 +149,15 @@ interface EscalationConfig {
143
149
  afterDays: number;
144
150
  escalateTo: ApproverConfig;
145
151
  }
152
+ /** One rung of an escalation ladder. See {@link ApprovalTemplateConfig.escalationSteps}. */
153
+ interface EscalationStep {
154
+ /** Delay from the level opening, in calendar or business days. */
155
+ afterDays?: number;
156
+ /** Delay from the level opening, in working hours. Mutually exclusive with {@link afterDays}. */
157
+ afterHours?: number;
158
+ /** Who is added to the level when this rung fires. */
159
+ escalateTo: ApproverConfig;
160
+ }
146
161
  interface ApprovalTemplateConfig {
147
162
  name: string;
148
163
  /**
@@ -164,8 +179,26 @@ interface ApprovalTemplateConfig {
164
179
  levels: ApprovalLevelConfig[];
165
180
  conditions?: ConditionRule[];
166
181
  escalation?: EscalationConfig;
182
+ /**
183
+ * An escalation ladder: successive rungs, each measured from the moment the
184
+ * level opened, each adding another approver.
185
+ *
186
+ * A single {@link escalation} could only ever fire once, so a request that
187
+ * stalled past its second deadline had nowhere further to go — the usual
188
+ * "chase the manager, then the director, then the VP" pattern had to be built
189
+ * outside the engine. Steps are sorted by delay and fire in order; each adds
190
+ * its approver to the level without removing anyone already there.
191
+ *
192
+ * Takes precedence over {@link escalation} when both are set.
193
+ */
194
+ escalationSteps?: EscalationStep[];
167
195
  /** Overall SLA for the entire workflow in days. Emits approval:sla_breached when elapsed. */
168
196
  slaDeadlineDays?: number;
197
+ /**
198
+ * Overall SLA in **working hours**. Mutually exclusive with
199
+ * {@link slaDeadlineDays}.
200
+ */
201
+ slaDeadlineHours?: number;
169
202
  /** Allow emergency override (bypass remaining levels). Must be true to use engine.override(). */
170
203
  allowOverride?: boolean;
171
204
  }
@@ -227,6 +260,9 @@ interface ApprovalLevelInstance {
227
260
  weights?: Record<string, number>;
228
261
  escalationDueAt?: Date;
229
262
  escalationAfterDays?: number;
263
+ escalationAfterHours?: number;
264
+ /** How many escalation rungs have fired on this level. */
265
+ escalationStep?: number;
230
266
  /** When the next reminder for this level is due; cleared when the level closes. */
231
267
  reminderDueAt?: Date;
232
268
  /** How many reminders have already been sent for this level. */
@@ -280,7 +316,9 @@ interface InfoRequest {
280
316
  /** Snapshot of template configuration captured at submit time to insulate in-flight instances from template updates. */
281
317
  interface TemplateSnapshot {
282
318
  escalation?: EscalationConfig;
319
+ escalationSteps?: EscalationStep[];
283
320
  slaDeadlineDays?: number;
321
+ slaDeadlineHours?: number;
284
322
  allowOverride?: boolean;
285
323
  }
286
324
  interface ApprovalInstance {
@@ -329,4 +367,4 @@ interface ApprovalInstance {
329
367
  attachments?: Attachment[];
330
368
  }
331
369
 
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 };
370
+ 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, EscalationStep as p, ResolverFn as q };
package/dist/nestjs.cjs CHANGED
@@ -1072,6 +1072,18 @@ var ApprovalEngine = class _ApprovalEngine {
1072
1072
  message: `Level ${l.level} sets reminderEveryDays without reminderAfterDays, so no reminder would ever be sent.`
1073
1073
  });
1074
1074
  }
1075
+ if (l.escalationAfterHours !== void 0 && l.escalationAfterHours <= 0) {
1076
+ errors.push({
1077
+ field: `levels[${i}].escalationAfterHours`,
1078
+ message: `Level ${l.level} escalationAfterHours must be a positive number.`
1079
+ });
1080
+ }
1081
+ if (l.escalationAfterDays !== void 0 && l.escalationAfterHours !== void 0) {
1082
+ errors.push({
1083
+ field: `levels[${i}].escalationAfterHours`,
1084
+ message: `Level ${l.level} sets both escalationAfterDays and escalationAfterHours; pick one so the deadline is unambiguous.`
1085
+ });
1086
+ }
1075
1087
  if (l.escalationAfterDays !== void 0 && l.escalationAfterDays <= 0) {
1076
1088
  errors.push({
1077
1089
  field: `levels[${i}].escalationAfterDays`,
@@ -1136,6 +1148,18 @@ var ApprovalEngine = class _ApprovalEngine {
1136
1148
  }
1137
1149
  });
1138
1150
  }
1151
+ if (config.slaDeadlineDays !== void 0 && config.slaDeadlineHours !== void 0) {
1152
+ errors.push({
1153
+ field: "slaDeadlineHours",
1154
+ message: "Template sets both slaDeadlineDays and slaDeadlineHours; pick one so the SLA is unambiguous."
1155
+ });
1156
+ }
1157
+ if (config.slaDeadlineHours !== void 0 && config.slaDeadlineHours <= 0) {
1158
+ errors.push({
1159
+ field: "slaDeadlineHours",
1160
+ message: "slaDeadlineHours must be a positive number."
1161
+ });
1162
+ }
1139
1163
  if (config.conditions) {
1140
1164
  config.conditions.forEach((rule, ruleIdx) => {
1141
1165
  errors.push(...validateConditionExpression(rule.when, `conditions[${ruleIdx}].when`));
@@ -1249,7 +1273,9 @@ var ApprovalEngine = class _ApprovalEngine {
1249
1273
  threshold: cfg.threshold,
1250
1274
  weights: cfg.weights,
1251
1275
  escalationAfterDays: cfg.escalationAfterDays,
1252
- escalationDueAt: inFirstGroup && cfg.escalationAfterDays ? this.deadlineFrom(now, cfg.escalationAfterDays) : void 0,
1276
+ escalationAfterHours: cfg.escalationAfterHours,
1277
+ escalationStep: 0,
1278
+ escalationDueAt: inFirstGroup ? this.levelEscalationDue(now, cfg, this.firstRungOf(template.escalationSteps)) : void 0,
1253
1279
  subWorkflowTemplate: cfg.subWorkflow?.templateName,
1254
1280
  reminderAfterDays: cfg.reminderAfterDays,
1255
1281
  reminderEveryDays: cfg.reminderEveryDays,
@@ -1279,7 +1305,7 @@ var ApprovalEngine = class _ApprovalEngine {
1279
1305
  timestamp: now,
1280
1306
  ...auditCtx
1281
1307
  };
1282
- const slaDeadlineAt = template.slaDeadlineDays ? this.deadlineFrom(now, template.slaDeadlineDays) : void 0;
1308
+ const slaDeadlineAt = template.slaDeadlineHours ? this.deadlineFromHours(now, template.slaDeadlineHours) : template.slaDeadlineDays ? this.deadlineFrom(now, template.slaDeadlineDays) : void 0;
1283
1309
  const instance = {
1284
1310
  id: instanceId,
1285
1311
  tenantId: this.tenantId,
@@ -1306,7 +1332,9 @@ var ApprovalEngine = class _ApprovalEngine {
1306
1332
  slaDeadlineAt,
1307
1333
  templateSnapshot: {
1308
1334
  escalation: template.escalation,
1335
+ escalationSteps: template.escalationSteps,
1309
1336
  slaDeadlineDays: template.slaDeadlineDays,
1337
+ slaDeadlineHours: template.slaDeadlineHours,
1310
1338
  allowOverride: template.allowOverride
1311
1339
  }
1312
1340
  };
@@ -2578,7 +2606,7 @@ var ApprovalEngine = class _ApprovalEngine {
2578
2606
  reason: opts.reason,
2579
2607
  ...auditCtx
2580
2608
  };
2581
- const slaDeadlineAt = template.slaDeadlineDays ? this.deadlineFrom(now, template.slaDeadlineDays) : void 0;
2609
+ const slaDeadlineAt = template.slaDeadlineHours ? this.deadlineFromHours(now, template.slaDeadlineHours) : template.slaDeadlineDays ? this.deadlineFrom(now, template.slaDeadlineDays) : void 0;
2582
2610
  const newInstance = {
2583
2611
  id: newInstanceId,
2584
2612
  tenantId: this.tenantId,
@@ -2600,7 +2628,9 @@ var ApprovalEngine = class _ApprovalEngine {
2600
2628
  slaDeadlineAt,
2601
2629
  templateSnapshot: {
2602
2630
  escalation: template.escalation,
2631
+ escalationSteps: template.escalationSteps,
2603
2632
  slaDeadlineDays: template.slaDeadlineDays,
2633
+ slaDeadlineHours: template.slaDeadlineHours,
2604
2634
  allowOverride: template.allowOverride
2605
2635
  }
2606
2636
  };
@@ -3347,10 +3377,14 @@ var ApprovalEngine = class _ApprovalEngine {
3347
3377
  async escalateInternal(instanceId, escalatedBy = "system", auditCtx, levelNumber) {
3348
3378
  return this.withOptimisticRetry(instanceId, async (instance) => {
3349
3379
  if (instance.status !== "pending") return instance;
3350
- const escalationConfig = instance.templateSnapshot?.escalation ?? (await this.registry.get(instance.templateName)).escalation;
3351
- if (!escalationConfig) return instance;
3380
+ const ladder = await this.escalationLadder(instance);
3381
+ const targetLevel = levelNumber === void 0 ? this.currentLevelInstance(instance) : instance.levels.find((l) => l.level === levelNumber) ?? this.currentLevelInstance(instance);
3382
+ const rungIndex = targetLevel.escalationStep ?? 0;
3383
+ const rung = ladder[rungIndex];
3384
+ const escalateTo = rung?.escalateTo ?? (instance.templateSnapshot?.escalation ?? (await this.registry.get(instance.templateName)).escalation)?.escalateTo;
3385
+ if (!escalateTo) return instance;
3352
3386
  const newApprovers = await this.resolver.resolveApprovers(
3353
- [escalationConfig.escalateTo],
3387
+ [escalateTo],
3354
3388
  instance.submittedBy,
3355
3389
  instance.data,
3356
3390
  this.opts.orgProvider,
@@ -3368,10 +3402,13 @@ var ApprovalEngine = class _ApprovalEngine {
3368
3402
  );
3369
3403
  return instance;
3370
3404
  }
3371
- const level = levelNumber === void 0 ? this.currentLevelInstance(instance) : instance.levels.find((l) => l.level === levelNumber) ?? this.currentLevelInstance(instance);
3405
+ const level = targetLevel;
3372
3406
  level.approverIds = [.../* @__PURE__ */ new Set([...level.approverIds, ...filteredApprovers])];
3373
- level.escalationDueAt = void 0;
3374
3407
  const now = this.clock.now();
3408
+ level.escalationStep = rungIndex + 1;
3409
+ const nextRung = ladder[rungIndex + 1];
3410
+ const levelOpenedAt = this.levelOpenedAt(instance, level, now);
3411
+ level.escalationDueAt = nextRung ? this.stepDueAt(levelOpenedAt, nextRung) : void 0;
3375
3412
  const escalatedTo = filteredApprovers[0] ?? "unknown";
3376
3413
  const auditEntry = {
3377
3414
  action: "escalated",
@@ -3543,6 +3580,69 @@ var ApprovalEngine = class _ApprovalEngine {
3543
3580
  deadlineFrom(from, days) {
3544
3581
  return this.calendar ? this.calendar.addBusinessDays(from, days) : new Date(from.getTime() + days * 864e5);
3545
3582
  }
3583
+ /**
3584
+ * Resolve an hour-based deadline.
3585
+ *
3586
+ * A calendar that only knows whole days — `weekendCalendar`, say — has no
3587
+ * `addBusinessHours`, and this falls back to elapsed clock time rather than
3588
+ * quietly pretending the calendar was applied. Configure
3589
+ * `businessHoursCalendar` to have hours skip evenings and weekends.
3590
+ */
3591
+ deadlineFromHours(from, hours) {
3592
+ const addHours = this.calendar?.addBusinessHours?.bind(this.calendar);
3593
+ return addHours ? addHours(from, hours) : new Date(from.getTime() + hours * 36e5);
3594
+ }
3595
+ /** Level deadline from whichever of days/hours the template configured. */
3596
+ levelEscalationDue(from, level, firstRung) {
3597
+ if (level.escalationAfterHours) return this.deadlineFromHours(from, level.escalationAfterHours);
3598
+ if (level.escalationAfterDays) return this.deadlineFrom(from, level.escalationAfterDays);
3599
+ return firstRung ? this.stepDueAt(from, firstRung) : void 0;
3600
+ }
3601
+ /** First rung of a ladder, sorted by delay, or undefined when there is none. */
3602
+ firstRungOf(steps) {
3603
+ if (!steps || steps.length === 0) return void 0;
3604
+ return [...steps].sort(
3605
+ (a, b) => (a.afterHours ?? (a.afterDays ?? 0) * 24) - (b.afterHours ?? (b.afterDays ?? 0) * 24)
3606
+ )[0];
3607
+ }
3608
+ /**
3609
+ * The escalation ladder for an instance, sorted by delay.
3610
+ *
3611
+ * Read from the instance's template snapshot so an in-flight approval keeps
3612
+ * the ladder it was submitted under, exactly as the single-step
3613
+ * {@link EscalationConfig} already did.
3614
+ */
3615
+ async escalationLadder(instance) {
3616
+ const snapshot = instance.templateSnapshot;
3617
+ const steps = snapshot?.escalationSteps ?? (await this.registry.get(instance.templateName)).escalationSteps ?? [];
3618
+ return [...steps].sort(
3619
+ (a, b) => (a.afterHours ?? (a.afterDays ?? 0) * 24) - (b.afterHours ?? (b.afterDays ?? 0) * 24)
3620
+ );
3621
+ }
3622
+ /**
3623
+ * When a level started collecting decisions.
3624
+ *
3625
+ * Escalation rungs are measured from this, not from the previous rung, so a
3626
+ * ladder reads the way it is written. Recovered from the audit trail — the
3627
+ * `submitted` entry for the opening level, the `level_advanced` entry
3628
+ * otherwise — and falls back to `now` when no entry exists, which only leaves
3629
+ * the ladder no worse off than the single-step behaviour it replaces.
3630
+ */
3631
+ levelOpenedAt(instance, level, fallback) {
3632
+ for (let i = instance.auditLog.length - 1; i >= 0; i--) {
3633
+ const entry = instance.auditLog[i];
3634
+ if (!entry) continue;
3635
+ const opensThisLevel = (entry.action === "level_advanced" || entry.action === "submitted") && entry.level === level.level;
3636
+ if (opensThisLevel) return new Date(entry.timestamp);
3637
+ }
3638
+ return fallback;
3639
+ }
3640
+ /** Deadline for one rung, measured from when the level opened. */
3641
+ stepDueAt(from, step) {
3642
+ if (step.afterHours) return this.deadlineFromHours(from, step.afterHours);
3643
+ if (step.afterDays) return this.deadlineFrom(from, step.afterDays);
3644
+ return void 0;
3645
+ }
3546
3646
  async requireInstance(id) {
3547
3647
  const instance = await this.opts.adapter.getInstance(this.tenantId, id);
3548
3648
  if (!instance) throw new ApprovalNotFoundError("Instance", id);
@@ -3635,6 +3735,7 @@ var ApprovalEngine = class _ApprovalEngine {
3635
3735
  }
3636
3736
  /** Resolve approvers for a group, set its deadlines, and mark it pending. */
3637
3737
  async activateGroup(instance, group, now) {
3738
+ const firstRung = this.firstRungOf(instance.templateSnapshot?.escalationSteps);
3638
3739
  for (const lvl of group) {
3639
3740
  if (lvl.subWorkflowTemplate) {
3640
3741
  lvl.approverIds = [];
@@ -3648,9 +3749,8 @@ var ApprovalEngine = class _ApprovalEngine {
3648
3749
  now
3649
3750
  );
3650
3751
  }
3651
- if (lvl.escalationAfterDays) {
3652
- lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);
3653
- }
3752
+ lvl.escalationDueAt = this.levelEscalationDue(now, lvl, firstRung);
3753
+ lvl.escalationStep = 0;
3654
3754
  this.scheduleReminder(lvl, now);
3655
3755
  lvl.status = "pending";
3656
3756
  }