hierarchical-approval 0.5.0 → 0.7.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 (77) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +109 -4
  3. package/dist/{ApprovalEngine-mkh6D9n9.d.cts → ApprovalEngine-BET-UqQo.d.cts} +120 -18
  4. package/dist/{ApprovalEngine-jy4cYeES.d.ts → ApprovalEngine-BGfrrFxl.d.ts} +120 -18
  5. package/dist/ISchedulerAdapter-DKv_QjVN.d.cts +13 -0
  6. package/dist/ISchedulerAdapter-DKv_QjVN.d.ts +13 -0
  7. package/dist/{IStorageAdapter-ClBdEeTj.d.cts → IStorageAdapter-BibHlgPw.d.cts} +1 -1
  8. package/dist/{IStorageAdapter-8ajiGUeu.d.ts → IStorageAdapter-D-oPxxun.d.ts} +1 -1
  9. package/dist/{Logger-CIzvalFc.d.cts → Logger-BplhlU7l.d.cts} +1 -0
  10. package/dist/{Logger-CIzvalFc.d.ts → Logger-BplhlU7l.d.ts} +1 -0
  11. package/dist/adapters/MemoryAdapter.cjs +19 -7
  12. package/dist/adapters/MemoryAdapter.cjs.map +1 -1
  13. package/dist/adapters/MemoryAdapter.d.cts +2 -2
  14. package/dist/adapters/MemoryAdapter.d.ts +2 -2
  15. package/dist/adapters/MemoryAdapter.js +19 -7
  16. package/dist/adapters/MemoryAdapter.js.map +1 -1
  17. package/dist/adapters/PostgresAdapter.cjs +60 -31
  18. package/dist/adapters/PostgresAdapter.cjs.map +1 -1
  19. package/dist/adapters/PostgresAdapter.d.cts +2 -2
  20. package/dist/adapters/PostgresAdapter.d.ts +2 -2
  21. package/dist/adapters/PostgresAdapter.js +60 -31
  22. package/dist/adapters/PostgresAdapter.js.map +1 -1
  23. package/dist/index.cjs +745 -104
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +6 -5
  26. package/dist/index.d.ts +6 -5
  27. package/dist/index.js +745 -105
  28. package/dist/index.js.map +1 -1
  29. package/dist/nestjs.cjs +725 -97
  30. package/dist/nestjs.cjs.map +1 -1
  31. package/dist/nestjs.d.cts +4 -3
  32. package/dist/nestjs.d.ts +4 -3
  33. package/dist/nestjs.js +725 -97
  34. package/dist/nestjs.js.map +1 -1
  35. package/dist/plugins/audit.cjs +2 -0
  36. package/dist/plugins/audit.cjs.map +1 -1
  37. package/dist/plugins/audit.d.cts +1 -1
  38. package/dist/plugins/audit.d.ts +1 -1
  39. package/dist/plugins/audit.js +2 -0
  40. package/dist/plugins/audit.js.map +1 -1
  41. package/dist/plugins/metrics.cjs +2 -0
  42. package/dist/plugins/metrics.cjs.map +1 -1
  43. package/dist/plugins/metrics.d.cts +1 -1
  44. package/dist/plugins/metrics.d.ts +1 -1
  45. package/dist/plugins/metrics.js +2 -0
  46. package/dist/plugins/metrics.js.map +1 -1
  47. package/dist/plugins/notify.cjs +2 -0
  48. package/dist/plugins/notify.cjs.map +1 -1
  49. package/dist/plugins/notify.d.cts +1 -1
  50. package/dist/plugins/notify.d.ts +1 -1
  51. package/dist/plugins/notify.js +2 -0
  52. package/dist/plugins/notify.js.map +1 -1
  53. package/dist/plugins/resilience.cjs +2 -0
  54. package/dist/plugins/resilience.cjs.map +1 -1
  55. package/dist/plugins/resilience.d.cts +1 -1
  56. package/dist/plugins/resilience.d.ts +1 -1
  57. package/dist/plugins/resilience.js +2 -0
  58. package/dist/plugins/resilience.js.map +1 -1
  59. package/dist/plugins/scheduler.cjs +69 -0
  60. package/dist/plugins/scheduler.cjs.map +1 -0
  61. package/dist/plugins/scheduler.d.cts +43 -0
  62. package/dist/plugins/scheduler.d.ts +43 -0
  63. package/dist/plugins/scheduler.js +67 -0
  64. package/dist/plugins/scheduler.js.map +1 -0
  65. package/dist/plugins/webhook.cjs +226 -0
  66. package/dist/plugins/webhook.cjs.map +1 -0
  67. package/dist/plugins/webhook.d.cts +214 -0
  68. package/dist/plugins/webhook.d.ts +214 -0
  69. package/dist/plugins/webhook.js +221 -0
  70. package/dist/plugins/webhook.js.map +1 -0
  71. package/dist/testing.cjs +744 -104
  72. package/dist/testing.cjs.map +1 -1
  73. package/dist/testing.d.cts +4 -3
  74. package/dist/testing.d.ts +4 -3
  75. package/dist/testing.js +744 -104
  76. package/dist/testing.js.map +1 -1
  77. package/package.json +11 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,201 @@
