hierarchical-approval 3.2.0 → 4.0.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 (57) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +21 -0
  3. package/dist/{ApprovalEngine-Cslh4Goz.d.ts → ApprovalEngine-D3RKWQfD.d.ts} +85 -6
  4. package/dist/{ApprovalEngine-zAgvH_aO.d.cts → ApprovalEngine-DDp4Ep1B.d.cts} +85 -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 +1 -2
  16. package/dist/adapters/MemoryAdapter.cjs.map +1 -1
  17. package/dist/adapters/MemoryAdapter.d.cts +2 -2
  18. package/dist/adapters/MemoryAdapter.d.ts +2 -2
  19. package/dist/adapters/MemoryAdapter.js +1 -2
  20. package/dist/adapters/MemoryAdapter.js.map +1 -1
  21. package/dist/adapters/PostgresAdapter.cjs +10 -4
  22. package/dist/adapters/PostgresAdapter.cjs.map +1 -1
  23. package/dist/adapters/PostgresAdapter.d.cts +2 -2
  24. package/dist/adapters/PostgresAdapter.d.ts +2 -2
  25. package/dist/adapters/PostgresAdapter.js +10 -4
  26. package/dist/adapters/PostgresAdapter.js.map +1 -1
  27. package/dist/index.cjs +216 -62
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +8 -8
  30. package/dist/index.d.ts +8 -8
  31. package/dist/index.js +216 -62
  32. package/dist/index.js.map +1 -1
  33. package/dist/{instance-BXHxvTLE.d.cts → instance-uUQh5XW3.d.cts} +17 -0
  34. package/dist/{instance-BXHxvTLE.d.ts → instance-uUQh5XW3.d.ts} +17 -0
  35. package/dist/nestjs.cjs +216 -62
  36. package/dist/nestjs.cjs.map +1 -1
  37. package/dist/nestjs.d.cts +7 -7
  38. package/dist/nestjs.d.ts +7 -7
  39. package/dist/nestjs.js +216 -62
  40. package/dist/nestjs.js.map +1 -1
  41. package/dist/plugins/audit.d.cts +2 -2
  42. package/dist/plugins/audit.d.ts +2 -2
  43. package/dist/plugins/notify.d.cts +2 -2
  44. package/dist/plugins/notify.d.ts +2 -2
  45. package/dist/plugins/resilience.d.cts +3 -3
  46. package/dist/plugins/resilience.d.ts +3 -3
  47. package/dist/plugins/tracing.d.cts +2 -2
  48. package/dist/plugins/tracing.d.ts +2 -2
  49. package/dist/plugins/webhook.d.cts +2 -2
  50. package/dist/plugins/webhook.d.ts +2 -2
  51. package/dist/testing.cjs +227 -68
  52. package/dist/testing.cjs.map +1 -1
  53. package/dist/testing.d.cts +7 -7
  54. package/dist/testing.d.ts +7 -7
  55. package/dist/testing.js +227 -68
  56. package/dist/testing.js.map +1 -1
  57. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,235 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [4.0.0] - 2026-09-04
