hierarchical-approval 3.3.0 → 4.0.1

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 (63) hide show
  1. package/CHANGELOG.md +217 -1
  2. package/README.md +22 -1
  3. package/dist/{ApprovalEngine-Cslh4Goz.d.ts → ApprovalEngine-BfKL6iGQ.d.ts} +86 -6
  4. package/dist/{ApprovalEngine-zAgvH_aO.d.cts → ApprovalEngine-DcD6s_jB.d.cts} +86 -6
  5. package/dist/{IAuditAdapter-udkAnnmu.d.ts → IAuditAdapter-DkbyFFjb.d.ts} +1 -1
  6. package/dist/{IAuditAdapter-BkxGx6TI.d.cts → IAuditAdapter-Dt8qjkQL.d.cts} +1 -1
  7. package/dist/{IAuthorizationPolicy-YQlSwj-8.d.cts → IAuthorizationPolicy-YJ_08Mof.d.cts} +1 -1
  8. package/dist/{IAuthorizationPolicy-Z4nkX-bu.d.ts → IAuthorizationPolicy-eo6glbpE.d.ts} +1 -1
  9. package/dist/{INotificationAdapter-C-PL3Pvp.d.ts → INotificationAdapter-BTO9FSKy.d.ts} +1 -1
  10. package/dist/{INotificationAdapter-D3PBQE1B.d.cts → INotificationAdapter-XbD69gss.d.cts} +1 -1
  11. package/dist/{IOperationMiddleware-tgJcKzTP.d.cts → IOperationMiddleware-BBLk8MIz.d.cts} +1 -1
  12. package/dist/{IOperationMiddleware-Cbd05rje.d.ts → IOperationMiddleware-CX7lo4lp.d.ts} +1 -1
  13. package/dist/{IStorageAdapter-CEKfu-U8.d.ts → IStorageAdapter-CdXMggPK.d.ts} +1 -1
  14. package/dist/{IStorageAdapter-DUrmnJUs.d.cts → IStorageAdapter-Cjw5zDwQ.d.cts} +1 -1
  15. package/dist/adapters/MemoryAdapter.cjs.map +1 -1
  16. package/dist/adapters/MemoryAdapter.d.cts +4 -4
  17. package/dist/adapters/MemoryAdapter.d.ts +4 -4
  18. package/dist/adapters/MemoryAdapter.js.map +1 -1
  19. package/dist/adapters/PostgresAdapter.cjs +10 -4
  20. package/dist/adapters/PostgresAdapter.cjs.map +1 -1
  21. package/dist/adapters/PostgresAdapter.d.cts +2 -2
  22. package/dist/adapters/PostgresAdapter.d.ts +2 -2
  23. package/dist/adapters/PostgresAdapter.js +10 -4
  24. package/dist/adapters/PostgresAdapter.js.map +1 -1
  25. package/dist/index.cjs +235 -60
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.d.cts +56 -8
  28. package/dist/index.d.ts +56 -8
  29. package/dist/index.js +235 -60
  30. package/dist/index.js.map +1 -1
  31. package/dist/{instance-BXHxvTLE.d.cts → instance-uUQh5XW3.d.cts} +17 -0
  32. package/dist/{instance-BXHxvTLE.d.ts → instance-uUQh5XW3.d.ts} +17 -0
  33. package/dist/nestjs.cjs +235 -60
  34. package/dist/nestjs.cjs.map +1 -1
  35. package/dist/nestjs.d.cts +7 -7
  36. package/dist/nestjs.d.ts +7 -7
  37. package/dist/nestjs.js +235 -60
  38. package/dist/nestjs.js.map +1 -1
  39. package/dist/plugins/audit.d.cts +2 -2
  40. package/dist/plugins/audit.d.ts +2 -2
  41. package/dist/plugins/notify.d.cts +2 -2
  42. package/dist/plugins/notify.d.ts +2 -2
  43. package/dist/plugins/resilience.cjs +12 -4
  44. package/dist/plugins/resilience.cjs.map +1 -1
  45. package/dist/plugins/resilience.d.cts +3 -3
  46. package/dist/plugins/resilience.d.ts +3 -3
  47. package/dist/plugins/resilience.js +12 -4
  48. package/dist/plugins/resilience.js.map +1 -1
  49. package/dist/plugins/scheduler.cjs +1 -1
  50. package/dist/plugins/scheduler.cjs.map +1 -1
  51. package/dist/plugins/scheduler.js +1 -1
  52. package/dist/plugins/scheduler.js.map +1 -1
  53. package/dist/plugins/tracing.d.cts +2 -2
  54. package/dist/plugins/tracing.d.ts +2 -2
  55. package/dist/plugins/webhook.d.cts +2 -2
  56. package/dist/plugins/webhook.d.ts +2 -2
  57. package/dist/testing.cjs +246 -66
  58. package/dist/testing.cjs.map +1 -1
  59. package/dist/testing.d.cts +7 -7
  60. package/dist/testing.d.ts +7 -7
  61. package/dist/testing.js +246 -66
  62. package/dist/testing.js.map +1 -1
  63. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## 4.0.1 - 2026-09-14
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
7
+
1
8
  # Changelog