3
3
  All notable changes to `hierarchical-approval` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [Unreleased]
7
+
8
+ _Nothing yet._
9
+
10
+ ## [0.7.0] - 2026-09-04
11
+
12
+ ### Fixed — two condition-evaluation bypasses
13
+
14
+ Both of these let a `ConditionRule` fire when it should not have, and because
15
+ conditions decide which levels an instance gets, a spurious match on a
16
+ `skipLevels` rule **removes approval levels from a live document**. Anyone using
17
+ `skipLevels` — or `addLevels` to *escalate* above a threshold — should upgrade.
18
+
19
+ - **Numeric operators no longer coerce non-numbers to zero.** `>`, `<`, `>=` and
20
+ `<=` compared with `Number(actual)`, and `Number()` maps `null`, `''`, `' '`,
21
+ `[]` and `false` all to `0`. So a fast-track rule like
22
+ `{ when: { field: 'amount', operator: '<', value: 5000 }, skipLevels: [2, 3] }`
23
+ matched a purchase order whose `amount` was `null` or blank, silently skipping
24
+ two approval levels on exactly the documents whose value was unknown. `false`
25
+ and `[]` did the same, and `true` compared as `1`.
26
+
27
+ A numeric comparison against a non-number is now treated as *undecidable* rather
28
+ than false-y: it reports **no match**, which is the outcome `undefined` has
29
+ always produced. Accepted operands are finite numbers, bigints, `Date`
30
+ (compared as epoch milliseconds), and numeric strings such as `'100'` or
31
+ `' 1e3 '` — ERP payloads routinely arrive as JSON strings, so string comparison
32
+ is retained. Rejected: `null`, `undefined`, booleans, arrays, objects, blank
33
+ strings, `NaN` and `Infinity`.
34
+
35
+ **Behaviour change.** A rule that was matching on blank or boolean data stops
36
+ matching. That is the fix, but it does change which levels such an instance
37
+ gets, so re-check any template whose conditions run against optional fields.
38
+ `==` and `!=` are untouched — they were already strict.
39
+
40
+ - **Dot-path field lookup now reads own properties only.** `getField` tested
41
+ `key in obj`, which walks the prototype chain, so a condition on `isFastTrack`
42
+ was satisfied by an inherited `Object.prototype.isFastTrack` that no document
43
+ ever declared — turning any prototype pollution elsewhere in the dependency
44
+ tree into an approval-level bypass. Resolution now uses
45
+ `Object.prototype.hasOwnProperty`, so a segment that is not an own property
46
+ resolves to `undefined` exactly as an absent field does. This also closes
47
+ `__proto__`, `constructor` and `prototype` as readable paths.
48
+
49
+ **Behaviour change.** Context data whose fields live on a prototype (a class
50
+ instance with getters, rather than a plain object) no longer resolves. Plain
51
+ objects, arrays, array indices and `Object.create(null)` objects are
52
+ unaffected, and context data does not survive JSONB round-tripping as a class
53
+ instance in any case.
54
+
55
+ ### Added
56
+
57
+ - **`toComparableNumber(value)` is exported from the package root.** The same
58
+ strict coercion the built-in numeric operators use, returning `number` or
59
+ `null`, so a custom operator registered with `registerConditionOperator()` can
60
+ inherit the identical semantics instead of re-introducing `Number()`. The
61
+ README's `between` recipe now uses it — the previous version of that snippet
62
+ demonstrated the zero-coercion bug.
63
+
64
+ ### Tests
65
+
66
+ - Restored the **17 `ConditionEvaluator` unit tests that commit `8d648d8`
67
+ deleted**, having replaced the suite body with a `// ... existing tests ...`
68
+ placeholder and a single test. Coverage of the evaluator had silently dropped
69
+ to 74% of statements and 56% of functions.
70
+ - Added 36 tests across the two fixes (per-type non-comparable operand matrix for
71
+ each numeric operator, the `skipLevels` bypass scenario, prototype-pollution
72
+ guards, `toComparableNumber` directly) plus an executable copy of the README's
73
+ custom-operator recipes, so a documented snippet cannot rot or stop compiling.
74
+ All 24 of the pre-existing-bug assertions were confirmed to fail against the
75
+ unfixed source.
76
+
77
+ ## [0.6.0] - 2026-08-21
78
+
79
+ ### Fixed — event delivery, template reads, and the CI lint gate
80
+
81
+ - **A throwing `engine.on()` listener no longer breaks the operation that emitted
82
+ the event.** `EventBus` delivered events straight through `eventemitter3`, which
83
+ invokes listeners synchronously and does not swallow exceptions — and the engine
84
+ emits *after* persisting but *before* dispatching notifications and running
85
+ after-middleware. So one buggy subscriber would reject `approve()` with its own
86
+ error (while the instance stayed persisted as approved), skip notification
87
+ dispatch entirely, leave a tracing span opened and never ended, and suppress the
88
+ `approval:completed` emit on the following line. Listener failures are now
89
+ isolated per listener and reported via the engine's logger; a rejecting `async`
90
+ listener is caught too, instead of surfacing as a process-level
91
+ `unhandledRejection`. **Behaviour change:** a listener error no longer propagates
92
+ to the caller. Register an `onListenerError` sink by passing a `logger`, or use
93
+ `EventBus.setListenerErrorHandler` directly.
94
+ - **`approval:completed` is now delivered to notification adapters.** It was
95
+ emitted on the in-process bus only, so a webhook/email integrator never learned
96
+ a document had been fully approved and had to infer it from `approval:approved`
97
+ plus `isFinal`. Dispatched on both the normal-completion and `override` paths.
98
+ - **`PostgresAdapter.getTemplate`/`listTemplates` now return `createdAt` as a real
99
+ `Date`.** They returned the JSONB payload raw, so `createdAt` was an ISO string
100
+ despite `ApprovalTemplate` typing it as a `Date`. `TemplateRegistry.update()`
101
+ threaded that string into the next `saveTemplate()`, which called
102
+ `createdAt.toISOString()` on it — meaning **`engine.updateTemplate()` failed 100%
103
+ of the time against real PostgreSQL** while passing against `MemoryAdapter`,
104
+ which tolerates the string silently. No migration needed.
105
+ - **CI enforces lint again.** The workflow ran `npm run lint || true`, so lint could
106
+ never fail a build; an unused import had already accumulated on `main` as a
107
+ result. The `|| true` is gone.
108
+
109
+ ### Added — cycle-time analytics
110
+
111
+ - **`getStatistics()` now reports time-to-decision.** The returned
112
+ `ApprovalStatistics` gains `cycleTime: CycleTimeStats` and
113
+ `cycleTimeByTemplate: Record<string, CycleTimeStats>`, each shaped as
114
+ `{ count, averageMs, p50Ms, p95Ms, minMs, maxMs }` (all durations in
115
+ milliseconds; new `CycleTimeStats` interface, exported from the package root).
116
+ - Counts instances in status `approved`, `rejected`, or `cancelled`.
117
+ **`expired` is deliberately excluded** — its terminal timestamp reflects a
118
+ scheduler deadline firing, not a decision, so including it would skew the
119
+ distribution rather than describe it.
120
+ - Elapsed time per instance is `updatedAt - createdAt`; no new fields were
121
+ added to `ApprovalInstance` or either storage adapter.
122
+ - Every field is `0` (never `NaN`) when `count` is `0`. `cycleTimeByTemplate`
123
+ mirrors `byTemplate`'s population rule: a template only appears once it has
124
+ at least one completed instance.
125
+ - Adapter-agnostic — computed from existing `getInstancesByFilter` pages, so
126
+ `MemoryAdapter` and `PostgresAdapter` both support it with no changes.
127
+
128
+ ### Added — `plugins/webhook`
129
+
130
+ - **`hierarchical-approval/plugins/webhook`** — a sixth built-in plug-in
131
+ subpath: an HTTP `INotificationAdapter` with signing and retry, on its own
132
+ tree-shakeable import path with **zero new dependencies**.
133
+ - `WebhookNotificationAdapter` POSTs each event as JSON to a configured URL.
134
+ `notify()` never throws (per the `INotificationAdapter` contract) — it logs
135
+ and drops the notification once retries are exhausted.
136
+ - **Signing (opt-in via `secret`).** Each request carries an
137
+ `X-Approval-Signature: t=<unix-seconds>,v1=<hex-hmac>` header (Stripe-style),
138
+ the HMAC-SHA256 digest of the signing string `` `<unix-seconds>.<json-body>` ``.
139
+ Unsigned when `secret` is omitted. `DEFAULT_SIGNATURE_HEADER` names the
140
+ default header (`'X-Approval-Signature'`); override via `signatureHeader`.
141
+ - **Retry.** `5xx`, `408`, `429`, and network/timeout errors are retried with
142
+ exponential backoff and full jitter, up to `maxAttempts` (default `3`); a
143
+ `429` honors `Retry-After` (seconds or an HTTP date) in place of the
144
+ computed backoff. Any other `4xx` fails on the first attempt.
145
+ - **Durability.** The public `deliver()` method throws
146
+ `WebhookDeliveryError` (`status?`, `attempts`, `cause?`) on final failure
147
+ instead of swallowing it, so it can be bound as the `transport` of
148
+ `plugins/notify`'s `OutboxNotificationAdapter` for at-least-once delivery
149
+ across process restarts — no adapter shim needed.
150
+ - Ships a new `HttpClient` port (a plain `fetch`-shaped function type) and
151
+ `getDefaultHttpClient()`. **No new dependency** — the port is structurally
152
+ satisfied by the global `fetch` (Node.js 18+); pass a custom `httpClient`
153
+ to use a different implementation.
154
+
155
+ ### Added — `plugins/scheduler`, and the `schedulerAdapter` option now works
156
+
157
+ - **`hierarchical-approval/plugins/scheduler`** — ships `InMemorySchedulerAdapter`,
158
+ the first reference implementation of the `ISchedulerAdapter` port, which until
159
+ now was exported from the package root with no implementation anywhere to copy.
160
+ - **`ApprovalEngineOptions.schedulerAdapter` was a no-op.** It was declared and
161
+ documented as "Replaces built-in setInterval polling", but `scheduleAt` and
162
+ `cancel` were never called — an injected BullMQ/Temporal/cron scheduler was
163
+ silently ignored while the built-in poller kept running. When supplied it now
164
+ drives the periodic scan via self-rescheduling one-shot calls. Default
165
+ behaviour with no option supplied is unchanged.
166
+
167
+ ### Fixed — `PostgresAdapter`
168
+
169
+ Three defects affecting users on published `0.5.0`. If you use `PostgresAdapter`,
170
+ upgrading is recommended.
171
+
172
+ - **Cursor pagination silently corrupted every page after the first.**
173
+ `getInstancesByCursor`'s cursor decoder split the `updatedAt_iso:id` string on
174
+ the _first_ colon, but an ISO-8601 timestamp (e.g.
175
+ `2026-06-26T09:00:00.000Z`) itself contains colons — so every decoded cursor
176
+ had a truncated timestamp and a corrupted id, breaking every subsequent page
177
+ fetch via `getInstancesByCursor`. Also hardened cursor encoding to normalize
178
+ through `Date.prototype.toISOString()` regardless of the row value's shape.
179
+ No migration needed.
180
+ - **`templateId` was silently dropped on every write and always read back as
181
+ `''`.** `PostgresAdapter` had no `template_id` column, so
182
+ `ApprovalInstance.templateId` was permanently lost for any instance persisted
183
+ through this adapter. Fixed by adding a `template_id` column and including it
184
+ in both the insert and read paths.
185
+ **Action required on upgrade:** run the adapter's migration (its
186
+ `CREATE TABLE`/`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements, executed
187
+ automatically the next time the adapter initializes) to add the new column —
188
+ existing rows backfill to `''` until rewritten.
189
+ - **The `schema` option was interpolated into SQL without the safe-identifier
190
+ validation `tablePrefix` already had**, an inconsistent injection surface at
191
+ construction time. `schema` is now validated against the same
192
+ `/^[a-z][a-z0-9_]*$/` pattern as `tablePrefix`, throwing
193
+ `ApprovalValidationError` on an invalid value. No migration needed; only
194
+ affects adapter construction with an attacker-controlled `schema` value.
195
+
196
+ ### Fixed — `MemoryAdapter` and notifications
197
+
198
+ - **`TemplatedNotificationAdapter`** — now logs fatal events (notification render/send failures) at the `fatal` level instead of `error`.
199
+ - **`MemoryAdapter`** — `getInstancesByFilter` now honors `fromDate`/`toDate` (previously the JSON-cloned string dates made the comparison a no-op and both bounds silently matched nothing). `getTemplate`/`listTemplates` now return `createdAt` as a real `Date`, matching the `ApprovalTemplate` contract.
200
+
6
201
  ## [0.5.0] - 2026-07-23
7
202
 
8
203
  ### Added — NestJS integration
package/README.md CHANGED
@@ -305,6 +305,33 @@ conditions: [
305
305
 
306
306
  **Built-in operators:** `>`, `<`, `>=`, `<=`, `==`, `!=`, `in`, `not_in`
307
307
 
308
+ #### How values are compared
309
+
310
+ `==` and `!=` compare **strictly** — `'100'` does not equal `100`.
311
+
312
+ The numeric operators (`>`, `<`, `>=`, `<=`) match only when **both** sides are
313
+ unambiguously numeric: finite numbers, bigints, `Date` (compared as epoch
314
+ milliseconds), and numeric strings such as `'100'` or `' 1e3 '` — because ERP
315
+ payloads routinely arrive as JSON strings. Anything else — `null`, `undefined`,
316
+ `true`/`false`, arrays, objects, blank strings, `NaN`, `Infinity` — is treated as
317
+ *not comparable*, and the condition reports **no match**.
318
+
319
+ This matters for safety. A rule that skips levels on small amounts must not fire
320
+ on a document whose amount was never populated:
321
+
322
+ ```ts
323
+ { when: { field: 'amount', operator: '<', value: 5000 }, skipLevels: [2, 3] }
324
+
325
+ // amount: 4999 -> matches, levels 2 and 3 are skipped
326
+ // amount: null -> NO match, every level is kept
327
+ // amount: '' -> NO match, every level is kept
328
+ ```
329
+
330
+ Field paths resolve **own properties only**, so an inherited or polluted
331
+ `Object.prototype` member can never satisfy a condition. A path segment that is
332
+ not an own property — including `__proto__`, `constructor`, and `prototype` —
333
+ resolves to `undefined`, exactly as a genuinely absent field does.
334
+
308
335
  **Register custom operators** at engine level:
309
336
 
310
337
  ```ts