11
+
12
+ Six defects fixed across 3.x shared one root cause: `currentLevel` is a single
13
+ number, and it cannot describe the approval frontier once a parallel group has
14
+ several levels open at once. Each fix corrected one more reader of it. This
15
+ release fixes the model instead.
16
+
17
+ ### BREAKING — the frontier is explicit
18
+
19
+ - **`ApprovalInstance.openLevels: number[]`** lists every level currently
20
+ collecting decisions, ascending — one entry for a sequential chain, several
21
+ inside a parallel group, empty once the instance is terminal. It is the
22
+ supported way to ask what an instance is waiting on, and
23
+ `engine.getOpenLevels(id)` returns it without fetching the whole instance.
24
+
25
+ - **`currentLevel` is now derived**, defined as the lowest open level and
26
+ recomputed by the engine on every write. It remains for display and for the
27
+ audit trail, and a terminal instance keeps its last value so the record still
28
+ shows where the request stopped.
29
+
30
+ **Migrating:** anything using `currentLevel` to decide who may act, what to
31
+ notify, or what is overdue should read `openLevels`. On a sequential template
32
+ the two agree, so most callers need no change.
33
+
34
+ - **Custom `IStorageAdapter` implementations must round-trip `openLevels`**, as
35
+ they already must for `levels`. The bundled adapters do; `PostgresAdapter`
36
+ adds an `open_levels` column via `migrate()`.
37
+
38
+ `ApprovalInstance` now requires the field, so a hand-built instance (a test
39
+ fixture, say) will not compile until it supplies one.
40
+
41
+ ### Fixed — returning past a rejected group
42
+
43
+ - **`returnTo: 'previous'` from inside a parallel group went back into that same
44
+ group.** It stepped back from `currentLevel`, which — now that the frontier
45
+ moves as branches close — could be another branch of the group being rejected.
46
+ It now steps back past the whole group, which is what "return to the previous
47
+ step" means when the step has several branches.
48
+
49
+ ### Internal
50
+
51
+ - Every instance write goes through one path that recomputes the frontier first,
52
+ so a future operation cannot persist levels without updating `openLevels`.
53
+ This is the same discipline applied to level construction in 3.0.0 and to the
54
+ PostgreSQL column list in 1.6.0, both of which drifted for the same reason.
55
+
56
+ ## [3.9.0] - 2026-09-04
57
+
58
+ ### Fixed — replacing an approver on a weighted level bricked the approval
59
+
60
+ - **On a weighted level the weight belongs to the seat, not the person — but
61
+ replacing the person dropped it.** `weights` is keyed by approver id, and a
62
+ substitute was not in the map, so they fell back to the default weight of 1.
63
+
64
+ A CFO carrying weight 3 on a level with `threshold: 3` who was reassigned,
65
+ delegated, or covered by out-of-office left the level's achievable weight at
66
+ 2. The next decision then threw
67
+
68
+ > `Level 1 ("Exec") needs a weight of 3 but the assigned approvers total only 2.`
69
+
70
+ and **nobody could approve the instance again**. `transferApprovals()` did this
71
+ in bulk, which made offboarding — the exact situation the feature exists for —
72
+ the most likely way to hit it.
73
+
74
+ A substitute now inherits the weight of the approver they replace, through
75
+ `reassign()`, `delegate()` and out-of-office cover alike. The original's weight
76
+ is copied rather than moved, so a vote they had already cast keeps counting.
77
+
78
+ Nothing is invented: an approver who never carried a weight still gets the
79
+ default, and escalation continues to *add* approvers at the default weight
80
+ rather than inheriting anyone's.
81
+
82
+ ## [3.8.0] - 2026-09-04
83
+
84
+ ### Fixed — a condition could build a parallel group the validator forbids
85
+
86
+ - **`validateTemplate()` checked group contiguity across the static levels
87
+ only.** A condition whose `addLevels` joined an existing group from further
88
+ down the chain therefore passed validation and then built, at runtime,
89
+ precisely the interleaving the rule exists to forbid.
90
+
91
+ With levels 1–2 in group `review` and an ungrouped level 3, a rule adding
92
+ level 4 to `review` produced a chain where levels 1, 2 **and 4** were all open
93
+ while level 3 sat waiting — the group had jumped over a level that was meant
94
+ to come first, and the template author's ordering was silently inverted.
95
+
96
+ Contiguity is now checked across every level a template can produce, static
97
+ and condition-added alike. A group whose members are interleaved with anything
98
+ else is rejected at definition time, where the author can see it.
99
+
100
+ ## [3.7.0] - 2026-09-04
101
+
102
+ ### Fixed — `override()` and expiry did not end the sub-workflow family
103
+
104
+ 3.0.0 made a finished parent stop its children and report to its own parent,
105
+ but wired that into `approve()`, `reject()` and `cancel()` only. `override()`
106
+ and deadline expiry are terminal too, and were missed — so the fix was
107
+ incomplete in exactly the two paths nobody watches.
108
+
109
+ - **An expired child left its parent waiting forever.** The child reached
110
+ `cancelled`, but never told the parent, which sat pending on an approval that
111
+ could now never happen. A permanent deadlock, and the more serious of the two.
112
+
113
+ - **An overridden parent left its children running** — still notifying, still
114
+ escalating, still in `getWorkload()`, for a decision whose outcome nobody
115
+ would read.
116
+
117
+ Both now run the same post-decision step as every other terminal path.
118
+ `override()` continues to bypass the parent's own remaining levels, which is
119
+ what an administrative override is for; what changes is that it no longer
120
+ strands the work it started.
121
+
122
+ ## [3.6.0] - 2026-09-04
123
+
124
+ ### Fixed — `resubmit()` rebuilt an incomplete chain
125
+
126
+ - **A resubmitted approval could never finish.** `resubmit()` was a third
127
+ hand-written copy of level construction, after `submit()` and
128
+ `recomputeFutureChain()` (unified in 3.0.0), and the only one that decided
129
+ what to open by **array index** rather than by group. On a template whose
130
+ chain begins with a parallel group, resubmitting opened just the first branch
131
+ and left the rest of the group behind.
132
+
133
+ It also dropped `group`, `subWorkflow`, `escalationAfterHours` and every
134
+ reminder field — so a sub-workflow level came back unbound and threw
135
+ "No approvers resolved for this level" the moment the chain reached it,
136
+ leaving the approval permanently stuck.
137
+
138
+ All three paths now go through the same `buildLevelInstance()`.
139
+
140
+ ### Changed — the release audit gate distinguishes a flaky endpoint from a real CVE
141
+
142
+ - `npm audit` exits non-zero both for "found an advisory" and for "could not
143
+ reach the advisory endpoint", and the v3.1.0 publish was blocked by the
144
+ latter. The CI and publish workflows now retry **only** the transient case,
145
+ with backoff.
146
+
147
+ A genuine high/critical advisory still fails on the first attempt, and an
148
+ endpoint that stays unreachable still fails the build — refusing to publish
149
+ unaudited, rather than passing silently the way an `|| true` would.
150
+
151
+ ## [3.5.0] - 2026-09-04
152
+
153
+ ### Fixed — an approval could complete with a branch rejected and another never decided
154
+
155
+ **The most serious defect found in this audit. Upgrade if you use
156
+ `returnTo: 'previous'` with parallel branch groups.**
157
+
158
+ Rejecting one branch of a parallel group with `returnTo: 'previous'` sent the
159
+ chain back a level but left the rest of the group as it was — the rejected
160
+ branch still `rejected`, its sibling still `pending`. Neither was `waiting`, and
161
+ the engine treats "no waiting level" as "nothing left to do". So when the
162
+ earlier level was approved again, the instance was marked **`approved`**:
163
+
164
+ - with one branch **rejected**, and
165
+ - with another branch **nobody had ever decided**.
166
+
167
+ A document could therefore reach fully-approved without Finance ever approving
168
+ it and over Legal's explicit rejection.
169
+
170
+ Two independent fixes, because one of them should never have been needed:
171
+
172
+ - **Returning to a previous level now resets every level above it** to a clean
173
+ `waiting` state, clearing decisions, approvers and deadlines so the chain
174
+ replays properly. A branch that had already approved must decide again — its
175
+ approval was for a version that was sent back.
176
+
177
+ - **Completion now requires that every level actually be `approved` or
178
+ `skipped`.** "No next group" alone is not enough. If the two ever disagree the
179
+ engine throws `INCOMPLETE_CHAIN` naming the offending levels, rather than
180
+ recording an approval nobody gave. This is a tripwire that should be
181
+ unreachable — it exists because the state it catches was reachable.
182
+
183
+ `override()` is unaffected: bypassing the remaining levels is exactly what an
184
+ administrative override is for.
185
+
186
+ ## [3.4.0] - 2026-09-04
187
+
188
+ ### Fixed — notifications went to the wrong people
189
+
190
+ `NotificationEvent.recipients` is what an adapter actually delivers to, and it
191
+ was read from the single level matching `instance.currentLevel`. That was wrong
192
+ in two directions at once.
193
+
194
+ - **An approver on any branch of a parallel group except the lowest was never
195
+ notified.** They were not told work had arrived, not told the instance
196
+ advanced — nothing. Combined with the inbox bug fixed in 3.3.0, an
197
+ upper-branch approver had no way at all to learn they owed a decision.
198
+ Recipients are now the union across every open level, matching what
199
+ `getCurrentApprovers()` has returned since 1.0.0.
200
+
201
+ - **An event that already named its own audience had it overwritten.** A comment
202
+ mentioning one person was delivered to the current level's approvers instead
203
+ of the person mentioned; a reminder aimed at those who had not yet voted, and
204
+ a clarification request addressed to the submitter, were redirected the same
205
+ way. An event that names recipients now keeps them.
206
+
207
+ This had been latent since mentions were added in 2.8.0: the payload carried
208
+ the right audience and the adapter boundary discarded it.
209
+
210
+ - **`ApprovalTestKit.fullyApprove()` could not drive a parallel group.** It read
211
+ `instance.currentLevel`, which does not move until the whole group closes, so
212
+ it approved the lowest branch and then re-offered the same decision until the
213
+ engine refused it. It now walks every open level.
214
+
215
+ ## [3.3.0] - 2026-09-04
216
+
217
+ ### Fixed — an approver on an upper parallel branch had an empty inbox
218
+
219
+ **Affects `MemoryAdapter` only; `PostgresAdapter` was already correct**, so the
220
+ same deployment behaved differently depending on which adapter was in use.
221
+
222
+ - **`getInstancesByApprover` matched only the level equal to
223
+ `instance.currentLevel`.** That names a single level, so inside a parallel
224
+ group it identifies just the lowest branch. An approver assigned to any branch
225
+ above it was invisible:
226
+
227
+ - **`getPendingFor()` returned nothing for them** — their own queue was empty
228
+ while they held open work, so they had no way to know it was theirs.
229
+ - **`transferApprovals()` scanned nothing for them**, silently leaving a
230
+ departing colleague's upper-branch approvals behind. That is exactly the
231
+ "missing one leaves an approval that can never complete" failure the sweep
232
+ was built to prevent.
233
+
234
+ It now matches any open level, as `PostgresAdapter` always did.
235
+
236
+ This is the third place `currentLevel` was used as though it named the whole
237
+ open frontier — after escalation in 1.1.0 and `delegate`/`reassign` in 1.8.0.
238
+
10
239
  ## [3.2.0] - 2026-09-04
