hierarchical-approval 4.0.1 → 4.0.2

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 (37) hide show
  1. package/CHANGELOG.md +18 -5
  2. package/README.md +1 -1
  3. package/dist/{ApprovalEngine-BfKL6iGQ.d.ts → ApprovalEngine-Dqe-rC6K.d.ts} +188 -1
  4. package/dist/{ApprovalEngine-DcD6s_jB.d.cts → ApprovalEngine-XgGBQLC_.d.cts} +188 -1
  5. package/dist/adapters/MemoryAdapter.cjs.map +1 -1
  6. package/dist/adapters/MemoryAdapter.d.cts +18 -0
  7. package/dist/adapters/MemoryAdapter.d.ts +18 -0
  8. package/dist/adapters/MemoryAdapter.js.map +1 -1
  9. package/dist/adapters/PostgresAdapter.cjs.map +1 -1
  10. package/dist/adapters/PostgresAdapter.d.cts +16 -0
  11. package/dist/adapters/PostgresAdapter.d.ts +16 -0
  12. package/dist/adapters/PostgresAdapter.js.map +1 -1
  13. package/dist/index.cjs +238 -18
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +238 -18
  18. package/dist/index.js.map +1 -1
  19. package/dist/nestjs.cjs +238 -18
  20. package/dist/nestjs.cjs.map +1 -1
  21. package/dist/nestjs.d.cts +1 -1
  22. package/dist/nestjs.d.ts +1 -1
  23. package/dist/nestjs.js +238 -18
  24. package/dist/nestjs.js.map +1 -1
  25. package/dist/plugins/notify.cjs +3 -1
  26. package/dist/plugins/notify.cjs.map +1 -1
  27. package/dist/plugins/notify.d.cts +2 -0
  28. package/dist/plugins/notify.d.ts +2 -0
  29. package/dist/plugins/notify.js +3 -1
  30. package/dist/plugins/notify.js.map +1 -1
  31. package/dist/testing.cjs +250 -18
  32. package/dist/testing.cjs.map +1 -1
  33. package/dist/testing.d.cts +22 -1
  34. package/dist/testing.d.ts +22 -1
  35. package/dist/testing.js +250 -18
  36. package/dist/testing.js.map +1 -1
  37. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,9 +1,8 @@
1
- ## 4.0.1 - 2026-09-14
1
+ ## 4.0.2 - 2026-09-20
2
2
 
3
- - fix(resilience): include the offending value in RateLimitMiddleware errors
4
- - fix(scheduler): include the job id in InMemorySchedulerAdapter's post-shutdown error
5
- - fix(postgres): test the null-rowCount fallback via a dedicated fake pool
6
- - fix(postgres): let FakePool pass an explicit null rowCount through
3
+ - fix(notify): name the offending value in DigestNotificationAdapter's error
4
+ - fix(engine): stop leaking a listener when on() double-registers it
5
+ - fix(webhook): give the real-timer sleep test slack for CI jitter
7
6
 
8
7
  # Changelog
9
8
 
@@ -14,9 +13,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
13
 
15
14
  ### Fixed
16
15
 
16
+ - `EventBus` (and therefore `engine.on()`/`off()`) no longer leaks a listener
17
+ when the same function is registered twice with `on()`: each registration
18
+ now needs its own `off()` call to undo, instead of the second registration
19
+ silently overwriting the bookkeeping for the first and leaving it permanently
20
+ attached.
21
+ - `DigestNotificationAdapter`'s `intervalMs` validation error now names the
22
+ offending value, instead of a message that gave no clue which value failed.
23
+
24
+ ## [4.0.1] - 2026-09-14
25
+
26
+ ### Fixed
27
+
17
28
  - `InMemorySchedulerAdapter.scheduleAt()`'s post-shutdown error now names the
18
29
  rejected job id, so a caller can tell which scheduled job was dropped
19
30
  without adding their own logging around every call.
31
+ - `RateLimitMiddleware` errors now include the offending value that tripped
32
+ the limit, instead of a message that named the limit but not the input.
20
33
 
21
34
  ## [4.0.0] - 2026-09-04