@@ -312,11 +339,23 @@ engine.registerConditionOperator(
312
339
  'contains',
313
340
  (actual, expected) => typeof actual === 'string' && actual.includes(String(expected)),
314
341
  );
342
+ ```
315
343
 
316
- engine.registerConditionOperator(
317
- 'between',
318
- (actual, [min, max]: number[]) => Number(actual) >= min && Number(actual) <= max,
319
- );
344
+ Custom numeric operators should reuse the same strictness via the exported
345
+ `toComparableNumber` helper, which returns `null` for anything that is not
346
+ unambiguously a number:
347
+
348
+ ```ts
349
+ import { toComparableNumber } from 'hierarchical-approval';
350
+
351
+ engine.registerConditionOperator('between', (actual, expected) => {
352
+ const value = toComparableNumber(actual);
353
+ if (value === null || !Array.isArray(expected) || expected.length !== 2) return false;
354
+ const min = toComparableNumber(expected[0]);
355
+ const max = toComparableNumber(expected[1]);
356
+ if (min === null || max === null) return false;
357
+ return value >= min && value <= max;
358
+ });
320
359
  ```
321
360
 
322
361
  ---
@@ -721,9 +760,27 @@ const stats = await engine.getStatistics({ documentType: 'purchase_order' });
721
760
  // byStatus: { pending, approved, rejected, cancelled, expired },
722
761
  // overdue: number, // pending past an escalation/expiry deadline
723
762
  // approvalRate: number, // approved / (approved + rejected); 0 when none resolved
763
+ // byTemplate: Record<string, { total, approved, rejected, pending }>,
764
+ // cycleTime: CycleTimeStats,
765
+ // cycleTimeByTemplate: Record<string, CycleTimeStats>,
724
766
  // }
725
767
  ```