11
240
 
12
241
  ### Fixed — `getStatistics().overdue` ignored most of its filter
package/README.md CHANGED
@@ -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>;
@@ -1008,6 +1019,42 @@ declare class ApprovalEngine {
1008
1019
  * @param opts - `open` activates the level now, computing deadlines from `now`.
1009
1020
  */
1010
1021
  private buildLevelInstance;
1022
+ /**
1023
+ * Carry a weighted level's vote weight from one approver to another.
1024
+ *
1025
+ * On a weighted level the weight belongs to the **seat**, not to the person
1026
+ * filling it. Without this, replacing an approver silently dropped their
1027
+ * weight to the default of 1: a CFO carrying weight 3 who was reassigned,
1028
+ * delegated or covered while away left the level unable to reach its
1029
+ * threshold, and the next decision threw "needs a weight of 3 but the
1030
+ * assigned approvers total only 2" — an approval nobody could complete.
1031
+ *
1032
+ * A no-op unless the level actually carries weights and the original had one.
1033
+ */
1034
+ private inheritWeight;
1035
+ /** Apply {@link inheritWeight} for every out-of-office substitution made on a level. */
1036
+ private inheritWeightsForSubstitutions;
1037
+ /**
1038
+ * Recompute the approval frontier from the levels themselves.
1039
+ *
1040
+ * `openLevels` is every level currently collecting decisions, and
1041
+ * `currentLevel` is the lowest of them. Deriving both from one place means
1042
+ * they cannot drift out of step with the levels — the failure behind six
1043
+ * defects fixed across 3.x, each of which read `currentLevel` as though it
1044
+ * named the whole frontier.
1045
+ *
1046
+ * A terminal instance has no open levels; `currentLevel` keeps its last value
1047
+ * so the audit trail and any UI still show where it finished.
1048
+ */
1049
+ private syncFrontier;
1050
+ /**
1051
+ * The single write path for an instance.
1052
+ *
1053
+ * Every mutation goes through here so the frontier is recomputed before the
1054
+ * instance is stored. Writing through the adapter directly would let a new
1055
+ * operation persist levels without updating `openLevels`.
1056
+ */
1057
+ private persistInstance;
1011
1058
  /** Level deadline from whichever of days/hours the template configured. */
1012
1059
  private levelEscalationDue;
1013
1060
  /** First rung of a ladder, sorted by delay, or undefined when there is none. */
@@ -1133,7 +1180,39 @@ declare class ApprovalEngine {
1133
1180
  */
1134
1181
  private cancelOrphanedChildren;
1135
1182
  private afterDecision;
1183
+ /**
1184
+ * Return every level above `levelNumber` to a clean `waiting` state.
1185
+ *
1186
+ * Used when a rejection sends the chain back: a level that keeps a stale
1187
+ * `approved`/`rejected` status is neither open nor replayable, and the engine
1188
+ * treats "nothing waiting" as "nothing left to do".
1189
+ */
1190
+ private resetLevelsAfter;
1191
+ /**
1192
+ * Whether an instance has genuinely finished approving.
1193
+ *
1194
+ * "No next group" is not the same as "every level approved": a level left in
1195
+ * a stale state is neither open nor waiting, and treating that as completion
1196
+ * marked instances approved with a branch still undecided. Completion now
1197
+ * requires that every level actually reached a positive terminal state.
1198
+ */
1199
+ private isFullyApproved;
1200
+ /**
1201
+ * Refuse to complete an instance that has not actually been approved throughout.
1202
+ *
1203
+ * A tripwire, not a routine check: it should be unreachable, and it exists
1204
+ * because the state it catches was reachable and silently produced an
1205
+ * approved document that one branch had rejected and another had never seen.
1206
+ */
1207
+ private assertFullyApproved;
1136
1208
  private findNextLevel;
1209
+ /**
1210
+ * The level to send an instance back to, given the level being rejected.
1211
+ *
1212
+ * Sends the chain back past the **whole group** the rejection came from, not
1213
+ * merely one level. Returning into a sibling branch of the same group would
1214
+ * put the instance back inside the step it was just rejected at.
1215
+ */
1137
1216
  private findPreviousLevel;
1138
1217
  private guardBulkSize;
1139
1218
  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>;
@@ -1008,6 +1019,42 @@ declare class ApprovalEngine {
1008
1019
  * @param opts - `open` activates the level now, computing deadlines from `now`.
1009
1020
  */
1010
1021
  private buildLevelInstance;
1022
+ /**
1023
+ * Carry a weighted level's vote weight from one approver to another.
1024
+ *
1025
+ * On a weighted level the weight belongs to the **seat**, not to the person
1026
+ * filling it. Without this, replacing an approver silently dropped their
1027
+ * weight to the default of 1: a CFO carrying weight 3 who was reassigned,
1028
+ * delegated or covered while away left the level unable to reach its
1029
+ * threshold, and the next decision threw "needs a weight of 3 but the
1030
+ * assigned approvers total only 2" — an approval nobody could complete.
1031
+ *
1032
+ * A no-op unless the level actually carries weights and the original had one.
1033
+ */
1034
+ private inheritWeight;
1035
+ /** Apply {@link inheritWeight} for every out-of-office substitution made on a level. */
1036
+ private inheritWeightsForSubstitutions;
1037
+ /**
1038
+ * Recompute the approval frontier from the levels themselves.
1039
+ *
1040
+ * `openLevels` is every level currently collecting decisions, and
1041
+ * `currentLevel` is the lowest of them. Deriving both from one place means
1042
+ * they cannot drift out of step with the levels — the failure behind six
1043
+ * defects fixed across 3.x, each of which read `currentLevel` as though it
1044
+ * named the whole frontier.
1045
+ *
1046
+ * A terminal instance has no open levels; `currentLevel` keeps its last value
1047
+ * so the audit trail and any UI still show where it finished.
1048
+ */
1049
+ private syncFrontier;
1050
+ /**
1051
+ * The single write path for an instance.
1052
+ *
1053
+ * Every mutation goes through here so the frontier is recomputed before the
1054
+ * instance is stored. Writing through the adapter directly would let a new
1055
+ * operation persist levels without updating `openLevels`.
1056
+ */
1057
+ private persistInstance;
1011
1058
  /** Level deadline from whichever of days/hours the template configured. */
1012
1059
  private levelEscalationDue;
1013
1060
  /** First rung of a ladder, sorted by delay, or undefined when there is none. */
@@ -1133,7 +1180,39 @@ declare class ApprovalEngine {
1133
1180
  */
1134
1181
  private cancelOrphanedChildren;
1135
1182
  private afterDecision;
1183
+ /**
1184
+ * Return every level above `levelNumber` to a clean `waiting` state.
1185
+ *
1186
+ * Used when a rejection sends the chain back: a level that keeps a stale
1187
+ * `approved`/`rejected` status is neither open nor replayable, and the engine
1188
+ * treats "nothing waiting" as "nothing left to do".
1189
+ */
1190
+ private resetLevelsAfter;
1191
+ /**
1192
+ * Whether an instance has genuinely finished approving.
1193
+ *
1194
+ * "No next group" is not the same as "every level approved": a level left in
1195
+ * a stale state is neither open nor waiting, and treating that as completion
1196
+ * marked instances approved with a branch still undecided. Completion now
1197
+ * requires that every level actually reached a positive terminal state.
1198
+ */
1199
+ private isFullyApproved;
1200
+ /**
1201
+ * Refuse to complete an instance that has not actually been approved throughout.
1202
+ *
1203
+ * A tripwire, not a routine check: it should be unreachable, and it exists
1204
+ * because the state it catches was reachable and silently produced an
1205
+ * approved document that one branch had rejected and another had never seen.
1206
+ */
1207
+ private assertFullyApproved;
1136
1208
  private findNextLevel;
1209
+ /**
1210
+ * The level to send an instance back to, given the level being rejected.
1211
+ *
1212
+ * Sends the chain back past the **whole group** the rejection came from, not
1213
+ * merely one level. Returning into a sibling branch of the same group would
1214
+ * put the instance back inside the step it was just rejected at.
1215
+ */
1137
1216
  private findPreviousLevel;
1138
1217
  private guardBulkSize;
1139
1218
  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;
@@ -136,8 +136,7 @@ var MemoryAdapter = class {
136
136
  async getInstancesByApprover(tenantId, approverId, opts) {
137
137
  const all = [...this.instances.values()].filter((i) => {
138
138
  if (i.tenantId !== tenantId || i.status !== "pending") return false;
139
- const currentLevel = i.levels.find((l) => l.level === i.currentLevel);
140
- return currentLevel?.approverIds.includes(approverId) ?? false;
139
+ return i.levels.some((l) => l.status === "pending" && l.approverIds.includes(approverId));
141
140
  });
142
141
  return paginate(
143
142
  all.map((i) => reviveDates(deepClone(i))),