hierarchical-approval 1.1.0 → 1.3.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 +75 -0
  2. package/README.md +61 -0
  3. package/dist/{ApprovalEngine-qwImBe9A.d.cts → ApprovalEngine-CfySjeuy.d.cts} +29 -7
  4. package/dist/{ApprovalEngine-BDnQwKi9.d.ts → ApprovalEngine-Dhu5mlCG.d.ts} +29 -7
  5. package/dist/{IAuditAdapter-CWdZxS3U.d.cts → IAuditAdapter-B2I3CjaJ.d.cts} +1 -1
  6. package/dist/{IAuditAdapter-99JBU3Db.d.ts → IAuditAdapter-GixC9zS1.d.ts} +1 -1
  7. package/dist/{IAuthorizationPolicy-U-jZrl2F.d.ts → IAuthorizationPolicy-4ge9Gnxj.d.ts} +1 -1
  8. package/dist/{IAuthorizationPolicy-ChZIUJhQ.d.cts → IAuthorizationPolicy-BHDZK9QL.d.cts} +1 -1
  9. package/dist/{INotificationAdapter-BKwhxhe2.d.cts → INotificationAdapter-BKMeGmaG.d.cts} +1 -1
  10. package/dist/{INotificationAdapter-CsSWiCZE.d.ts → INotificationAdapter-BmSeB7FZ.d.ts} +1 -1
  11. package/dist/{IOperationMiddleware-D162RiJs.d.cts → IOperationMiddleware-BLw9Oc-S.d.cts} +1 -1
  12. package/dist/{IOperationMiddleware-CPkGSVfr.d.ts → IOperationMiddleware-BW9t8wPn.d.ts} +1 -1
  13. package/dist/{IStorageAdapter-BZarNxs9.d.cts → IStorageAdapter-CKV_FZFV.d.cts} +1 -1
  14. package/dist/{IStorageAdapter-DrY-rhhF.d.ts → IStorageAdapter-DQJmR6Mt.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 +126 -14
  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 +126 -14
  24. package/dist/index.js.map +1 -1
  25. package/dist/{instance-DuEgSIIz.d.cts → instance-BsM0DUPf.d.cts} +15 -0
  26. package/dist/{instance-DuEgSIIz.d.ts → instance-BsM0DUPf.d.ts} +15 -0
  27. package/dist/nestjs.cjs +126 -14
  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 +126 -14
  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 +126 -14
  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 +126 -14
  48. package/dist/testing.js.map +1 -1
  49. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,81 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [1.3.0] - 2026-09-04
11
+
12
+ ### Added — out-of-office cover
13
+
14
+ - **`outOfOfficeProvider` swaps absent approvers for their stand-in.** An
15
+ approver on leave stalled the chain until somebody noticed and reassigned by
16
+ hand; `delegate()` needed the absent person to initiate it, and `reassign()`
17
+ needed an administrator to spot the problem first.
18
+
19
+ ```ts
20
+ new ApprovalEngine({
21
+ adapter,
22
+ outOfOfficeProvider: {
23
+ getDelegateFor: async (userId, at) => hr.coverFor(userId, at), // null when available
24
+ },
25
+ });
26
+ ```
27
+
28
+ Applied wherever approvers are resolved — at submit, when a level activates,
29
+ on escalation, and in `previewApprovalChain()`, so a preview cannot disagree
30
+ with what `submit()` goes on to do.
31
+
32
+ Kept as an injected provider rather than engine-owned state because absence
33
+ lives in the HR or directory system that already tracks leave; storing it here
34
+ too would guarantee the two disagree. The resolution time is passed to the
35
+ provider, so cover can be date-bound.
36
+
37
+ - **Substitution is transitive but bounded.** An A→B→C chain of absences lands
38
+ on whoever is actually present, up to five hops. A cover *cycle* (A covers B
39
+ while B covers A) stops and leaves the original approver assigned — visible and
40
+ fixable, unlike a hang. A provider that throws is treated as "no cover known",
41
+ because an HR lookup failing must not stop an approval being routed at all.
42
+
43
+ New export: `OutOfOfficeProvider`.
44
+
45
+ ## [1.2.0] - 2026-09-04
46
+
47
+ ### Added — template inheritance
48
+
49
+ - **`extends` derives one template from another.** ERP tenants run many
50
+ near-identical workflows — one per region, legal entity or document class —
51
+ that share a spine and differ in a level or two. Each had to be written out in
52
+ full, so a change to the shared part meant editing every copy and hoping none
53
+ were missed.
54
+
55
+ ```ts
56
+ await engine.defineTemplate({
57
+ name: 'PO-EU',
58
+ extends: 'PO-base',
59
+ documentType: 'purchase_order',
60
+ removeLevels: [2],
61
+ levels: [{ level: 3, approvers: [{ type: 'user', userId: 'eu-fin' }] }],
62
+ });
63
+ ```
64
+
65
+ Levels are keyed by number: a child level overrides the base level *field by
66
+ field* (so a derived template can swap just the approvers and inherit name,
67
+ mode and deadlines), child-only levels are appended, and `removeLevels` drops
68
+ inherited ones. `conditions` are replaced wholesale when supplied — merging
69
+ two rule lists would produce a chain neither author intended. `escalation`,
70
+ `slaDeadlineDays` and `allowOverride` are inherited unless the child sets them.
71
+
72
+ - **Resolution happens once, at define time, and the flattened result is what
73
+ gets stored.** Resolving lazily on read would mean editing a base silently
74
+ reshapes every derived template — and every in-flight instance running against
75
+ one — which is exactly the surprise `templateSnapshot` exists to prevent
76
+ elsewhere in the engine. A base that itself extends something is already
77
+ flattened, so chains resolve naturally and cycles cannot form.
78
+
79
+ - **Validation runs on the flattened template**, not the fragment: a derived
80
+ config carrying no levels of its own is valid (it inherits the chain), while
81
+ one whose `removeLevels` strips the chain entirely is rejected at definition
82
+ time. `extends` and `removeLevels` are directives for the call and are not
83
+ persisted.
84
+
10
85
  ## [1.1.0] - 2026-09-04
11
86
 
12
87
  ### Added — approval reminders
package/README.md CHANGED
@@ -399,6 +399,67 @@ engine.registerConditionOperator('between', (actual, expected) => {
399
399
  });
400
400
  ```
401
401
 
402
+ ### Out-of-office cover
403
+
404
+ Approvers go on leave, and a chain that waits on an absent person stalls.
405
+ Configure a provider and absent approvers are swapped for their stand-in
406
+ wherever approvers are resolved — at submit, when a level activates, on
407
+ escalation, and in `previewApprovalChain()`:
408
+
409
+ ```ts
410
+ const engine = new ApprovalEngine({
411
+ adapter,
412
+ outOfOfficeProvider: {
413
+ // Return the stand-in, or null when the approver is available.
414
+ getDelegateFor: async (userId, at) => hr.coverFor(userId, at),
415
+ },
416
+ });
417
+ ```
418
+
419
+ Absence lives in the HR or directory system that already tracks leave, so this
420
+ is an injected provider rather than engine-owned state — duplicating that data
421
+ here would guarantee the two disagree. The resolution time is passed in, so
422
+ cover can be date-bound.
423
+
424
+ Substitution is transitive (A away → B, B away → C lands on C) up to five hops.
425
+ A cover *cycle* stops rather than looping, and a provider that throws is treated
426
+ as "no cover known" — an HR lookup failing must not stop an approval being
427
+ routed at all.
428
+
429
+ ### Template inheritance
430
+
431
+ ERP tenants run many near-identical workflows — one per region, entity or
432
+ document class — that share a spine and differ in a level or two. `extends`
433
+ derives one template from another:
434
+
435
+ ```ts
436
+ await engine.defineTemplate({
437
+ name: 'PO-EU',
438
+ extends: 'PO-base',
439
+ documentType: 'purchase_order',
440
+ removeLevels: [2], // drop an inherited level
441
+ levels: [
442
+ { level: 3, approvers: [{ type: 'user', userId: 'eu-fin' }] }, // override just the approvers
443
+ { level: 4, name: 'EU Compliance', approvers: [...], mode: 'any' }, // add a new level
444
+ ],
445
+ });
446
+ ```
447
+
448
+ | Field | Merge rule |
449
+ |---|---|
450
+ | `levels` | Keyed by level number: a child level overrides the base level *field by field*, child-only levels are appended, `removeLevels` drops inherited ones |
451
+ | `conditions` | Replaced wholesale when the child supplies them |
452
+ | `escalation`, `slaDeadlineDays`, `allowOverride` | Child value if set, otherwise inherited |
453
+
454
+ **Resolution happens once, at define time, and the flattened result is stored.**
455
+ Editing a base later never silently reshapes a derived template or an instance
456
+ already running against one — the same insulation `templateSnapshot` gives
457
+ in-flight instances. A base that itself extends something is already flattened,
458
+ so chains resolve naturally and cycles cannot form.
459
+
460
+ Validation runs on the *flattened* template, so a derived config that strips its
461
+ whole chain is rejected at definition time.
462
+
402
463
  ### Reminders
403
464
 
404
465
  Escalation reassigns work; a reminder only nudges, which is usually what an
@@ -1,14 +1,14 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-BZarNxs9.cjs';
2
- import { k as ConditionExpression, o as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-DuEgSIIz.cjs';
3
- import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-BKwhxhe2.cjs';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-CKV_FZFV.cjs';
2
+ import { k as ConditionExpression, o as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-BsM0DUPf.cjs';
3
+ import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-BKMeGmaG.cjs';
4
4
  import { z } from 'zod';
5
5
  import { L as Logger } from './Logger-BplhlU7l.cjs';
6
6
  import { C as Clock } from './Clock-3FnOczFJ.cjs';
7
- import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-D162RiJs.cjs';
8
- import { I as IAuditAdapter } from './IAuditAdapter-CWdZxS3U.cjs';
7
+ import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-BLw9Oc-S.cjs';
8
+ import { I as IAuditAdapter } from './IAuditAdapter-B2I3CjaJ.cjs';
9
9
  import { I as IMetricsAdapter } from './IMetricsAdapter-QvyaKQCc.cjs';
10
10
  import { I as ISchedulerAdapter } from './ISchedulerAdapter-DKv_QjVN.cjs';
11
- import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-ChZIUJhQ.cjs';
11
+ import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-BHDZK9QL.cjs';
12
12
 
13
13
  declare const SubmitOptionsSchema: z.ZodObject<{
14
14
  templateName: z.ZodString;
@@ -136,6 +136,23 @@ interface OrgProvider {
136
136
  /** Optional: resolve users matching a custom attribute/value pair. */
137
137
  getUsersByAttribute?(attr: string, value: unknown, tenantId?: string): Promise<string[]> | string[];
138
138
  }
139
+ /**
140
+ * Supplies out-of-office cover so an approver on leave does not stall a chain.
141
+ *
142
+ * Consulted whenever a level's approvers are resolved — at submit, when a level
143
+ * activates, and when a chain is previewed. Kept as an injected provider rather
144
+ * than engine-owned state because absence lives in the HR or directory system
145
+ * that already knows about leave; duplicating it here would guarantee the two
146
+ * disagree.
147
+ */
148
+ interface OutOfOfficeProvider {
149
+ /**
150
+ * @param userId - The approver about to be assigned.
151
+ * @param at - The moment cover is being resolved for.
152
+ * @returns The user to stand in, or null/undefined when the approver is available.
153
+ */
154
+ getDelegateFor(userId: string, at: Date): Promise<string | null | undefined> | string | null | undefined;
155
+ }
139
156
  type ApproverResolverFn = (config: Record<string, unknown>, ctx: {
140
157
  submittedBy: string;
141
158
  data: Record<string, unknown>;
@@ -296,6 +313,11 @@ interface ApprovalEngineOptions {
296
313
  adapter: IStorageAdapter;
297
314
  tenantId?: string;
298
315
  orgProvider?: OrgProvider;
316
+ /**
317
+ * Supplies stand-ins for approvers who are away, so leave does not stall a
318
+ * chain. Consulted every time approvers are resolved.
319
+ */
320
+ outOfOfficeProvider?: OutOfOfficeProvider;
299
321
  logger?: Logger;
300
322
  escalationPollIntervalMs?: number;
301
323
  /** Maximum number of instances allowed in a single bulk operation. Default: 200. */
@@ -546,4 +568,4 @@ declare class ApprovalEngine {
546
568
  private runExternalAudit;
547
569
  }
548
570
 
549
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
571
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OutOfOfficeProvider as k, type OverrideOptions as l, type PreviewResult as m, type RejectOptions as n, type ResubmitOptions as o, type RetryPolicy as p, defaultIdGenerator as q, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
@@ -1,14 +1,14 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-DrY-rhhF.js';
2
- import { k as ConditionExpression, o as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-DuEgSIIz.js';
3
- import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-CsSWiCZE.js';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-DQJmR6Mt.js';
2
+ import { k as ConditionExpression, o as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-BsM0DUPf.js';
3
+ import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-BmSeB7FZ.js';
4
4
  import { z } from 'zod';
5
5
  import { L as Logger } from './Logger-BplhlU7l.js';
6
6
  import { C as Clock } from './Clock-3FnOczFJ.js';
7
- import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-CPkGSVfr.js';
8
- import { I as IAuditAdapter } from './IAuditAdapter-99JBU3Db.js';
7
+ import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-BW9t8wPn.js';
8
+ import { I as IAuditAdapter } from './IAuditAdapter-GixC9zS1.js';
9
9
  import { I as IMetricsAdapter } from './IMetricsAdapter-QvyaKQCc.js';
10
10
  import { I as ISchedulerAdapter } from './ISchedulerAdapter-DKv_QjVN.js';
11
- import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-U-jZrl2F.js';
11
+ import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-4ge9Gnxj.js';
12
12
 
13
13
  declare const SubmitOptionsSchema: z.ZodObject<{
14
14
  templateName: z.ZodString;
@@ -136,6 +136,23 @@ interface OrgProvider {
136
136
  /** Optional: resolve users matching a custom attribute/value pair. */
137
137
  getUsersByAttribute?(attr: string, value: unknown, tenantId?: string): Promise<string[]> | string[];
138
138
  }
139
+ /**
140
+ * Supplies out-of-office cover so an approver on leave does not stall a chain.
141
+ *
142
+ * Consulted whenever a level's approvers are resolved — at submit, when a level
143
+ * activates, and when a chain is previewed. Kept as an injected provider rather
144
+ * than engine-owned state because absence lives in the HR or directory system
145
+ * that already knows about leave; duplicating it here would guarantee the two
146
+ * disagree.
147
+ */
148
+ interface OutOfOfficeProvider {
149
+ /**
150
+ * @param userId - The approver about to be assigned.
151
+ * @param at - The moment cover is being resolved for.
152
+ * @returns The user to stand in, or null/undefined when the approver is available.
153
+ */
154
+ getDelegateFor(userId: string, at: Date): Promise<string | null | undefined> | string | null | undefined;
155
+ }
139
156
  type ApproverResolverFn = (config: Record<string, unknown>, ctx: {
140
157
  submittedBy: string;
141
158
  data: Record<string, unknown>;
@@ -296,6 +313,11 @@ interface ApprovalEngineOptions {
296
313
  adapter: IStorageAdapter;
297
314
  tenantId?: string;
298
315
  orgProvider?: OrgProvider;
316
+ /**
317
+ * Supplies stand-ins for approvers who are away, so leave does not stall a
318
+ * chain. Consulted every time approvers are resolved.
319
+ */
320
+ outOfOfficeProvider?: OutOfOfficeProvider;
299
321
  logger?: Logger;
300
322
  escalationPollIntervalMs?: number;
301
323
  /** Maximum number of instances allowed in a single bulk operation. Default: 200. */
@@ -546,4 +568,4 @@ declare class ApprovalEngine {
546
568
  private runExternalAudit;
547
569
  }
548
570
 
549
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
571
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OutOfOfficeProvider as k, type OverrideOptions as l, type PreviewResult as m, type RejectOptions as n, type ResubmitOptions as o, type RetryPolicy as p, defaultIdGenerator as q, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
@@ -1,4 +1,4 @@
1
- import { b as AuditEntry, a as ApprovalInstance } from './instance-DuEgSIIz.cjs';
1
+ import { b as AuditEntry, a as ApprovalInstance } from './instance-BsM0DUPf.cjs';
2
2
 
3
3
  interface IAuditAdapter {
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { b as AuditEntry, a as ApprovalInstance } from './instance-DuEgSIIz.js';
1
+ import { b as AuditEntry, a as ApprovalInstance } from './instance-BsM0DUPf.js';
2
2
 
3
3
  interface IAuditAdapter {
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-DuEgSIIz.js';
1
+ import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-BsM0DUPf.js';
2
2
 
3
3
  interface AuthorizationContext {
4
4
  operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment' | 'updateData';
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-DuEgSIIz.cjs';
1
+ import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-BsM0DUPf.cjs';
2
2
 
3
3
  interface AuthorizationContext {
4
4
  operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment' | 'updateData';
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, b as AuditEntry } from './instance-DuEgSIIz.cjs';
1
+ import { a as ApprovalInstance, b as AuditEntry } from './instance-BsM0DUPf.cjs';
2
2
 
3
3
  interface ApprovalEvent {
4
4
  instanceId: string;
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, b as AuditEntry } from './instance-DuEgSIIz.js';
1
+ import { a as ApprovalInstance, b as AuditEntry } from './instance-BsM0DUPf.js';
2
2
 
3
3
  interface ApprovalEvent {
4
4
  instanceId: string;
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance } from './instance-DuEgSIIz.cjs';
1
+ import { a as ApprovalInstance } from './instance-BsM0DUPf.cjs';
2
2
 
3
3
  declare class ApprovalError extends Error {
4
4
  readonly code: string;
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance } from './instance-DuEgSIIz.js';
1
+ import { a as ApprovalInstance } from './instance-BsM0DUPf.js';
2
2
 
3
3
  declare class ApprovalError extends Error {
4
4
  readonly code: string;
@@ -1,4 +1,4 @@
1
- import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-DuEgSIIz.cjs';
1
+ import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-BsM0DUPf.cjs';
2
2
 
3
3
  interface PaginationOpts {
4
4
  limit: number;
@@ -1,4 +1,4 @@
1
- import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-DuEgSIIz.js';
1
+ import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-BsM0DUPf.js';
2
2
 
3
3
  interface PaginationOpts {
4
4
  limit: number;
@@ -1,5 +1,5 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-BZarNxs9.cjs';
2
- import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-DuEgSIIz.cjs';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-CKV_FZFV.cjs';
2
+ import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-BsM0DUPf.cjs';
3
3
 
4
4
  declare class MemoryAdapter implements IStorageAdapter {
5
5
  private templates;
@@ -1,5 +1,5 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-DrY-rhhF.js';
2
- import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-DuEgSIIz.js';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-DQJmR6Mt.js';
2
+ import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-BsM0DUPf.js';
3
3
 
4
4
  declare class MemoryAdapter implements IStorageAdapter {
5
5
  private templates;
@@ -1,7 +1,7 @@
1
1
  import * as tls from 'tls';
2
2
  import * as pg from 'pg';
3
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-BZarNxs9.cjs';
4
- import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-DuEgSIIz.cjs';
3
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-CKV_FZFV.cjs';
4
+ import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-BsM0DUPf.cjs';
5
5
 
6
6
  interface PostgresAdapterOptions {
7
7
  connectionString?: string;
@@ -1,7 +1,7 @@
1
1
  import * as tls from 'tls';
2
2
  import * as pg from 'pg';
3
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-DrY-rhhF.js';
4
- import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-DuEgSIIz.js';
3
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-DQJmR6Mt.js';
4
+ import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-BsM0DUPf.js';
5
5
 
6
6
  interface PostgresAdapterOptions {
7
7
  connectionString?: string;
package/dist/index.cjs CHANGED
@@ -224,6 +224,66 @@ var TemplateRegistry = class {
224
224
  this.clock = opts?.clock ?? systemClock;
225
225
  this.generateId = opts?.generateId ?? defaultIdGenerator;
226
226
  }
227
+ /**
228
+ * Flatten a template that declares `extends` against its base.
229
+ *
230
+ * Resolution is done once, at define time, and the flattened result is what
231
+ * gets stored. Resolving lazily on read would mean editing a base silently
232
+ * reshapes every derived template — and every in-flight instance running
233
+ * against one — which is exactly the surprise `templateSnapshot` exists to
234
+ * prevent elsewhere in the engine.
235
+ *
236
+ * Merge rules:
237
+ * - **Levels** are keyed by level number. A child level with the same number
238
+ * overrides the base level field by field, so a derived template can change
239
+ * just the approvers and inherit the rest. Child-only levels are appended,
240
+ * and `removeLevels` drops inherited ones.
241
+ * - **conditions** are replaced wholesale when the child supplies them.
242
+ * Concatenating two rule lists would produce a chain neither author
243
+ * intended, and there is no meaningful way to "merge" boolean rules.
244
+ * - **escalation**, **slaDeadlineDays** and **allowOverride** are taken from
245
+ * the child when set, otherwise inherited.
246
+ *
247
+ * A base that itself extends something is already flattened in storage, so
248
+ * chains resolve naturally and cycles cannot form: the base must exist before
249
+ * the child can name it.
250
+ *
251
+ * @param config - The raw config as supplied by the caller.
252
+ * @returns The config with inheritance applied, or unchanged if no `extends`.
253
+ */
254
+ async resolveInheritance(config) {
255
+ if (!config.extends) return config;
256
+ if (config.extends === config.name) {
257
+ throw new ApprovalValidationError(`Template "${config.name}" cannot extend itself.`);
258
+ }
259
+ const base = await this.adapter.getTemplate(this.tenantId, config.extends);
260
+ if (!base) {
261
+ throw new ApprovalTemplateNotFoundError(
262
+ `${config.extends} (referenced by "${config.name}" via extends)`
263
+ );
264
+ }
265
+ const removed = new Set(config.removeLevels ?? []);
266
+ const byLevel = /* @__PURE__ */ new Map();
267
+ for (const lvl of base.levels) {
268
+ if (!removed.has(lvl.level)) byLevel.set(lvl.level, lvl);
269
+ }
270
+ for (const lvl of config.levels ?? []) {
271
+ const inherited = byLevel.get(lvl.level);
272
+ byLevel.set(lvl.level, inherited ? { ...inherited, ...lvl } : lvl);
273
+ }
274
+ const levels = [...byLevel.values()].sort((a, b) => a.level - b.level);
275
+ const resolved = {
276
+ ...config,
277
+ levels,
278
+ conditions: config.conditions ?? base.conditions,
279
+ escalation: config.escalation ?? base.escalation,
280
+ slaDeadlineDays: config.slaDeadlineDays ?? base.slaDeadlineDays,
281
+ allowOverride: config.allowOverride ?? base.allowOverride
282
+ };
283
+ delete resolved.extends;
284
+ delete resolved.removeLevels;
285
+ return resolved;
286
+ }
227
287
  async define(config) {
228
288
  const existing = await this.adapter.getTemplate(this.tenantId, config.name);
229
289
  if (existing) {
@@ -271,6 +331,7 @@ var TemplateRegistry = class {
271
331
  };
272
332
 
273
333
  // src/engine/LevelResolver.ts
334
+ var MAX_OOO_HOPS = 5;
274
335
  var LevelResolver = class {
275
336
  constructor() {
276
337
  this.resolvers = /* @__PURE__ */ new Map();
@@ -282,7 +343,7 @@ var LevelResolver = class {
282
343
  registerApproverType(typeName, fn) {
283
344
  this.approverTypes.set(typeName, fn);
284
345
  }
285
- async resolveApprovers(approvers, submittedBy, data, orgProvider) {
346
+ async resolveApprovers(approvers, submittedBy, data, orgProvider, outOfOffice, at) {
286
347
  const resolved = [];
287
348
  for (const approver of approvers) {
288
349
  switch (approver.type) {
@@ -302,7 +363,9 @@ var LevelResolver = class {
302
363
  break;
303
364
  }
304
365
  case "dynamic": {
305
- const fn = this.resolvers.get(approver.resolver);
366
+ const fn = this.resolvers.get(
367
+ approver.resolver
368
+ );
306
369
  if (!fn) {
307
370
  throw new Error(
308
371
  `No resolver registered for "${approver.resolver}". Call engine.registerResolver("${approver.resolver}", fn) first.`
@@ -319,7 +382,11 @@ var LevelResolver = class {
319
382
  `Unknown approver type "${approver.type}". Register it with engine.registerApproverType("${approver.type}", fn) first.`
320
383
  );
321
384
  }
322
- const ids = await customFn(approver, { submittedBy, data, orgProvider });
385
+ const ids = await customFn(approver, {
386
+ submittedBy,
387
+ data,
388
+ orgProvider
389
+ });
323
390
  resolved.push(...ids);
324
391
  break;
325
392
  }
@@ -331,7 +398,40 @@ var LevelResolver = class {
331
398
  "No approvers resolved for this level. Check your approver configuration \u2014 role may have no members or dynamic resolver returned empty."
332
399
  );
333
400
  }
334
- return result;
401
+ return this.applyOutOfOffice(result, outOfOffice, at);
402
+ }
403
+ /**
404
+ * Replace approvers who are away with their cover.
405
+ *
406
+ * Substitution is transitive up to {@link MAX_OOO_HOPS} so an A→B→C chain of
407
+ * absences still lands on someone present, but a cycle (A covers B while B
408
+ * covers A) simply stops rather than looping — leaving the original approver
409
+ * assigned, which is visible and fixable, unlike a hang.
410
+ *
411
+ * A provider that throws is treated as "no cover known": an HR lookup failing
412
+ * must not block an approval from being routed at all.
413
+ */
414
+ async applyOutOfOffice(userIds, provider, at) {
415
+ if (!provider) return userIds;
416
+ const asOf = at ?? /* @__PURE__ */ new Date();
417
+ const covered = [];
418
+ for (const original of userIds) {
419
+ let current = original;
420
+ const seen = /* @__PURE__ */ new Set([current]);
421
+ for (let hop = 0; hop < MAX_OOO_HOPS; hop++) {
422
+ let delegate;
423
+ try {
424
+ delegate = await provider.getDelegateFor(current, asOf);
425
+ } catch {
426
+ break;
427
+ }
428
+ if (!delegate || delegate === current || seen.has(delegate)) break;
429
+ seen.add(delegate);
430
+ current = delegate;
431
+ }
432
+ covered.push(current);
433
+ }
434
+ return [...new Set(covered)];
335
435
  }
336
436
  };
337
437
 
@@ -855,9 +955,11 @@ var ApprovalEngine = class _ApprovalEngine {
855
955
  /** Validate a template config without persisting. Synchronous; never throws. */
856
956
  validateTemplate(config) {
857
957
  const errors = [];
858
- if (!config.levels || config.levels.length === 0) {
958
+ const hasOwnLevels = Boolean(config.levels && config.levels.length > 0);
959
+ if (!hasOwnLevels && config.extends === void 0) {
859
960
  errors.push({ field: "levels", message: "Template must have at least one level." });
860
- } else {
961
+ }
962
+ if (hasOwnLevels) {
861
963
  const levelNums = /* @__PURE__ */ new Set();
862
964
  config.levels.forEach((l, i) => {
863
965
  if (levelNums.has(l.level)) {
@@ -986,25 +1088,27 @@ var ApprovalEngine = class _ApprovalEngine {
986
1088
  return { valid: errors.length === 0, errors };
987
1089
  }
988
1090
  async defineTemplate(config) {
989
- const validation = this.validateTemplate(config);
1091
+ const resolved = await this.registry.resolveInheritance(config);
1092
+ const validation = this.validateTemplate(resolved);
990
1093
  if (!validation.valid) {
991
1094
  const first = validation.errors[0];
992
1095
  throw new ApprovalValidationError(
993
1096
  `Invalid template configuration: ${first?.message ?? "unknown error"}`
994
1097
  );
995
1098
  }
996
- return this.registry.define(config);
1099
+ return this.registry.define(resolved);
997
1100
  }
998
1101
  /** Update an existing template, incrementing its version. In-flight instances are protected by their templateSnapshot. */
999
1102
  async updateTemplate(config) {
1000
- const validation = this.validateTemplate(config);
1103
+ const resolved = await this.registry.resolveInheritance(config);
1104
+ const validation = this.validateTemplate(resolved);
1001
1105
  if (!validation.valid) {
1002
1106
  const first = validation.errors[0];
1003
1107
  throw new ApprovalValidationError(
1004
1108
  `Invalid template configuration: ${first?.message ?? "unknown error"}`
1005
1109
  );
1006
1110
  }
1007
- return this.registry.update(config);
1111
+ return this.registry.update(resolved);
1008
1112
  }
1009
1113
  async getTemplate(name) {
1010
1114
  return this.registry.get(name);
@@ -1085,7 +1189,9 @@ var ApprovalEngine = class _ApprovalEngine {
1085
1189
  lvl.approverConfigs,
1086
1190
  opts.submittedBy,
1087
1191
  opts.data,
1088
- this.opts.orgProvider
1192
+ this.opts.orgProvider,
1193
+ this.opts.outOfOfficeProvider,
1194
+ now
1089
1195
  );
1090
1196
  }
1091
1197
  const auditEntry = {
@@ -2088,7 +2194,9 @@ var ApprovalEngine = class _ApprovalEngine {
2088
2194
  cfg.approvers,
2089
2195
  submittedBy,
2090
2196
  data,
2091
- this.opts.orgProvider
2197
+ this.opts.orgProvider,
2198
+ this.opts.outOfOfficeProvider,
2199
+ this.clock.now()
2092
2200
  );
2093
2201
  levels.push({ level: cfg.level, name: cfg.name, resolvedApprovers, mode: cfg.mode });
2094
2202
  } catch {
@@ -2467,7 +2575,9 @@ var ApprovalEngine = class _ApprovalEngine {
2467
2575
  [escalationConfig.escalateTo],
2468
2576
  instance.submittedBy,
2469
2577
  instance.data,
2470
- this.opts.orgProvider
2578
+ this.opts.orgProvider,
2579
+ this.opts.outOfOfficeProvider,
2580
+ this.clock.now()
2471
2581
  );
2472
2582
  const filteredApprovers = newApprovers.filter((id) => id !== instance.submittedBy);
2473
2583
  if (filteredApprovers.length === 0) {
@@ -2752,7 +2862,9 @@ var ApprovalEngine = class _ApprovalEngine {
2752
2862
  lvl.approverConfigs,
2753
2863
  instance.submittedBy,
2754
2864
  instance.data,
2755
- this.opts.orgProvider
2865
+ this.opts.orgProvider,
2866
+ this.opts.outOfOfficeProvider,
2867
+ now
2756
2868
  );
2757
2869
  if (lvl.escalationAfterDays) {
2758
2870
  lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);