726
768
 
769
+ `byTemplate` breaks the same counts down per template name (only populated for templates with at least one matching instance).
770
+
771
+ **Cycle-time analytics.** `cycleTime` (and its per-template mirror `cycleTimeByTemplate`) reports time-to-decision, in milliseconds, as `{ count, averageMs, p50Ms, p95Ms, minMs, maxMs }`:
772
+
773
+ ```ts
774
+ console.log(`Median time-to-decision: ${(stats.cycleTime.p50Ms / 60_000).toFixed(1)} min`);
775
+ console.log(`p95 over ${stats.cycleTime.count} completed instances: ${stats.cycleTime.p95Ms}ms`);
776
+
777
+ for (const [templateName, timing] of Object.entries(stats.cycleTimeByTemplate)) {
778
+ console.log(templateName, timing.averageMs);
779
+ }
780
+ ```
781
+
782
+ Only instances in status `approved`, `rejected`, or `cancelled` count as "completed" for this calculation — **`expired` is deliberately excluded**: its terminal timestamp reflects a scheduler deadline firing, not a decision being made, so including it would skew the distribution rather than describe it. Elapsed time per instance is `updatedAt - createdAt`. When `count` is `0` every other field is `0`, never `NaN`.
783
+
727
784
  ### Health check
728
785
 