2
9
 
3
10
  All notable changes to `hierarchical-approval` are documented here. This project
@@ -5,7 +12,216 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
12
 
6
13
  ## [Unreleased]
7
14
 
8
- _Nothing yet._
15
+ ### Fixed
16
+
17
+ - `InMemorySchedulerAdapter.scheduleAt()`'s post-shutdown error now names the
18
+ rejected job id, so a caller can tell which scheduled job was dropped
19
+ without adding their own logging around every call.
20
+
21
+ ## [4.0.0] - 2026-09-04
22
+
23
+ Six defects fixed across 3.x shared one root cause: `currentLevel` is a single
24
+ number, and it cannot describe the approval frontier once a parallel group has
25
+ several levels open at once. Each fix corrected one more reader of it. This
26
+ release fixes the model instead.
27
+
28
+ ### BREAKING — the frontier is explicit
29
+
30
+ - **`ApprovalInstance.openLevels: number[]`** lists every level currently
31
+ collecting decisions, ascending — one entry for a sequential chain, several
32
+ inside a parallel group, empty once the instance is terminal. It is the
33
+ supported way to ask what an instance is waiting on, and
34
+ `engine.getOpenLevels(id)` returns it without fetching the whole instance.
35
+
36
+ - **`currentLevel` is now derived**, defined as the lowest open level and
37
+ recomputed by the engine on every write. It remains for display and for the
38
+ audit trail, and a terminal instance keeps its last value so the record still
39
+ shows where the request stopped.
40
+
41
+ **Migrating:** anything using `currentLevel` to decide who may act, what to
42
+ notify, or what is overdue should read `openLevels`. On a sequential template
43
+ the two agree, so most callers need no change.
44
+
45
+ - **Custom `IStorageAdapter` implementations must round-trip `openLevels`**, as
46
+ they already must for `levels`. The bundled adapters do; `PostgresAdapter`
47
+ adds an `open_levels` column via `migrate()`.
48
+
49
+ `ApprovalInstance` now requires the field, so a hand-built instance (a test
50
+ fixture, say) will not compile until it supplies one.
51
+
52
+ ### Fixed — returning past a rejected group
53
+
54
+ - **`returnTo: 'previous'` from inside a parallel group went back into that same
55
+ group.** It stepped back from `currentLevel`, which — now that the frontier
56
+ moves as branches close — could be another branch of the group being rejected.
57
+ It now steps back past the whole group, which is what "return to the previous
58
+ step" means when the step has several branches.
59
+
60
+ ### Internal
61
+
62
+ - Every instance write goes through one path that recomputes the frontier first,
63
+ so a future operation cannot persist levels without updating `openLevels`.
64
+ This is the same discipline applied to level construction in 3.0.0 and to the
65
+ PostgreSQL column list in 1.6.0, both of which drifted for the same reason.
66
+
67
+ ## [3.9.0] - 2026-09-04
68
+
69
+ ### Fixed — replacing an approver on a weighted level bricked the approval
70
+
71
+ - **On a weighted level the weight belongs to the seat, not the person — but
72
+ replacing the person dropped it.** `weights` is keyed by approver id, and a
73
+ substitute was not in the map, so they fell back to the default weight of 1.
74
+
75
+ A CFO carrying weight 3 on a level with `threshold: 3` who was reassigned,
76
+ delegated, or covered by out-of-office left the level's achievable weight at
77
+ 2. The next decision then threw
78
+
79
+ > `Level 1 ("Exec") needs a weight of 3 but the assigned approvers total only 2.`
80
+
81
+ and **nobody could approve the instance again**. `transferApprovals()` did this
82
+ in bulk, which made offboarding — the exact situation the feature exists for —
83
+ the most likely way to hit it.
84
+
85
+ A substitute now inherits the weight of the approver they replace, through
86
+ `reassign()`, `delegate()` and out-of-office cover alike. The original's weight
87
+ is copied rather than moved, so a vote they had already cast keeps counting.
88
+
89
+ Nothing is invented: an approver who never carried a weight still gets the
90
+ default, and escalation continues to *add* approvers at the default weight
91
+ rather than inheriting anyone's.
92
+
93
+ ## [3.8.0] - 2026-09-04
94
+
95
+ ### Fixed — a condition could build a parallel group the validator forbids
96
+
97
+ - **`validateTemplate()` checked group contiguity across the static levels
98
+ only.** A condition whose `addLevels` joined an existing group from further
99
+ down the chain therefore passed validation and then built, at runtime,
100
+ precisely the interleaving the rule exists to forbid.
101
+
102
+ With levels 1–2 in group `review` and an ungrouped level 3, a rule adding
103
+ level 4 to `review` produced a chain where levels 1, 2 **and 4** were all open
104
+ while level 3 sat waiting — the group had jumped over a level that was meant
105
+ to come first, and the template author's ordering was silently inverted.
106
+
107
+ Contiguity is now checked across every level a template can produce, static
108
+ and condition-added alike. A group whose members are interleaved with anything
109
+ else is rejected at definition time, where the author can see it.
110
+
111
+ ## [3.7.0] - 2026-09-04
112
+
113
+ ### Fixed — `override()` and expiry did not end the sub-workflow family
114
+
115
+ 3.0.0 made a finished parent stop its children and report to its own parent,
116
+ but wired that into `approve()`, `reject()` and `cancel()` only. `override()`
117
+ and deadline expiry are terminal too, and were missed — so the fix was
118
+ incomplete in exactly the two paths nobody watches.
119
+
120
+ - **An expired child left its parent waiting forever.** The child reached
121
+ `cancelled`, but never told the parent, which sat pending on an approval that
122
+ could now never happen. A permanent deadlock, and the more serious of the two.
123
+
124
+ - **An overridden parent left its children running** — still notifying, still
125
+ escalating, still in `getWorkload()`, for a decision whose outcome nobody
126
+ would read.
127
+
128
+ Both now run the same post-decision step as every other terminal path.
129
+ `override()` continues to bypass the parent's own remaining levels, which is
130
+ what an administrative override is for; what changes is that it no longer
131
+ strands the work it started.
132
+
133
+ ## [3.6.0] - 2026-09-04
134
+
135
+ ### Fixed — `resubmit()` rebuilt an incomplete chain
136
+
137
+ - **A resubmitted approval could never finish.** `resubmit()` was a third
138
+ hand-written copy of level construction, after `submit()` and
139
+ `recomputeFutureChain()` (unified in 3.0.0), and the only one that decided
140
+ what to open by **array index** rather than by group. On a template whose
141
+ chain begins with a parallel group, resubmitting opened just the first branch
142
+ and left the rest of the group behind.
143
+
144
+ It also dropped `group`, `subWorkflow`, `escalationAfterHours` and every
145
+ reminder field — so a sub-workflow level came back unbound and threw
146
+ "No approvers resolved for this level" the moment the chain reached it,
147
+ leaving the approval permanently stuck.
148
+
149
+ All three paths now go through the same `buildLevelInstance()`.
150
+
151
+ ### Changed — the release audit gate distinguishes a flaky endpoint from a real CVE
152
+
153
+ - `npm audit` exits non-zero both for "found an advisory" and for "could not
154
+ reach the advisory endpoint", and the v3.1.0 publish was blocked by the
155
+ latter. The CI and publish workflows now retry **only** the transient case,
156
+ with backoff.
157
+
158
+ A genuine high/critical advisory still fails on the first attempt, and an
159
+ endpoint that stays unreachable still fails the build — refusing to publish
160
+ unaudited, rather than passing silently the way an `|| true` would.
161
+
162
+ ## [3.5.0] - 2026-09-04
163
+
164
+ ### Fixed — an approval could complete with a branch rejected and another never decided
165
+
166
+ **The most serious defect found in this audit. Upgrade if you use
167
+ `returnTo: 'previous'` with parallel branch groups.**
168
+
169
+ Rejecting one branch of a parallel group with `returnTo: 'previous'` sent the
170
+ chain back a level but left the rest of the group as it was — the rejected
171
+ branch still `rejected`, its sibling still `pending`. Neither was `waiting`, and
172
+ the engine treats "no waiting level" as "nothing left to do". So when the
173
+ earlier level was approved again, the instance was marked **`approved`**:
174
+
175
+ - with one branch **rejected**, and
176
+ - with another branch **nobody had ever decided**.
177
+
178
+ A document could therefore reach fully-approved without Finance ever approving
179
+ it and over Legal's explicit rejection.
180
+
181
+ Two independent fixes, because one of them should never have been needed:
182
+
183
+ - **Returning to a previous level now resets every level above it** to a clean
184
+ `waiting` state, clearing decisions, approvers and deadlines so the chain
185
+ replays properly. A branch that had already approved must decide again — its
186
+ approval was for a version that was sent back.
187
+
188
+ - **Completion now requires that every level actually be `approved` or
189
+ `skipped`.** "No next group" alone is not enough. If the two ever disagree the
190
+ engine throws `INCOMPLETE_CHAIN` naming the offending levels, rather than
191
+ recording an approval nobody gave. This is a tripwire that should be
192
+ unreachable — it exists because the state it catches was reachable.
193
+
194
+ `override()` is unaffected: bypassing the remaining levels is exactly what an
195
+ administrative override is for.
196
+
197
+ ## [3.4.0] - 2026-09-04
198
+
199
+ ### Fixed — notifications went to the wrong people
200
+
201
+ `NotificationEvent.recipients` is what an adapter actually delivers to, and it
202
+ was read from the single level matching `instance.currentLevel`. That was wrong
203
+ in two directions at once.
204
+
205
+ - **An approver on any branch of a parallel group except the lowest was never
206
+ notified.** They were not told work had arrived, not told the instance
207
+ advanced — nothing. Combined with the inbox bug fixed in 3.3.0, an
208
+ upper-branch approver had no way at all to learn they owed a decision.
209
+ Recipients are now the union across every open level, matching what
210
+ `getCurrentApprovers()` has returned since 1.0.0.
211
+
212
+ - **An event that already named its own audience had it overwritten.** A comment
213
+ mentioning one person was delivered to the current level's approvers instead
214
+ of the person mentioned; a reminder aimed at those who had not yet voted, and
215
+ a clarification request addressed to the submitter, were redirected the same
216
+ way. An event that names recipients now keeps them.
217
+
218
+ This had been latent since mentions were added in 2.8.0: the payload carried
219
+ the right audience and the adapter boundary discarded it.
220
+
221
+ - **`ApprovalTestKit.fullyApprove()` could not drive a parallel group.** It read
222
+ `instance.currentLevel`, which does not move until the whole group closes, so
223
+ it approved the lowest branch and then re-offered the same decision until the
224
+ engine refused it. It now walks every open level.
9
225
 