22
35
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ Multi-tenant · audit-ready · fully pluggable · zero runtime dependencies you
11
11
  [![types](https://img.shields.io/npm/types/hierarchical-approval.svg?logo=typescript&logoColor=white&color=3178c6)](https://www.typescriptlang.org/)
12
12
  [![minzipped size](https://img.shields.io/bundlephobia/minzip/hierarchical-approval?color=44cc11)](https://bundlephobia.com/package/hierarchical-approval)
13
13
  [![license](https://img.shields.io/npm/l/hierarchical-approval.svg?color=blue)](./LICENSE)
14
- [![tests](https://img.shields.io/badge/tests-1204%20passing-44cc11.svg?logo=vitest&logoColor=white)](./tests)
14
+ [![tests](https://img.shields.io/badge/tests-1256%20passing-44cc11.svg?logo=vitest&logoColor=white)](./tests)
15
15
 
16
16
  [**Documentation**](https://hierarchical-approval.matthewswong.com) ·
17
17
  [**npm**](https://www.npmjs.com/package/hierarchical-approval) ·
@@ -625,18 +625,71 @@ declare class ApprovalEngine {
625
625
  /** Set by {@link shutdown}; stops the self-rescheduling loop from scheduling another tick. */
626
626
  private schedulerStopped;
627
627
  constructor(opts: ApprovalEngineOptions);
628
+ /**
629
+ * Subscribe to a lifecycle event (see {@link ApprovalEventMap} for the full
630
+ * catalog and each payload's shape). A listener that throws, or returns a
631
+ * rejecting promise, cannot abort the operation that emitted the event or
632
+ * block the remaining listeners — the failure is reported via the engine's
633
+ * logger instead.
634
+ *
635
+ * @returns `this`, for chaining multiple subscriptions.
636
+ */
628
637
  on<K extends ApprovalEventName>(event: K, listener: (payload: ApprovalEventMap[K]) => void): this;
638
+ /**
639
+ * Unsubscribe a listener previously registered with {@link on}. A no-op if
640
+ * `listener` was never registered for `event`.
641
+ *
642
+ * @returns `this`, for chaining.
643
+ */
629
644
  off<K extends ApprovalEventName>(event: K, listener: (payload: ApprovalEventMap[K]) => void): this;
645
+ /**
646
+ * Register a dynamic-approver resolver under `name`, for use by a level whose
647
+ * approver config is `{ type: 'dynamic', resolver: name }`. Overwrites any
648
+ * resolver already registered under the same name.
649
+ */
630
650
  registerResolver(name: string, fn: ResolverFn): void;
651
+ /**
652
+ * Register a custom approver type under `typeName`, for use by a level whose
653
+ * approver config carries `{ type: typeName, ... }`. Overwrites any approver
654
+ * type already registered under the same type name.
655
+ */
631
656
  registerApproverType(typeName: string, fn: ApproverResolverFn): void;
657
+ /**
658
+ * Register a custom condition operator under `name`, for use by template
659
+ * condition rules whose `operator` is `name`. Operators are shared process-wide
660
+ * across every engine instance, not scoped to this tenant.
661
+ *
662
+ * @throws {@link ApprovalValidationError} if `name` is already taken by a
663
+ * built-in operator (`>`, `<`, `>=`, `<=`, `==`, `!=`, `in`, `not_in`).
664
+ */
632
665
  registerConditionOperator(name: string, fn: ConditionOperatorFn): void;
633
666
  /** Validate a template config without persisting. Synchronous; never throws. */
634
667
  validateTemplate(config: ApprovalTemplateConfig): ValidationResult;
635
668
  defineTemplate(config: ApprovalTemplateConfig): Promise<string>;
636
669
  /** Update an existing template, incrementing its version. In-flight instances are protected by their templateSnapshot. */
637
670
  updateTemplate(config: ApprovalTemplateConfig): Promise<string>;
671
+ /**
672
+ * Fetch the current version of a defined template.
673
+ *
674
+ * @throws {@link ApprovalTemplateNotFoundError} if no template is registered under `name`.
675
+ */
638
676
  getTemplate(name: string): Promise<ApprovalTemplate>;
677
+ /** List every template currently defined for this tenant. */
639
678
  listTemplates(): Promise<ApprovalTemplate[]>;
679
+ /**
680
+ * Start a new approval instance from a defined template.
681
+ *
682
+ * Evaluates the template's conditions against `raw.data` first, so the
683
+ * levels an instance opens with can differ from the template's static
684
+ * declaration (added, skipped, or reordered). If a prior call with the
685
+ * same tenant, document, template and data produced a non-terminal
686
+ * instance, that instance is returned unchanged instead of creating a
687
+ * duplicate — safe to retry on network failure.
688
+ *
689
+ * @throws {@link ApprovalTemplateNotFoundError} if `raw.templateName` is not defined.
690
+ * @throws {@link ApprovalValidationError} if `raw` fails schema validation, or if
691
+ * condition evaluation leaves no levels (or a duplicate level number) to open.
692
+ */
640
693
  submit(raw: SubmitOptions, auditCtx?: AuditContext,
641
694
  /**
642
695
  * @internal Set only when the engine spawns a sub-workflow child. Passed at
@@ -649,8 +702,63 @@ declare class ApprovalEngine {
649
702
  parentLevel: number;
650
703
  depth: number;
651
704
  }): Promise<ApprovalInstance>;
705
+ /**
706
+ * Record `opts.approverId`'s approval of their open level on `instanceId`.
707
+ *
708
+ * When more than one level is open in parallel and the approver sits on
709
+ * exactly one of them, that level is inferred; pass `opts.level` explicitly
710
+ * when it must be disambiguated (e.g. the approver sits on several).
711
+ *
712
+ * @throws {@link ApprovalNotFoundError} if `instanceId` does not exist.
713
+ * @throws {@link ApprovalError} (code `INVALID_STATUS`) if the instance is not `pending`,
714
+ * (code `INVALID_LEVEL`) if no level (or the explicit `opts.level`) is awaiting a decision,
715
+ * or (code `ALREADY_ACTED`) if this approver already approved or rejected that level.
716
+ * @throws {@link ApprovalForbiddenError} if `opts.approverId` submitted the instance
717
+ * (self-approval), is not an approver on the resolved level, or is denied by the
718
+ * configured {@link IAuthorizationPolicy}.
719
+ * @throws {@link ApprovalValidationError} if `opts.approverId` sits on more than one
720
+ * open parallel level and `opts.level` was not supplied to disambiguate.
721
+ */
652
722
  approve(instanceId: string, raw: ApproveOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
723
+ /**
724
+ * Record `opts.approverId`'s rejection of their open level on `instanceId`.
725
+ *
726
+ * A single rejection does not necessarily fail the level — under `'quorum'`
727
+ * or `'weighted'` mode it only fails once the remaining approvers can no
728
+ * longer reach threshold. When the level does fail, `opts.returnTo`
729
+ * controls what happens next: `'originator'` (default) fails the whole
730
+ * instance; `'previous'` resets every level after the prior one and sends
731
+ * the instance back there to replay.
732
+ *
733
+ * @throws {@link ApprovalNotFoundError} if `instanceId` does not exist.
734
+ * @throws {@link ApprovalError} (code `INVALID_STATUS`) if the instance is not `pending`,
735
+ * (code `INVALID_LEVEL`) if no level (or the explicit `opts.level`) is awaiting a decision,
736
+ * or (code `ALREADY_ACTED`) if this approver already approved or rejected that level.
737
+ * @throws {@link ApprovalForbiddenError} if `opts.approverId` submitted the instance
738
+ * (self-rejection), is not an approver on the resolved level, or is denied by the
739
+ * configured {@link IAuthorizationPolicy}.
740
+ * @throws {@link ApprovalValidationError} if `opts.approverId` sits on more than one
741
+ * open parallel level with `opts.level` unset, or if `opts.returnTo === 'previous'`
742
+ * but the failing level is already the first level in the chain.
743
+ */
653
744
  reject(instanceId: string, raw: RejectOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
745
+ /**
746
+ * Reassign `opts.fromApprover`'s seat on their open level to `opts.toApprover`,
747
+ * carrying across any 'weighted'-mode vote weight. When `opts.until` is set,
748
+ * the level records `delegatedFrom`/`delegatedTo`/`delegatedUntil`, and the
749
+ * scheduled escalation tick reassigns the seat back to `opts.fromApprover`
750
+ * once that time passes (still pending); omit `opts.until` for a permanent
751
+ * handoff.
752
+ *
753
+ * @throws {@link ApprovalNotFoundError} if `instanceId` does not exist.
754
+ * @throws {@link ApprovalError} (code `INVALID_STATUS`) if the instance is not `pending`,
755
+ * or (code `INVALID_LEVEL`) if no level (or the explicit `opts.level`) is awaiting a decision.
756
+ * @throws {@link ApprovalForbiddenError} if `opts.fromApprover` equals `opts.toApprover`,
757
+ * is not an approver on the resolved level, already acted on it, or is denied by the
758
+ * configured {@link IAuthorizationPolicy}.
759
+ * @throws {@link ApprovalValidationError} if `opts.fromApprover` sits on more than one
760
+ * open parallel level and `opts.level` was not supplied to disambiguate.
761
+ */
654
762
  delegate(instanceId: string, raw: DelegateOptions, auditCtx?: AuditContext): Promise<void>;
655
763
  /**
656
764
  * Administratively replace an approver on the current level. Unlike delegate(),
@@ -660,7 +768,36 @@ declare class ApprovalEngine {
660
768
  * cannot be reassigned.
661
769
  */
662
770
  reassign(instanceId: string, raw: ReassignOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
771
+ /**
772
+ * Withdraw an approval before it reaches a final decision.
773
+ *
774
+ * Cancelling cascades: any sub-workflow children spawned by this instance
775
+ * are cancelled in turn (an orphaned child left running would eventually
776
+ * report back to a parent that already moved on), and if this instance is
777
+ * itself a sub-workflow child, its own parent is unblocked as if this
778
+ * approval had been rejected.
779
+ *
780
+ * @param instanceId - The instance to cancel.
781
+ * @param raw - Who is cancelling and why.
782
+ * @param auditCtx - Optional compliance context recorded on the audit entry.
783
+ * @returns The cancelled instance.
784
+ * @throws ApprovalError if the instance is already `approved` or `rejected`.
785
+ */
663
786
  cancel(instanceId: string, raw: CancelOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
787
+ /**
788
+ * Manually escalate the instance's current (lowest pending) level to the
789
+ * next rung of its escalation ladder, adding whatever the rung's
790
+ * `escalateTo` resolves to as extra approvers on that level. This is the
791
+ * same path the scheduled tick uses for a deadline-driven escalation — call
792
+ * it directly to escalate on demand instead of waiting for the ladder timer.
793
+ *
794
+ * A no-op (returns the instance unchanged, does not throw) when: the
795
+ * instance is not `pending`; the ladder has no rung configured for the
796
+ * current step; or every resolved approver turns out to be the submitter
797
+ * (nobody new to add).
798
+ *
799
+ * @throws {@link ApprovalNotFoundError} if `instanceId` does not exist.
800
+ */
664
801
  escalate(instanceId: string, raw: EscalateOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
665
802
  /**
666
803
  * Change an instance's document data while it is still pending, and
@@ -822,6 +959,17 @@ declare class ApprovalEngine {
822
959
  submittedBy: string;
823
960
  decisions?: SimulatedDecision[];
824
961
  }): Promise<SimulationResult>;
962
+ /**
963
+ * Check whether a user could approve an instance right now, without acting.
964
+ *
965
+ * The read-only counterpart to calling {@link approve} and handling the
966
+ * rejection — useful for deciding whether to show an approve button at all.
967
+ * `eligible: false` never throws; the reason names why: the instance is not
968
+ * `pending`, the user submitted it, they hold no open level (or delegated
969
+ * theirs away), or they already voted on it.
970
+ *
971
+ * @returns `{ eligible: true }`, or `{ eligible: false, reason }`.
972
+ */
825
973
  canApprove(instanceId: string, userId: string): Promise<CanApproveResult>;
826
974
  /** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
827
975
  override(instanceId: string, raw: OverrideOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
@@ -848,13 +996,34 @@ declare class ApprovalEngine {
848
996
  * @returns What moved, what did not, and why. With `dryRun` nothing is written.
849
997
  */
850
998
  transferApprovals(raw: TransferApprovalsOptions, auditCtx?: AuditContext): Promise<TransferResult>;
999
+ /** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
851
1000
  bulkApprove(instanceIds: string[], raw: ApproveOptions, auditCtx?: AuditContext): Promise<BulkResult>;
852
1001
  /** Reject multiple instances in one call. Never throws — failures collected in result.failed. */
853
1002
  bulkReject(instanceIds: string[], raw: RejectOptions, auditCtx?: AuditContext): Promise<BulkResult>;
1003
+ /**
1004
+ * Fetch a single instance by id.
1005
+ *
1006
+ * @throws {@link ApprovalNotFoundError} if no instance exists with that id.
1007
+ */
854
1008
  getInstance(instanceId: string): Promise<ApprovalInstance>;
1009
+ /** Page through every instance with an open level assigned to one approver. */
855
1010
  getPendingFor(approverId: string, opts?: PaginationOpts): Promise<PaginatedResult<ApprovalInstance>>;
1011
+ /** Page through instances matching an arbitrary filter (status, document type, date range, ...). */
856
1012
  queryInstances(filter: InstanceFilter, opts?: PaginationOpts): Promise<PaginatedResult<ApprovalInstance>>;
1013
+ /**
1014
+ * Cursor-paginated variant of {@link queryInstances}, for stable paging
1015
+ * through large or actively-changing result sets that offset pagination
1016
+ * would skip or duplicate rows in.
1017
+ *
1018
+ * @throws {@link ApprovalError} (`NOT_SUPPORTED`) if the configured storage
1019
+ * adapter does not implement `getInstancesByCursor`.
1020
+ */
857
1021
  queryInstancesByCursor(filter: InstanceFilter, opts: CursorPaginationOpts): Promise<CursorPaginatedResult<ApprovalInstance>>;
1022
+ /**
1023
+ * The full audit trail for an instance, in the order it was recorded.
1024
+ *
1025
+ * @throws {@link ApprovalNotFoundError} if no instance exists with that id.
1026
+ */
858
1027
  getHistory(instanceId: string): Promise<AuditEntry[]>;
859
1028
  /**
860
1029
  * The levels currently collecting decisions, ascending.
@@ -867,6 +1036,16 @@ declare class ApprovalEngine {
867
1036
  * Empty once the instance is terminal.
868
1037
  */
869
1038
  getOpenLevels(instanceId: string): Promise<number[]>;
1039
+ /**
1040
+ * Everyone who currently owes a decision on an instance.
1041
+ *
1042
+ * A union across every open branch, so a parallel group with more than one
1043
+ * level collecting decisions at once still names every approver, not just
1044
+ * the one at {@link ApprovalInstance.currentLevel}. Empty once the instance
1045
+ * is no longer `pending`.
1046
+ *
1047
+ * @throws {@link ApprovalNotFoundError} if no instance exists with that id.
1048
+ */
870
1049
  getCurrentApprovers(instanceId: string): Promise<string[]>;
871
1050
  /** Check adapter connectivity and escalation scheduler health. */
872
1051
  healthCheck(): Promise<HealthResult>;
@@ -956,6 +1135,15 @@ declare class ApprovalEngine {
956
1135
  limit?: number;
957
1136
  dryRun?: boolean;
958
1137
  }): Promise<PurgeResult>;
1138
+ /**
1139
+ * Aggregate reporting numbers for a tenant: counts by status, approval
1140
+ * rate, cycle-time stats, and a per-template breakdown.
1141
+ *
1142
+ * `filter.status` is ignored — the breakdown covers every status by
1143
+ * design, so scoping it to one would make the counts misleading.
1144
+ *
1145
+ * @param filter - Optional scoping by document type, submitter, or date range.
1146
+ */
959
1147
  getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
960
1148
  /** Stops the escalation scheduler and any scheduler-adapter background loop; call once when tearing the engine down. */
961
1149
  shutdown(): Promise<void>;
@@ -1206,7 +1394,6 @@ declare class ApprovalEngine {
1206
1394
  * approved document that one branch had rejected and another had never seen.
1207
1395
  */
1208
1396
  private assertFullyApproved;
1209
- private findNextLevel;
1210
1397
  /**
1211
1398
  * The level to send an instance back to, given the level being rejected.
1212
1399
  *
@@ -625,18 +625,71 @@ declare class ApprovalEngine {
625
625
  /** Set by {@link shutdown}; stops the self-rescheduling loop from scheduling another tick. */
626
626
  private schedulerStopped;
627
627
  constructor(opts: ApprovalEngineOptions);
628
+ /**
629
+ * Subscribe to a lifecycle event (see {@link ApprovalEventMap} for the full
630
+ * catalog and each payload's shape). A listener that throws, or returns a
631
+ * rejecting promise, cannot abort the operation that emitted the event or
632
+ * block the remaining listeners — the failure is reported via the engine's
633
+ * logger instead.
634
+ *
635
+ * @returns `this`, for chaining multiple subscriptions.
636
+ */
628
637
  on<K extends ApprovalEventName>(event: K, listener: (payload: ApprovalEventMap[K]) => void): this;
638
+ /**
639
+ * Unsubscribe a listener previously registered with {@link on}. A no-op if
640
+ * `listener` was never registered for `event`.
641
+ *
642
+ * @returns `this`, for chaining.
643
+ */
629
644
  off<K extends ApprovalEventName>(event: K, listener: (payload: ApprovalEventMap[K]) => void): this;
645
+ /**
646
+ * Register a dynamic-approver resolver under `name`, for use by a level whose
647
+ * approver config is `{ type: 'dynamic', resolver: name }`. Overwrites any
648
+ * resolver already registered under the same name.
649
+ */
630
650
  registerResolver(name: string, fn: ResolverFn): void;
651
+ /**
652
+ * Register a custom approver type under `typeName`, for use by a level whose
653
+ * approver config carries `{ type: typeName, ... }`. Overwrites any approver
654
+ * type already registered under the same type name.
655
+ */
631
656
  registerApproverType(typeName: string, fn: ApproverResolverFn): void;
657
+ /**
658
+ * Register a custom condition operator under `name`, for use by template
659
+ * condition rules whose `operator` is `name`. Operators are shared process-wide
660
+ * across every engine instance, not scoped to this tenant.
661
+ *
662
+ * @throws {@link ApprovalValidationError} if `name` is already taken by a
663
+ * built-in operator (`>`, `<`, `>=`, `<=`, `==`, `!=`, `in`, `not_in`).
664
+ */
632
665
  registerConditionOperator(name: string, fn: ConditionOperatorFn): void;
633
666
  /** Validate a template config without persisting. Synchronous; never throws. */
634
667
  validateTemplate(config: ApprovalTemplateConfig): ValidationResult;
635
668
  defineTemplate(config: ApprovalTemplateConfig): Promise<string>;
636
669
  /** Update an existing template, incrementing its version. In-flight instances are protected by their templateSnapshot. */
637
670
  updateTemplate(config: ApprovalTemplateConfig): Promise<string>;
671
+ /**
672
+ * Fetch the current version of a defined template.
673
+ *
674
+ * @throws {@link ApprovalTemplateNotFoundError} if no template is registered under `name`.
675
+ */
638
676
  getTemplate(name: string): Promise<ApprovalTemplate>;
677
+ /** List every template currently defined for this tenant. */
639
678
  listTemplates(): Promise<ApprovalTemplate[]>;
679
+ /**
680
+ * Start a new approval instance from a defined template.
681
+ *
682
+ * Evaluates the template's conditions against `raw.data` first, so the
683
+ * levels an instance opens with can differ from the template's static
684
+ * declaration (added, skipped, or reordered). If a prior call with the
685
+ * same tenant, document, template and data produced a non-terminal
686
+ * instance, that instance is returned unchanged instead of creating a
687
+ * duplicate — safe to retry on network failure.
688
+ *
689
+ * @throws {@link ApprovalTemplateNotFoundError} if `raw.templateName` is not defined.
690
+ * @throws {@link ApprovalValidationError} if `raw` fails schema validation, or if
691
+ * condition evaluation leaves no levels (or a duplicate level number) to open.
692
+ */
640
693
  submit(raw: SubmitOptions, auditCtx?: AuditContext,
641
694
  /**
642
695
  * @internal Set only when the engine spawns a sub-workflow child. Passed at
@@ -649,8 +702,63 @@ declare class ApprovalEngine {
649
702
  parentLevel: number;
650
703
  depth: number;
651
704
  }): Promise<ApprovalInstance>;
705
+ /**
706
+ * Record `opts.approverId`'s approval of their open level on `instanceId`.
707
+ *
708
+ * When more than one level is open in parallel and the approver sits on
709
+ * exactly one of them, that level is inferred; pass `opts.level` explicitly
710
+ * when it must be disambiguated (e.g. the approver sits on several).
711
+ *
712
+ * @throws {@link ApprovalNotFoundError} if `instanceId` does not exist.
713
+ * @throws {@link ApprovalError} (code `INVALID_STATUS`) if the instance is not `pending`,
714
+ * (code `INVALID_LEVEL`) if no level (or the explicit `opts.level`) is awaiting a decision,
715
+ * or (code `ALREADY_ACTED`) if this approver already approved or rejected that level.
716
+ * @throws {@link ApprovalForbiddenError} if `opts.approverId` submitted the instance
717
+ * (self-approval), is not an approver on the resolved level, or is denied by the
718
+ * configured {@link IAuthorizationPolicy}.
719
+ * @throws {@link ApprovalValidationError} if `opts.approverId` sits on more than one
720
+ * open parallel level and `opts.level` was not supplied to disambiguate.
721
+ */
652
722
  approve(instanceId: string, raw: ApproveOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
723
+ /**
724
+ * Record `opts.approverId`'s rejection of their open level on `instanceId`.
725
+ *
726
+ * A single rejection does not necessarily fail the level — under `'quorum'`
727
+ * or `'weighted'` mode it only fails once the remaining approvers can no
728
+ * longer reach threshold. When the level does fail, `opts.returnTo`
729
+ * controls what happens next: `'originator'` (default) fails the whole
730
+ * instance; `'previous'` resets every level after the prior one and sends
731
+ * the instance back there to replay.
732
+ *
733
+ * @throws {@link ApprovalNotFoundError} if `instanceId` does not exist.
734
+ * @throws {@link ApprovalError} (code `INVALID_STATUS`) if the instance is not `pending`,
735
+ * (code `INVALID_LEVEL`) if no level (or the explicit `opts.level`) is awaiting a decision,
736
+ * or (code `ALREADY_ACTED`) if this approver already approved or rejected that level.
737
+ * @throws {@link ApprovalForbiddenError} if `opts.approverId` submitted the instance
738
+ * (self-rejection), is not an approver on the resolved level, or is denied by the
739
+ * configured {@link IAuthorizationPolicy}.
740
+ * @throws {@link ApprovalValidationError} if `opts.approverId` sits on more than one
741
+ * open parallel level with `opts.level` unset, or if `opts.returnTo === 'previous'`
742
+ * but the failing level is already the first level in the chain.
743
+ */
653
744
  reject(instanceId: string, raw: RejectOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
745
+ /**
746
+ * Reassign `opts.fromApprover`'s seat on their open level to `opts.toApprover`,
747
+ * carrying across any 'weighted'-mode vote weight. When `opts.until` is set,
748
+ * the level records `delegatedFrom`/`delegatedTo`/`delegatedUntil`, and the
749
+ * scheduled escalation tick reassigns the seat back to `opts.fromApprover`
750
+ * once that time passes (still pending); omit `opts.until` for a permanent
751
+ * handoff.
752
+ *
753
+ * @throws {@link ApprovalNotFoundError} if `instanceId` does not exist.
754
+ * @throws {@link ApprovalError} (code `INVALID_STATUS`) if the instance is not `pending`,
755
+ * or (code `INVALID_LEVEL`) if no level (or the explicit `opts.level`) is awaiting a decision.
756
+ * @throws {@link ApprovalForbiddenError} if `opts.fromApprover` equals `opts.toApprover`,
757
+ * is not an approver on the resolved level, already acted on it, or is denied by the
758
+ * configured {@link IAuthorizationPolicy}.
759
+ * @throws {@link ApprovalValidationError} if `opts.fromApprover` sits on more than one
760
+ * open parallel level and `opts.level` was not supplied to disambiguate.
761
+ */
654
762
  delegate(instanceId: string, raw: DelegateOptions, auditCtx?: AuditContext): Promise<void>;
655
763
  /**
656
764
  * Administratively replace an approver on the current level. Unlike delegate(),
@@ -660,7 +768,36 @@ declare class ApprovalEngine {
660
768
  * cannot be reassigned.
661
769
  */
662
770
  reassign(instanceId: string, raw: ReassignOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
771
+ /**
772
+ * Withdraw an approval before it reaches a final decision.
773
+ *
774
+ * Cancelling cascades: any sub-workflow children spawned by this instance
775
+ * are cancelled in turn (an orphaned child left running would eventually
776
+ * report back to a parent that already moved on), and if this instance is
777
+ * itself a sub-workflow child, its own parent is unblocked as if this
778
+ * approval had been rejected.
779
+ *
780
+ * @param instanceId - The instance to cancel.
781
+ * @param raw - Who is cancelling and why.
782
+ * @param auditCtx - Optional compliance context recorded on the audit entry.
783
+ * @returns The cancelled instance.
784
+ * @throws ApprovalError if the instance is already `approved` or `rejected`.
785
+ */
663
786
  cancel(instanceId: string, raw: CancelOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
787
+ /**
788
+ * Manually escalate the instance's current (lowest pending) level to the
789
+ * next rung of its escalation ladder, adding whatever the rung's
790
+ * `escalateTo` resolves to as extra approvers on that level. This is the
791
+ * same path the scheduled tick uses for a deadline-driven escalation — call
792
+ * it directly to escalate on demand instead of waiting for the ladder timer.
793
+ *
794
+ * A no-op (returns the instance unchanged, does not throw) when: the
795
+ * instance is not `pending`; the ladder has no rung configured for the
796
+ * current step; or every resolved approver turns out to be the submitter
797
+ * (nobody new to add).
798
+ *
799
+ * @throws {@link ApprovalNotFoundError} if `instanceId` does not exist.
800
+ */
664
801
  escalate(instanceId: string, raw: EscalateOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
665
802
  /**
666
803
  * Change an instance's document data while it is still pending, and
@@ -822,6 +959,17 @@ declare class ApprovalEngine {
822
959
  submittedBy: string;
823
960
  decisions?: SimulatedDecision[];
824
961
  }): Promise<SimulationResult>;
962
+ /**
963
+ * Check whether a user could approve an instance right now, without acting.
964
+ *
965
+ * The read-only counterpart to calling {@link approve} and handling the
966
+ * rejection — useful for deciding whether to show an approve button at all.
967
+ * `eligible: false` never throws; the reason names why: the instance is not
968
+ * `pending`, the user submitted it, they hold no open level (or delegated
969
+ * theirs away), or they already voted on it.
970
+ *
971
+ * @returns `{ eligible: true }`, or `{ eligible: false, reason }`.
972
+ */
825
973
  canApprove(instanceId: string, userId: string): Promise<CanApproveResult>;
826
974
  /** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
827
975
  override(instanceId: string, raw: OverrideOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
@@ -848,13 +996,34 @@ declare class ApprovalEngine {
848
996
  * @returns What moved, what did not, and why. With `dryRun` nothing is written.
849
997
  */
850
998
  transferApprovals(raw: TransferApprovalsOptions, auditCtx?: AuditContext): Promise<TransferResult>;
999
+ /** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
851
1000
  bulkApprove(instanceIds: string[], raw: ApproveOptions, auditCtx?: AuditContext): Promise<BulkResult>;
852
1001
  /** Reject multiple instances in one call. Never throws — failures collected in result.failed. */
853
1002
  bulkReject(instanceIds: string[], raw: RejectOptions, auditCtx?: AuditContext): Promise<BulkResult>;
1003
+ /**
1004
+ * Fetch a single instance by id.
1005
+ *
1006
+ * @throws {@link ApprovalNotFoundError} if no instance exists with that id.
1007
+ */
854
1008
  getInstance(instanceId: string): Promise<ApprovalInstance>;
1009
+ /** Page through every instance with an open level assigned to one approver. */
855
1010
  getPendingFor(approverId: string, opts?: PaginationOpts): Promise<PaginatedResult<ApprovalInstance>>;
1011
+ /** Page through instances matching an arbitrary filter (status, document type, date range, ...). */
856
1012
  queryInstances(filter: InstanceFilter, opts?: PaginationOpts): Promise<PaginatedResult<ApprovalInstance>>;
1013
+ /**
1014
+ * Cursor-paginated variant of {@link queryInstances}, for stable paging
1015
+ * through large or actively-changing result sets that offset pagination
1016
+ * would skip or duplicate rows in.
1017
+ *
1018
+ * @throws {@link ApprovalError} (`NOT_SUPPORTED`) if the configured storage
1019
+ * adapter does not implement `getInstancesByCursor`.
1020
+ */
857
1021
  queryInstancesByCursor(filter: InstanceFilter, opts: CursorPaginationOpts): Promise<CursorPaginatedResult<ApprovalInstance>>;
1022
+ /**
1023
+ * The full audit trail for an instance, in the order it was recorded.
1024
+ *
1025
+ * @throws {@link ApprovalNotFoundError} if no instance exists with that id.
1026
+ */
858
1027
  getHistory(instanceId: string): Promise<AuditEntry[]>;
859
1028
  /**
860
1029
  * The levels currently collecting decisions, ascending.
@@ -867,6 +1036,16 @@ declare class ApprovalEngine {
867
1036
  * Empty once the instance is terminal.
868
1037
  */
869
1038
  getOpenLevels(instanceId: string): Promise<number[]>;
1039
+ /**
1040
+ * Everyone who currently owes a decision on an instance.
1041
+ *
1042
+ * A union across every open branch, so a parallel group with more than one
1043
+ * level collecting decisions at once still names every approver, not just
1044
+ * the one at {@link ApprovalInstance.currentLevel}. Empty once the instance
1045
+ * is no longer `pending`.
1046
+ *
1047
+ * @throws {@link ApprovalNotFoundError} if no instance exists with that id.
1048
+ */
870
1049
  getCurrentApprovers(instanceId: string): Promise<string[]>;
871
1050
  /** Check adapter connectivity and escalation scheduler health. */
872
1051
  healthCheck(): Promise<HealthResult>;
@@ -956,6 +1135,15 @@ declare class ApprovalEngine {
956
1135
  limit?: number;
957
1136
  dryRun?: boolean;
958
1137
  }): Promise<PurgeResult>;
1138
+ /**
1139
+ * Aggregate reporting numbers for a tenant: counts by status, approval
1140
+ * rate, cycle-time stats, and a per-template breakdown.
1141
+ *
1142
+ * `filter.status` is ignored — the breakdown covers every status by
1143
+ * design, so scoping it to one would make the counts misleading.
1144
+ *
1145
+ * @param filter - Optional scoping by document type, submitter, or date range.
1146
+ */
959
1147
  getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
960
1148
  /** Stops the escalation scheduler and any scheduler-adapter background loop; call once when tearing the engine down. */
961
1149
  shutdown(): Promise<void>;
@@ -1206,7 +1394,6 @@ declare class ApprovalEngine {
1206
1394
  * approved document that one branch had rejected and another had never seen.
1207
1395
  */
1208
1396
  private assertFullyApproved;
1209
- private findNextLevel;
1210
1397
  /**
1211
1398
  * The level to send an instance back to, given the level being rejected.
1212
1399
  *