729
786
  ```ts
@@ -1072,6 +1129,54 @@ const engine = new ApprovalEngine({
1072
1129
 
1073
1130
  Every operation is wrapped in a span named `approval.<operation>` (e.g. `approval.approve`) carrying `approval.tenant_id`, `approval.actor_id` and `approval.instance_id` attributes. On success the span records the resulting `approval.result_status`/`approval.result_level` and status `OK`; on failure it calls `recordException`, tags `approval.error_code`, and sets status `ERROR` — then re-throws (tracing never swallows an error). There is **no hard dependency** on `@opentelemetry/api`: the middleware defaults to a no-op tracer, so you install and wire OpenTelemetry only if you want traces.
1074
1131
 
1132
+ ### `plugins/webhook` — signed HTTP delivery with retries
1133
+
1134
+ ```ts
1135
+ import { WebhookNotificationAdapter } from 'hierarchical-approval/plugins/webhook';
1136
+
1137
+ const webhook = new WebhookNotificationAdapter({
1138
+ url: 'https://example.com/hooks/approvals',
1139
+ secret: process.env.WEBHOOK_SECRET, // opt-in signing; omit to send unsigned requests
1140
+ });
1141
+
1142
+ const engine = new ApprovalEngine({ adapter, notificationAdapter: webhook });
1143
+ ```
1144
+
1145
+ Every event is POSTed to `url` as JSON. When `secret` is configured, each request also carries an `X-Approval-Signature: t=<unix-seconds>,v1=<hex-hmac>` header (Stripe-style), where `v1` is the HMAC-SHA256 digest of the signing string `` `${timestamp}.${body}` ``. Folding the timestamp into the signed payload lets a receiver reject stale/replayed requests. A receiver verifies it like this:
1146
+
1147
+ ```ts
1148
+ import { createHmac, timingSafeEqual } from 'node:crypto';
1149
+
1150
+ function verifyApprovalSignature(rawBody: string, header: string, secret: string): boolean {
1151
+ const [tPart, vPart] = header.split(',');
1152
+ const timestamp = tPart!.slice(2); // strip "t="
1153
+ const signature = vPart!.slice(3); // strip "v1="
1154
+ const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
1155
+ const sigBuf = Buffer.from(signature, 'hex');
1156
+ const expBuf = Buffer.from(expected, 'hex');
1157
+ return sigBuf.length === expBuf.length && timingSafeEqual(sigBuf, expBuf);
1158
+ }
1159
+ ```
1160
+
1161
+ `5xx` responses, `408`, and `429`, plus network/timeout errors, are retried with exponential backoff and full jitter up to `maxAttempts` (default `3`); a `429` honors a `Retry-After` header (seconds or an HTTP date) in place of the computed backoff. Any other `4xx` (e.g. `400`, `401`, `404`) is treated as permanent and fails on the first attempt. `notify()` — the method the engine calls — never throws, per the `INotificationAdapter` contract: on exhausting `maxAttempts` it logs and drops the notification, which makes the adapter used alone _at-most-once_.
1162
+
1163
+ For at-least-once delivery that survives a process restart, compose the adapter's throwing `deliver()` method as the `transport` of `OutboxNotificationAdapter` instead of wiring `notify()` directly:
1164
+
1165
+ ```ts
1166
+ import { OutboxNotificationAdapter } from 'hierarchical-approval/plugins/notify';
1167
+ import { WebhookNotificationAdapter } from 'hierarchical-approval/plugins/webhook';
1168
+
1169
+ const webhook = new WebhookNotificationAdapter({
1170
+ url: 'https://example.com/hooks/approvals',
1171
+ secret,
1172
+ });
1173
+ const durable = new OutboxNotificationAdapter({ transport: webhook.deliver.bind(webhook) });
1174
+
1175
+ const engine = new ApprovalEngine({ adapter, notificationAdapter: durable });
1176
+ ```
1177
+
1178
+ `deliver` and `OutboxNotificationAdapter`'s `NotificationTransport` type are structurally compatible, so this composition needs no adapter shim. This adapter takes **no new dependency**: its `HttpClient` port is a plain `fetch`-shaped function, and the global `fetch` (Node.js 18+) satisfies it directly — pass a custom `httpClient` in the options only if you need different behavior (a proxy, request mocking, a different runtime's fetch).
1179
+
1075
1180
  ---
1076
1181
 
1077
1182
  ## NestJS integration
@@ -1,12 +1,13 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-ClBdEeTj.cjs';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-BibHlgPw.cjs';
2
2
  import { m as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-D8D7b07N.cjs';
3
3
  import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-DVVmXU6a.cjs';
4
4
  import { z } from 'zod';
5
- import { L as Logger } from './Logger-CIzvalFc.cjs';
5
+ import { L as Logger } from './Logger-BplhlU7l.cjs';
6
6
  import { C as Clock } from './Clock-3FnOczFJ.cjs';
7
7
  import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-CXgXmGUF.cjs';
8
8
  import { I as IAuditAdapter } from './IAuditAdapter-B_DhuPsU.cjs';
9
9
  import { I as IMetricsAdapter } from './IMetricsAdapter-D9PUz4tM.cjs';
10
+ import { I as ISchedulerAdapter } from './ISchedulerAdapter-DKv_QjVN.cjs';
10
11
  import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-B6JzRNUk.cjs';
11
12
 
12
13
  declare const SubmitOptionsSchema: z.ZodObject<{
@@ -107,7 +108,8 @@ interface WeekendCalendarOptions {
107
108
  */
108
109
  declare function weekendCalendar(options?: WeekendCalendarOptions): BusinessCalendar;
109
110
 
110
- type IdGeneratorFn = (prefix: 'inst' | 'tpl') => string;
111
+ type IdGeneratorPrefix = 'inst' | 'tpl';
112
+ type IdGeneratorFn = (prefix: IdGeneratorPrefix) => string;
111
113
  declare const defaultIdGenerator: IdGeneratorFn;
112
114
 
113
115
  interface OrgProvider {
@@ -128,18 +130,26 @@ type ApproverResolverFn = (config: Record<string, unknown>, ctx: {
128
130
  }) => Promise<string[]> | string[];
129
131
 
130
132
  type ConditionOperatorFn = (actual: unknown, expected: unknown) => boolean;
131
-
132
- interface ISchedulerAdapter {
133
- /**
134
- * Schedule a one-shot callback to run at the given date.
135
- * Returns an opaque handle that can be passed to cancel().
136
- */
137
- scheduleAt(id: string, runAt: Date, callback: () => Promise<void>): Promise<string>;
138
- /** Cancel a previously scheduled callback by its handle. */
139
- cancel(handle: string): Promise<void>;
140
- /** Gracefully shut down the scheduler and release resources. */
141
- shutdown(): Promise<void>;
142
- }
133
+ /**
134
+ * Coerce a value to a number *only* when it unambiguously represents one.
135
+ *
136
+ * Plain `Number()` maps `null`, `''`, `' '`, `[]` and `false` all to `0`, which
137
+ * in an approval engine is an approval-bypass hazard: a rule such as
138
+ * `{ amount: '<' 5000 } -> skipLevels: [2, 3]` would fire on a document whose
139
+ * `amount` is missing or blank, silently skipping two approval levels. Numeric
140
+ * comparison against a value that is not a number is not "false-y", it is
141
+ * *undecidable*, so this returns `null` and the comparison reports no match —
142
+ * the same outcome `undefined` has always produced.
143
+ *
144
+ * Accepted: finite numbers, bigints, `Date` (compared as epoch ms), and numeric
145
+ * strings such as `'100'` or `' 1e3 '` (ERP payloads routinely arrive as JSON
146
+ * strings). Rejected: `null`, `undefined`, booleans, arrays, objects, blank
147
+ * strings, and the non-finite `NaN` / `Infinity`.
148
+ *
149
+ * @param value - The raw value taken from the condition or the context data.
150
+ * @returns The numeric value, or `null` when the value is not comparable.
151
+ */
152
+ declare function toComparableNumber(value: unknown): number | null;
143
153
 
144
154
  interface ValidationResult {
145
155
  valid: boolean;
@@ -192,6 +202,49 @@ interface ApprovalStatistics {
192
202
  rejected: number;
193
203
  pending: number;
194
204
  }>;
205
+ /**
206
+ * Time-to-decision ("cycle time") analytics, in milliseconds, for
207
+ * **completed** instances matching the filter — status `'approved'`,
208
+ * `'rejected'`, or `'cancelled'`. `'expired'` instances are excluded: their
209
+ * terminal timestamp reflects a scheduler deadline firing, not a decision
210
+ * being made, so they would skew the distribution rather than describe it.
211
+ *
212
+ * Elapsed time per instance is `updatedAt - createdAt`: `createdAt` is the
213
+ * submission instant, and `updatedAt` is set at the moment the instance
214
+ * transitions to its terminal status (see {@link ApprovalEngine.approve},
215
+ * {@link ApprovalEngine.reject}, {@link ApprovalEngine.cancel}). See
216
+ * {@link CycleTimeStats} for the zeroed shape returned when there are no
217
+ * completed instances.
218
+ */
219
+ cycleTime: CycleTimeStats;
220
+ /**
221
+ * The same {@link cycleTime} analytics broken down per template name.
222
+ * Mirrors the {@link byTemplate} population rule: a template only appears
223
+ * here when at least one of its instances is completed (`count > 0`).
224
+ */
225
+ cycleTimeByTemplate: Record<string, CycleTimeStats>;
226
+ }
227
+ /**
228
+ * Time-to-decision ("cycle time") statistics for a set of completed approval
229
+ * instances. All duration fields are in **milliseconds**.
230
+ *
231
+ * When {@link count} is `0` (no completed instances matched), every other
232
+ * field is `0` — never `NaN` — mirroring {@link computeTimingStats}'s
233
+ * empty-input behavior, which this type's values are derived from.
234
+ */
235
+ interface CycleTimeStats {
236
+ /** Number of completed instances included in this computation. */
237
+ count: number;
238
+ /** Arithmetic mean time-to-decision. `0` when {@link count} is `0`. */
239
+ averageMs: number;
240
+ /** 50th percentile (median) time-to-decision, via nearest-rank. `0` when {@link count} is `0`. */
241
+ p50Ms: number;
242
+ /** 95th percentile time-to-decision, via nearest-rank. `0` when {@link count} is `0`. */
243
+ p95Ms: number;
244
+ /** Smallest observed time-to-decision. `0` when {@link count} is `0`. */
245
+ minMs: number;
246
+ /** Largest observed time-to-decision. `0` when {@link count} is `0`. */
247
+ maxMs: number;
195
248
  }
196
249
  interface HealthResult {
197
250
  status: 'healthy' | 'degraded' | 'unhealthy';
@@ -225,7 +278,7 @@ interface ApprovalEngineOptions {
225
278
  */
226
279
  calendar?: BusinessCalendar;
227
280
  /** Custom ID generator for instances and templates. Defaults to timestamp+random. */
228
- generateId?: IdGeneratorFn;
281
+ generateId?: (prefix: IdGeneratorPrefix) => string;
229
282
  /** Custom optimistic locking retry policy. */
230
283
  retryPolicy?: RetryPolicy;
231
284
  /** Custom idempotency key derivation function. Default: SHA-256 of tenant+documentType+documentId+templateName. */
@@ -236,7 +289,20 @@ interface ApprovalEngineOptions {
236
289
  auditAdapter?: IAuditAdapter;
237
290
  /** Metrics adapter for Prometheus / Datadog / OpenTelemetry. */
238
291
  metricsAdapter?: IMetricsAdapter;
239
- /** Custom scheduler adapter (BullMQ, Temporal, cron). Replaces built-in setInterval polling. */
292
+ /**
293
+ * Custom scheduler adapter (BullMQ, Temporal, cron) that drives the recurring
294
+ * escalation/expiry/SLA-breach/delegation-revert scan.
295
+ *
296
+ * When provided, the built-in `setInterval` poll (see
297
+ * {@link EscalationScheduler.start}) is never started. Instead,
298
+ * {@link ISchedulerAdapter.scheduleAt} schedules each scan, and its callback
299
+ * reschedules the next one itself once the scan completes — the adapter
300
+ * changes *how* the periodic scan is triggered, not *what* it scans; every
301
+ * tick still runs the exact same overdue-instance query the built-in poller
302
+ * runs. {@link ISchedulerAdapter.cancel} and {@link ISchedulerAdapter.shutdown}
303
+ * are invoked during {@link ApprovalEngine.shutdown}. Omitting this option
304
+ * preserves the built-in `setInterval` polling behavior unchanged.
305
+ */
240
306
  schedulerAdapter?: ISchedulerAdapter;
241
307
  /** Authorization policy called before every mutating operation. */
242
308
  authorizationPolicy?: IAuthorizationPolicy;
@@ -249,6 +315,7 @@ declare class ApprovalEngine {
249
315
  private readonly registry;
250
316
  private readonly resolver;
251
317
  private readonly escalation;
318
+ private readonly escalationPollIntervalMs;
252
319
  private readonly tenantId;
253
320
  private readonly logger;
254
321
  private readonly clock;
@@ -257,6 +324,10 @@ declare class ApprovalEngine {
257
324
  private readonly maxBulkItems;
258
325
  private readonly retryPolicy;
259
326
  private readonly idempotencyKeyFn;
327
+ /** Handle for the currently-scheduled escalation tick when {@link ApprovalEngineOptions.schedulerAdapter} is set. */
328
+ private schedulerAdapterHandle;
329
+ /** Set by {@link shutdown}; stops the self-rescheduling loop from scheduling another tick. */
330
+ private schedulerStopped;
260
331
  constructor(opts: ApprovalEngineOptions);
261
332
  on<K extends ApprovalEventName>(event: K, listener: (payload: ApprovalEventMap[K]) => void): this;
262
333
  off<K extends ApprovalEventName>(event: K, listener: (payload: ApprovalEventMap[K]) => void): this;
@@ -313,6 +384,37 @@ declare class ApprovalEngine {
313
384
  */
314
385
  getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
315
386
  shutdown(): Promise<void>;
387
+ /**
388
+ * Schedules the next escalation scan via {@link ApprovalEngineOptions.schedulerAdapter}.
389
+ *
390
+ * Called once from the constructor to start the loop, then re-invoked by the
391
+ * scheduled callback itself after each scan completes — a self-rescheduling
392
+ * chain of one-shot {@link ISchedulerAdapter.scheduleAt} calls standing in for
393
+ * the `setInterval` that {@link EscalationScheduler.start} would otherwise
394
+ * use. Each invocation runs the exact same {@link EscalationScheduler.tick}
395
+ * scan the built-in poller runs; only the timer mechanism differs.
396
+ *
397
+ * A no-op once {@link shutdown} has set {@link schedulerStopped} — this is
398
+ * what stops the chain from rescheduling itself forever after teardown.
399
+ */
400
+ private scheduleNextEscalationTick;
401
+ /**
402
+ * Compute {@link CycleTimeStats} for every "completed" instance (see
403
+ * {@link CYCLE_TIME_STATUSES}) matching `filter`. Adapter-agnostic: fetches
404
+ * full instances (not just counts) via {@link fetchAllByFilter} so the
405
+ * actual `createdAt`/`updatedAt` timestamps are available, then reuses the
406
+ * shared {@link computeTimingStats} quantile routine from the metrics
407
+ * plugin rather than a second implementation.
408
+ */
409
+ private computeCycleTimeStats;
410
+ /**
411
+ * Page through every instance matching `filter` via the adapter's
412
+ * `getInstancesByFilter`, accumulating pages until the adapter reports no
413
+ * more results. Needed because adapters may impose a default page size
414
+ * (e.g. `PostgresAdapter` defaults to 50) when no explicit `limit` is given,
415
+ * so a single unbounded call cannot be relied on to return everything.
416
+ */
417
+ private fetchAllByFilter;
316
418
  private escalateInternal;
317
419
  private expireInstance;
318
420
  private markSlaBreached;
@@ -333,4 +435,4 @@ declare class ApprovalEngine {
333
435
  private runExternalAudit;
334
436
  }
335
437
 
336
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type ISchedulerAdapter as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type IdGeneratorFn as i, type IdempotencyKeyFn as j, type OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, weekendCalendar as w };
438
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, toComparableNumber as t, weekendCalendar as w };