10
226
  ## [3.3.0] - 2026-09-04
11
227
 
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-404%20passing-44cc11.svg?logo=vitest&logoColor=white)](./tests)
14
+ [![tests](https://img.shields.io/badge/tests-1204%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) ·
@@ -878,6 +878,27 @@ never change who can approve or when the level escalates.
878
878
 
879
879
  Requires the escalation scheduler to be running (it is by default).
880
880
 
881
+ ### Reading the approval frontier
882
+
883
+ `instance.openLevels` lists every level currently collecting decisions —
884
+ one entry for a sequential chain, several inside a parallel group, none once the
885
+ instance is terminal:
886
+
887
+ ```ts
888
+ const { openLevels, currentLevel } = await engine.getInstance(id);
889
+ // sequential: openLevels [2], currentLevel 2
890
+ // parallel group: openLevels [2, 3], currentLevel 2
891
+ await engine.getOpenLevels(id); // same list, without the whole instance
892
+ ```
893
+
894
+ **`currentLevel` names only the lowest open level.** That is the whole frontier
895
+ for a sequential chain and one branch of a parallel group, so use `openLevels`
896
+ to decide who may act, what to notify, or what is overdue. Reading `currentLevel`
897
+ as the frontier was the root cause of six defects fixed across 3.x.
898
+
899
+ Both are maintained by the engine; `currentLevel` is derived from `openLevels`
900
+ and recomputed on every write.
901
+
881
902
  ### Parallel branch groups
882
903
 
883
904
  Give levels the same `group` name and they activate together, joining before
@@ -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-CEKfu-U8.js';
2
- import { m as ConditionExpression, r as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, k as AuditContext, a as ApprovalInstance, C as Comment, e as ApprovalMode, f as ApprovalStatus, L as LevelStatus, b as AuditEntry } from './instance-BXHxvTLE.js';
3
- import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-C-PL3Pvp.js';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-CdXMggPK.js';
2
+ import { m as ConditionExpression, r as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, k as AuditContext, a as ApprovalInstance, C as Comment, e as ApprovalMode, f as ApprovalStatus, L as LevelStatus, b as AuditEntry } from './instance-uUQh5XW3.js';
3
+ import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-BTO9FSKy.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-Cbd05rje.js';
8
- import { I as IAuditAdapter } from './IAuditAdapter-udkAnnmu.js';
7
+ import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-CX7lo4lp.js';
8
+ import { I as IAuditAdapter } from './IAuditAdapter-DkbyFFjb.js';
9
9
  import { I as IMetricsAdapter } from './IMetricsAdapter-DWq8IFaf.js';
10
10
  import { I as ISchedulerAdapter } from './ISchedulerAdapter-DKv_QjVN.js';
11
- import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-Z4nkX-bu.js';
11
+ import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-eo6glbpE.js';
12
12
 
13
13
  declare const SubmitOptionsSchema: z.ZodObject<{
14
14
  templateName: z.ZodString;
@@ -856,6 +856,17 @@ declare class ApprovalEngine {
856
856
  queryInstances(filter: InstanceFilter, opts?: PaginationOpts): Promise<PaginatedResult<ApprovalInstance>>;
857
857
  queryInstancesByCursor(filter: InstanceFilter, opts: CursorPaginationOpts): Promise<CursorPaginatedResult<ApprovalInstance>>;
858
858
  getHistory(instanceId: string): Promise<AuditEntry[]>;
859
+ /**
860
+ * The levels currently collecting decisions, ascending.
861
+ *
862
+ * The supported way to ask what an instance is waiting on.
863
+ * {@link ApprovalInstance.currentLevel} names only the lowest of these, which
864
+ * is the whole frontier for a sequential chain and one branch of it inside a
865
+ * parallel group.
866
+ *
867
+ * Empty once the instance is terminal.
868
+ */
869
+ getOpenLevels(instanceId: string): Promise<number[]>;
859
870
  getCurrentApprovers(instanceId: string): Promise<string[]>;
860
871
  /** Check adapter connectivity and escalation scheduler health. */
861
872
  healthCheck(): Promise<HealthResult>;
@@ -946,6 +957,7 @@ declare class ApprovalEngine {
946
957
  dryRun?: boolean;
947
958
  }): Promise<PurgeResult>;
948
959
  getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
960
+ /** Stops the escalation scheduler and any scheduler-adapter background loop; call once when tearing the engine down. */
949
961
  shutdown(): Promise<void>;
950
962
  /**
951
963
  * Schedules the next escalation scan via {@link ApprovalEngineOptions.schedulerAdapter}.
@@ -1008,6 +1020,42 @@ declare class ApprovalEngine {
1008
1020
  * @param opts - `open` activates the level now, computing deadlines from `now`.
1009
1021
  */
1010
1022
  private buildLevelInstance;
1023
+ /**
1024
+ * Carry a weighted level's vote weight from one approver to another.
1025
+ *
1026
+ * On a weighted level the weight belongs to the **seat**, not to the person
1027
+ * filling it. Without this, replacing an approver silently dropped their
1028
+ * weight to the default of 1: a CFO carrying weight 3 who was reassigned,
1029
+ * delegated or covered while away left the level unable to reach its
1030
+ * threshold, and the next decision threw "needs a weight of 3 but the
1031
+ * assigned approvers total only 2" — an approval nobody could complete.
1032
+ *
1033
+ * A no-op unless the level actually carries weights and the original had one.
1034
+ */
1035
+ private inheritWeight;
1036
+ /** Apply {@link inheritWeight} for every out-of-office substitution made on a level. */
1037
+ private inheritWeightsForSubstitutions;
1038
+ /**
1039
+ * Recompute the approval frontier from the levels themselves.
1040
+ *
1041
+ * `openLevels` is every level currently collecting decisions, and
1042
+ * `currentLevel` is the lowest of them. Deriving both from one place means
1043
+ * they cannot drift out of step with the levels — the failure behind six
1044
+ * defects fixed across 3.x, each of which read `currentLevel` as though it
1045
+ * named the whole frontier.
1046
+ *
1047
+ * A terminal instance has no open levels; `currentLevel` keeps its last value
1048
+ * so the audit trail and any UI still show where it finished.
1049
+ */
1050
+ private syncFrontier;
1051
+ /**
1052
+ * The single write path for an instance.
1053
+ *
1054
+ * Every mutation goes through here so the frontier is recomputed before the
1055
+ * instance is stored. Writing through the adapter directly would let a new
1056
+ * operation persist levels without updating `openLevels`.
1057
+ */
1058
+ private persistInstance;
1011
1059
  /** Level deadline from whichever of days/hours the template configured. */
1012
1060
  private levelEscalationDue;
1013
1061
  /** First rung of a ladder, sorted by delay, or undefined when there is none. */
@@ -1133,7 +1181,39 @@ declare class ApprovalEngine {
1133
1181
  */
1134
1182
  private cancelOrphanedChildren;
1135
1183
  private afterDecision;
1184
+ /**
1185
+ * Return every level above `levelNumber` to a clean `waiting` state.
1186
+ *
1187
+ * Used when a rejection sends the chain back: a level that keeps a stale
1188
+ * `approved`/`rejected` status is neither open nor replayable, and the engine
1189
+ * treats "nothing waiting" as "nothing left to do".
1190
+ */
1191
+ private resetLevelsAfter;
1192
+ /**
1193
+ * Whether an instance has genuinely finished approving.
1194
+ *
1195
+ * "No next group" is not the same as "every level approved": a level left in
1196
+ * a stale state is neither open nor waiting, and treating that as completion
1197
+ * marked instances approved with a branch still undecided. Completion now
1198
+ * requires that every level actually reached a positive terminal state.
1199
+ */
1200
+ private isFullyApproved;
1201
+ /**
1202
+ * Refuse to complete an instance that has not actually been approved throughout.
1203
+ *
1204
+ * A tripwire, not a routine check: it should be unreachable, and it exists
1205
+ * because the state it catches was reachable and silently produced an
1206
+ * approved document that one branch had rejected and another had never seen.
1207
+ */
1208
+ private assertFullyApproved;
1136
1209
  private findNextLevel;
1210
+ /**
1211
+ * The level to send an instance back to, given the level being rejected.
1212
+ *
1213
+ * Sends the chain back past the **whole group** the rejection came from, not
1214
+ * merely one level. Returning into a sibling branch of the same group would
1215
+ * put the instance back inside the step it was just rejected at.
1216
+ */
1137
1217
  private findPreviousLevel;
1138
1218
  private guardBulkSize;
1139
1219
  private runAuthorizationPolicy;
@@ -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-DUrmnJUs.cjs';
2
- import { m as ConditionExpression, r as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, k as AuditContext, a as ApprovalInstance, C as Comment, e as ApprovalMode, f as ApprovalStatus, L as LevelStatus, b as AuditEntry } from './instance-BXHxvTLE.cjs';
3
- import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-D3PBQE1B.cjs';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-Cjw5zDwQ.cjs';
2
+ import { m as ConditionExpression, r as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, k as AuditContext, a as ApprovalInstance, C as Comment, e as ApprovalMode, f as ApprovalStatus, L as LevelStatus, b as AuditEntry } from './instance-uUQh5XW3.cjs';
3
+ import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-XbD69gss.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-tgJcKzTP.cjs';
8
- import { I as IAuditAdapter } from './IAuditAdapter-BkxGx6TI.cjs';
7
+ import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-BBLk8MIz.cjs';
8
+ import { I as IAuditAdapter } from './IAuditAdapter-Dt8qjkQL.cjs';
9
9
  import { I as IMetricsAdapter } from './IMetricsAdapter-DWq8IFaf.cjs';
10
10
  import { I as ISchedulerAdapter } from './ISchedulerAdapter-DKv_QjVN.cjs';
11
- import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-YQlSwj-8.cjs';
11
+ import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-YJ_08Mof.cjs';
12
12
 
13
13
  declare const SubmitOptionsSchema: z.ZodObject<{
14
14
  templateName: z.ZodString;
@@ -856,6 +856,17 @@ declare class ApprovalEngine {
856
856
  queryInstances(filter: InstanceFilter, opts?: PaginationOpts): Promise<PaginatedResult<ApprovalInstance>>;
857
857
  queryInstancesByCursor(filter: InstanceFilter, opts: CursorPaginationOpts): Promise<CursorPaginatedResult<ApprovalInstance>>;
858
858
  getHistory(instanceId: string): Promise<AuditEntry[]>;
859
+ /**
860
+ * The levels currently collecting decisions, ascending.
861
+ *
862
+ * The supported way to ask what an instance is waiting on.
863
+ * {@link ApprovalInstance.currentLevel} names only the lowest of these, which
864
+ * is the whole frontier for a sequential chain and one branch of it inside a
865
+ * parallel group.
866
+ *
867
+ * Empty once the instance is terminal.
868
+ */
869
+ getOpenLevels(instanceId: string): Promise<number[]>;
859
870
  getCurrentApprovers(instanceId: string): Promise<string[]>;
860
871
  /** Check adapter connectivity and escalation scheduler health. */
861
872
  healthCheck(): Promise<HealthResult>;
@@ -946,6 +957,7 @@ declare class ApprovalEngine {
946
957
  dryRun?: boolean;
947
958
  }): Promise<PurgeResult>;
948
959
  getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
960
+ /** Stops the escalation scheduler and any scheduler-adapter background loop; call once when tearing the engine down. */
949
961
  shutdown(): Promise<void>;
950
962
  /**
951
963
  * Schedules the next escalation scan via {@link ApprovalEngineOptions.schedulerAdapter}.
@@ -1008,6 +1020,42 @@ declare class ApprovalEngine {
1008
1020
  * @param opts - `open` activates the level now, computing deadlines from `now`.
1009
1021
  */
1010
1022
  private buildLevelInstance;
1023
+ /**
1024
+ * Carry a weighted level's vote weight from one approver to another.
1025
+ *
1026
+ * On a weighted level the weight belongs to the **seat**, not to the person
1027
+ * filling it. Without this, replacing an approver silently dropped their
1028
+ * weight to the default of 1: a CFO carrying weight 3 who was reassigned,
1029
+ * delegated or covered while away left the level unable to reach its
1030
+ * threshold, and the next decision threw "needs a weight of 3 but the
1031
+ * assigned approvers total only 2" — an approval nobody could complete.
1032
+ *
1033
+ * A no-op unless the level actually carries weights and the original had one.
1034
+ */
1035
+ private inheritWeight;
1036
+ /** Apply {@link inheritWeight} for every out-of-office substitution made on a level. */
1037
+ private inheritWeightsForSubstitutions;
1038
+ /**
1039
+ * Recompute the approval frontier from the levels themselves.
1040
+ *
1041
+ * `openLevels` is every level currently collecting decisions, and
1042
+ * `currentLevel` is the lowest of them. Deriving both from one place means
1043
+ * they cannot drift out of step with the levels — the failure behind six
1044
+ * defects fixed across 3.x, each of which read `currentLevel` as though it
1045
+ * named the whole frontier.
1046
+ *
1047
+ * A terminal instance has no open levels; `currentLevel` keeps its last value
1048
+ * so the audit trail and any UI still show where it finished.
1049
+ */
1050
+ private syncFrontier;
1051
+ /**
1052
+ * The single write path for an instance.
1053
+ *
1054
+ * Every mutation goes through here so the frontier is recomputed before the
1055
+ * instance is stored. Writing through the adapter directly would let a new
1056
+ * operation persist levels without updating `openLevels`.
1057
+ */
1058
+ private persistInstance;
1011
1059
  /** Level deadline from whichever of days/hours the template configured. */
1012
1060
  private levelEscalationDue;
1013
1061
  /** First rung of a ladder, sorted by delay, or undefined when there is none. */
@@ -1133,7 +1181,39 @@ declare class ApprovalEngine {
1133
1181
  */
1134
1182
  private cancelOrphanedChildren;
1135
1183
  private afterDecision;
1184
+ /**
1185
+ * Return every level above `levelNumber` to a clean `waiting` state.
1186
+ *
1187
+ * Used when a rejection sends the chain back: a level that keeps a stale
1188
+ * `approved`/`rejected` status is neither open nor replayable, and the engine
1189
+ * treats "nothing waiting" as "nothing left to do".
1190
+ */
1191
+ private resetLevelsAfter;
1192
+ /**
1193
+ * Whether an instance has genuinely finished approving.
1194
+ *
1195
+ * "No next group" is not the same as "every level approved": a level left in
1196
+ * a stale state is neither open nor waiting, and treating that as completion
1197
+ * marked instances approved with a branch still undecided. Completion now
1198
+ * requires that every level actually reached a positive terminal state.
1199
+ */
1200
+ private isFullyApproved;
1201
+ /**
1202
+ * Refuse to complete an instance that has not actually been approved throughout.
1203
+ *
1204
+ * A tripwire, not a routine check: it should be unreachable, and it exists
1205
+ * because the state it catches was reachable and silently produced an
1206
+ * approved document that one branch had rejected and another had never seen.
1207
+ */
1208
+ private assertFullyApproved;
1136
1209
  private findNextLevel;
1210
+ /**
1211
+ * The level to send an instance back to, given the level being rejected.
1212
+ *
1213
+ * Sends the chain back past the **whole group** the rejection came from, not
1214
+ * merely one level. Returning into a sibling branch of the same group would
1215
+ * put the instance back inside the step it was just rejected at.
1216
+ */
1137
1217
  private findPreviousLevel;
1138
1218
  private guardBulkSize;
1139
1219
  private runAuthorizationPolicy;
@@ -1,4 +1,4 @@
1
- import { b as AuditEntry, a as ApprovalInstance } from './instance-BXHxvTLE.js';
1
+ import { b as AuditEntry, a as ApprovalInstance } from './instance-uUQh5XW3.js';
2
2
 
3
3
  interface IAuditAdapter {
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { b as AuditEntry, a as ApprovalInstance } from './instance-BXHxvTLE.cjs';
1
+ import { b as AuditEntry, a as ApprovalInstance } from './instance-uUQh5XW3.cjs';
2
2
 
3
3
  interface IAuditAdapter {
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-BXHxvTLE.cjs';
1
+ import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-uUQh5XW3.cjs';
2
2
 
3
3
  interface AuthorizationContext {
4
4
  operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment' | 'updateData' | 'requestInfo' | 'provideInfo' | 'addAttachment' | 'removeAttachment';
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-BXHxvTLE.js';
1
+ import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-uUQh5XW3.js';
2
2
 
3
3
  interface AuthorizationContext {
4
4
  operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment' | 'updateData' | 'requestInfo' | 'provideInfo' | 'addAttachment' | 'removeAttachment';
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, b as AuditEntry } from './instance-BXHxvTLE.js';
1
+ import { a as ApprovalInstance, b as AuditEntry } from './instance-uUQh5XW3.js';
2
2
 
3
3
  interface ApprovalEvent {
4
4
  instanceId: string;
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, b as AuditEntry } from './instance-BXHxvTLE.cjs';
1
+ import { a as ApprovalInstance, b as AuditEntry } from './instance-uUQh5XW3.cjs';
2
2
 
3
3
  interface ApprovalEvent {
4
4
  instanceId: string;
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance } from './instance-BXHxvTLE.cjs';
1
+ import { a as ApprovalInstance } from './instance-uUQh5XW3.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-BXHxvTLE.js';
1
+ import { a as ApprovalInstance } from './instance-uUQh5XW3.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-BXHxvTLE.js';
1
+ import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-uUQh5XW3.js';
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-BXHxvTLE.cjs';
1
+ import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-uUQh5XW3.cjs';
2
2
 
3
3
  interface PaginationOpts {
4
4
  limit: number;