hierarchical-approval 2.6.0 → 2.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [2.7.0] - 2026-09-04
11
+
12
+ ### Added — `explainChain()`
13
+
14
+ - **Explains why a chain resolves the way it does.** `previewApprovalChain()`
15
+ answers *what* the chain will be; nothing answered *why*, so "why does this
16
+ purchase order have a CFO level?" meant reading the template and
17
+ re-evaluating its conditions by hand — the most common support question about
18
+ an approval engine, and the one it was worst at answering.
19
+
20
+ ```ts
21
+ await engine.explainChain('purchase-order', data, 'buyer-1');
22
+ // levels: [{ level: 3, name: 'CFO', source: 'condition', addedByRule: 0, … }]
23
+ // skipped: [{ level: 2, name: 'Finance', skippedByRule: 1 }]
24
+ // rules: [{ index: 0, matched: true, addsLevels: [3], skipsLevels: [] }, …]
25
+ ```
26
+
27
+ - **Every rule is reported, matched or not**, along with what it *would* add or
28
+ skip — which is how you find the rule that was supposed to fire and didn't,
29
+ not just the ones that did.
30
+
31
+ - **Failures are described rather than thrown.** A level whose approvers cannot
32
+ be resolved is still listed, carrying `resolutionError`; a rule that throws —
33
+ an unregistered operator, a malformed group — is reported against that rule
34
+ and the rest of the explanation still returns. A diagnostic is least useful at
35
+ exactly the moment a broken rule would make it throw.
36
+
37
+ - Reads nothing and writes nothing, so it is safe to expose to a support UI.
38
+ Sub-workflow levels are marked with their child template and skip approver
39
+ resolution, since nobody approves them directly.
40
+
41
+ New exports: `ChainExplanation`, `ExplainedLevel`, `ExplainedSkip`,
42
+ `ExplainedRule`.
43
+
10
44
  ## [2.6.0] - 2026-09-04
11
45
 
12
46
  ### Added — escalation ladders
package/README.md CHANGED
@@ -468,6 +468,37 @@ The sweep is **not atomic**: it reports per-instance failures rather than rollin
468
468
  back. A partial transfer is the useful outcome — the approvals that can move
469
469
  should move, and the ones that cannot are named so a human can look at them.
470
470
 
471
+ ### Why does this chain look like this?
472
+
473
+ `previewApprovalChain()` answers *what* the chain will be. `explainChain()`
474
+ answers *why* — the question behind "why does this purchase order have a CFO
475
+ level?":
476
+
477
+ ```ts
478
+ const explanation = await engine.explainChain('purchase-order', data, 'buyer-1');
479
+ // {
480
+ // templateName: 'purchase-order',
481
+ // levels: [
482
+ // { level: 1, name: 'Manager', source: 'template', resolvedApprovers: ['mgr-1'], … },
483
+ // { level: 3, name: 'CFO', source: 'condition', addedByRule: 0, resolvedApprovers: ['cfo'], … },
484
+ // ],
485
+ // skipped: [{ level: 2, name: 'Finance', skippedByRule: 1 }],
486
+ // rules: [
487
+ // { index: 0, matched: true, addsLevels: [3], skipsLevels: [] },
488
+ // { index: 1, matched: true, addsLevels: [], skipsLevels: [2] },
489
+ // ],
490
+ // }
491
+ ```
492
+
493
+ Every rule is reported, matched or not, along with what it *would* do — which is
494
+ usually how you find the rule that was supposed to fire and didn't. A level
495
+ whose approvers cannot be resolved is still listed, with `resolutionError`
496
+ naming the reason; a rule that throws is reported against that rule rather than
497
+ failing the whole explanation, since the explanation is least useful at exactly
498
+ the moment a broken rule would make it throw.
499
+
500
+ Reads nothing and writes nothing, so it is safe to expose to a support UI.
501
+
471
502
  ### Escalation ladders
472
503
 
473
504
  A single `escalation` fires once, so a request that stalls past its second
@@ -299,6 +299,52 @@ interface PreviewResult {
299
299
  /** Indices (0-based) of conditions that fired for this data. */
300
300
  conditionsApplied: number[];
301
301
  }
302
+ /** Where one level in an explained chain came from. */
303
+ interface ExplainedLevel {
304
+ level: number;
305
+ name: string;
306
+ mode: ApprovalMode;
307
+ /** `'template'` for a statically declared level, `'condition'` for one a rule added. */
308
+ source: 'template' | 'condition';
309
+ /** Index of the condition rule that added it, when `source` is `'condition'`. */
310
+ addedByRule?: number;
311
+ resolvedApprovers: string[];
312
+ /** Why approver resolution failed, when it did. The level is still listed. */
313
+ resolutionError?: string;
314
+ /** Set when this level hands off to a child approval. */
315
+ subWorkflowTemplate?: string;
316
+ }
317
+ /** A level the template declares that will not run, and the rule that removed it. */
318
+ interface ExplainedSkip {
319
+ level: number;
320
+ name: string;
321
+ /** Index of the condition rule whose `skipLevels` removed it. */
322
+ skippedByRule: number;
323
+ }
324
+ /** How one condition rule evaluated against the data. */
325
+ interface ExplainedRule {
326
+ index: number;
327
+ matched: boolean;
328
+ /** Levels this rule would add. Present whether or not it matched. */
329
+ addsLevels: number[];
330
+ /** Levels this rule would skip. Present whether or not it matched. */
331
+ skipsLevels: number[];
332
+ /** Why the rule could not be evaluated, e.g. an unregistered operator. */
333
+ error?: string;
334
+ }
335
+ /**
336
+ * A full account of why a chain looks the way it does.
337
+ *
338
+ * `previewApprovalChain()` answers *what* the chain will be; this answers *why*,
339
+ * which is the question a support engineer actually has when a purchase order
340
+ * arrives with a level nobody expected.
341
+ */
342
+ interface ChainExplanation {
343
+ templateName: string;
344
+ levels: ExplainedLevel[];
345
+ skipped: ExplainedSkip[];
346
+ rules: ExplainedRule[];
347
+ }
302
348
  interface BulkResult {
303
349
  succeeded: ApprovalInstance[];
304
350
  failed: Array<{
@@ -677,6 +723,28 @@ declare class ApprovalEngine {
677
723
  /** Preview the resolved approval chain for a template and document data, without creating an instance. */
678
724
  previewApprovalChain(templateName: string, data: Record<string, unknown>, submittedBy: string): Promise<PreviewResult>;
679
725
  /** Check whether a user is eligible to approve a specific instance. Never throws. */
726
+ /**
727
+ * Explain why a chain resolves the way it does for a given document.
728
+ *
729
+ * `previewApprovalChain()` answers *what* the chain will be. This answers
730
+ * *why*: which rule added a level, which rule removed one, which rules were
731
+ * evaluated and did not match, and where each level's approvers came from —
732
+ * the question behind "why does this purchase order have a CFO level?", which
733
+ * previously meant reading the template and re-evaluating the conditions by
734
+ * hand.
735
+ *
736
+ * A rule that throws — an operator nobody registered, a malformed group — is
737
+ * reported against that rule rather than failing the whole explanation. The
738
+ * explanation is a diagnostic tool, and it is least useful at exactly the
739
+ * moment a broken rule makes it throw.
740
+ *
741
+ * Reads nothing and writes nothing; safe to expose to a support UI.
742
+ *
743
+ * @param templateName - Template to explain.
744
+ * @param data - Document data the conditions are evaluated against.
745
+ * @param submittedBy - Submitter, used for approver resolution.
746
+ */
747
+ explainChain(templateName: string, data: Record<string, unknown>, submittedBy: string): Promise<ChainExplanation>;
680
748
  canApprove(instanceId: string, userId: string): Promise<CanApproveResult>;
681
749
  /** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
682
750
  override(instanceId: string, raw: OverrideOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
@@ -964,4 +1032,4 @@ declare class ApprovalEngine {
964
1032
  private runExternalAudit;
965
1033
  }
966
1034
 
967
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, toComparableNumber as F, validateConditionExpression as G, type HealthResult as H, type IdGeneratorFn as I, weekendCalendar as J, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, TEMPLATE_BUNDLE_VERSION as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type ApproverWorkload as f, type BusinessCalendar as g, type BusinessHoursCalendarOptions as h, type CancelOptions as i, type ConditionOperatorFn as j, type CycleTimeStats as k, type IdempotencyKeyFn as l, type ImportResult as m, type OutOfOfficeProvider as n, type OverrideOptions as o, type PreviewResult as p, type ProvideInfoOptions as q, type PurgeResult as r, type RejectOptions as s, type RequestInfoOptions as t, type ResubmitOptions as u, type RetryPolicy as v, type TemplateBundle as w, type TransferResult as x, businessHoursCalendar as y, defaultIdGenerator as z };
1035
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type TemplateBundle as F, type TransferResult as G, type HealthResult as H, type IdGeneratorFn as I, businessHoursCalendar as J, defaultIdGenerator as K, toComparableNumber as L, validateConditionExpression as M, weekendCalendar as N, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, TEMPLATE_BUNDLE_VERSION as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type ApproverWorkload as f, type BusinessCalendar as g, type BusinessHoursCalendarOptions as h, type CancelOptions as i, type ChainExplanation as j, type ConditionOperatorFn as k, type CycleTimeStats as l, type ExplainedLevel as m, type ExplainedRule as n, type ExplainedSkip as o, type IdempotencyKeyFn as p, type ImportResult as q, type OutOfOfficeProvider as r, type OverrideOptions as s, type PreviewResult as t, type ProvideInfoOptions as u, type PurgeResult as v, type RejectOptions as w, type RequestInfoOptions as x, type ResubmitOptions as y, type RetryPolicy as z };
@@ -299,6 +299,52 @@ interface PreviewResult {
299
299
  /** Indices (0-based) of conditions that fired for this data. */
300
300
  conditionsApplied: number[];
301
301
  }
302
+ /** Where one level in an explained chain came from. */
303
+ interface ExplainedLevel {
304
+ level: number;
305
+ name: string;
306
+ mode: ApprovalMode;
307
+ /** `'template'` for a statically declared level, `'condition'` for one a rule added. */
308
+ source: 'template' | 'condition';
309
+ /** Index of the condition rule that added it, when `source` is `'condition'`. */
310
+ addedByRule?: number;
311
+ resolvedApprovers: string[];
312
+ /** Why approver resolution failed, when it did. The level is still listed. */
313
+ resolutionError?: string;
314
+ /** Set when this level hands off to a child approval. */
315
+ subWorkflowTemplate?: string;
316
+ }
317
+ /** A level the template declares that will not run, and the rule that removed it. */
318
+ interface ExplainedSkip {
319
+ level: number;
320
+ name: string;
321
+ /** Index of the condition rule whose `skipLevels` removed it. */
322
+ skippedByRule: number;
323
+ }
324
+ /** How one condition rule evaluated against the data. */
325
+ interface ExplainedRule {
326
+ index: number;
327
+ matched: boolean;
328
+ /** Levels this rule would add. Present whether or not it matched. */
329
+ addsLevels: number[];
330
+ /** Levels this rule would skip. Present whether or not it matched. */
331
+ skipsLevels: number[];
332
+ /** Why the rule could not be evaluated, e.g. an unregistered operator. */
333
+ error?: string;
334
+ }
335
+ /**
336
+ * A full account of why a chain looks the way it does.
337
+ *
338
+ * `previewApprovalChain()` answers *what* the chain will be; this answers *why*,
339
+ * which is the question a support engineer actually has when a purchase order
340
+ * arrives with a level nobody expected.
341
+ */
342
+ interface ChainExplanation {
343
+ templateName: string;
344
+ levels: ExplainedLevel[];
345
+ skipped: ExplainedSkip[];
346
+ rules: ExplainedRule[];
347
+ }
302
348
  interface BulkResult {
303
349
  succeeded: ApprovalInstance[];
304
350
  failed: Array<{
@@ -677,6 +723,28 @@ declare class ApprovalEngine {
677
723
  /** Preview the resolved approval chain for a template and document data, without creating an instance. */
678
724
  previewApprovalChain(templateName: string, data: Record<string, unknown>, submittedBy: string): Promise<PreviewResult>;
679
725
  /** Check whether a user is eligible to approve a specific instance. Never throws. */
726
+ /**
727
+ * Explain why a chain resolves the way it does for a given document.
728
+ *
729
+ * `previewApprovalChain()` answers *what* the chain will be. This answers
730
+ * *why*: which rule added a level, which rule removed one, which rules were
731
+ * evaluated and did not match, and where each level's approvers came from —
732
+ * the question behind "why does this purchase order have a CFO level?", which
733
+ * previously meant reading the template and re-evaluating the conditions by
734
+ * hand.
735
+ *
736
+ * A rule that throws — an operator nobody registered, a malformed group — is
737
+ * reported against that rule rather than failing the whole explanation. The
738
+ * explanation is a diagnostic tool, and it is least useful at exactly the
739
+ * moment a broken rule makes it throw.
740
+ *
741
+ * Reads nothing and writes nothing; safe to expose to a support UI.
742
+ *
743
+ * @param templateName - Template to explain.
744
+ * @param data - Document data the conditions are evaluated against.
745
+ * @param submittedBy - Submitter, used for approver resolution.
746
+ */
747
+ explainChain(templateName: string, data: Record<string, unknown>, submittedBy: string): Promise<ChainExplanation>;
680
748
  canApprove(instanceId: string, userId: string): Promise<CanApproveResult>;
681
749
  /** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
682
750
  override(instanceId: string, raw: OverrideOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
@@ -964,4 +1032,4 @@ declare class ApprovalEngine {
964
1032
  private runExternalAudit;
965
1033
  }
966
1034
 
967
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, toComparableNumber as F, validateConditionExpression as G, type HealthResult as H, type IdGeneratorFn as I, weekendCalendar as J, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, TEMPLATE_BUNDLE_VERSION as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type ApproverWorkload as f, type BusinessCalendar as g, type BusinessHoursCalendarOptions as h, type CancelOptions as i, type ConditionOperatorFn as j, type CycleTimeStats as k, type IdempotencyKeyFn as l, type ImportResult as m, type OutOfOfficeProvider as n, type OverrideOptions as o, type PreviewResult as p, type ProvideInfoOptions as q, type PurgeResult as r, type RejectOptions as s, type RequestInfoOptions as t, type ResubmitOptions as u, type RetryPolicy as v, type TemplateBundle as w, type TransferResult as x, businessHoursCalendar as y, defaultIdGenerator as z };
1035
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type TemplateBundle as F, type TransferResult as G, type HealthResult as H, type IdGeneratorFn as I, businessHoursCalendar as J, defaultIdGenerator as K, toComparableNumber as L, validateConditionExpression as M, weekendCalendar as N, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, TEMPLATE_BUNDLE_VERSION as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type ApproverWorkload as f, type BusinessCalendar as g, type BusinessHoursCalendarOptions as h, type CancelOptions as i, type ChainExplanation as j, type ConditionOperatorFn as k, type CycleTimeStats as l, type ExplainedLevel as m, type ExplainedRule as n, type ExplainedSkip as o, type IdempotencyKeyFn as p, type ImportResult as q, type OutOfOfficeProvider as r, type OverrideOptions as s, type PreviewResult as t, type ProvideInfoOptions as u, type PurgeResult as v, type RejectOptions as w, type RequestInfoOptions as x, type ResubmitOptions as y, type RetryPolicy as z };
package/dist/index.cjs CHANGED
@@ -2693,6 +2693,98 @@ var ApprovalEngine = class _ApprovalEngine {
2693
2693
  return { levels, conditionsApplied };
2694
2694
  }
2695
2695
  /** Check whether a user is eligible to approve a specific instance. Never throws. */
2696
+ /**
2697
+ * Explain why a chain resolves the way it does for a given document.
2698
+ *
2699
+ * `previewApprovalChain()` answers *what* the chain will be. This answers
2700
+ * *why*: which rule added a level, which rule removed one, which rules were
2701
+ * evaluated and did not match, and where each level's approvers came from —
2702
+ * the question behind "why does this purchase order have a CFO level?", which
2703
+ * previously meant reading the template and re-evaluating the conditions by
2704
+ * hand.
2705
+ *
2706
+ * A rule that throws — an operator nobody registered, a malformed group — is
2707
+ * reported against that rule rather than failing the whole explanation. The
2708
+ * explanation is a diagnostic tool, and it is least useful at exactly the
2709
+ * moment a broken rule makes it throw.
2710
+ *
2711
+ * Reads nothing and writes nothing; safe to expose to a support UI.
2712
+ *
2713
+ * @param templateName - Template to explain.
2714
+ * @param data - Document data the conditions are evaluated against.
2715
+ * @param submittedBy - Submitter, used for approver resolution.
2716
+ */
2717
+ async explainChain(templateName, data, submittedBy) {
2718
+ const template = await this.registry.get(templateName);
2719
+ const conditions = template.conditions ?? [];
2720
+ const rules = [];
2721
+ const addedBy = /* @__PURE__ */ new Map();
2722
+ const skippedBy = /* @__PURE__ */ new Map();
2723
+ conditions.forEach((rule, index) => {
2724
+ const addsLevels = (rule.addLevels ?? []).map((l) => l.level);
2725
+ const skipsLevels = rule.skipLevels ?? [];
2726
+ try {
2727
+ const outcome = evaluateConditions([rule], data);
2728
+ const matched = outcome.addLevels.length > 0 || outcome.skipLevels.size > 0;
2729
+ rules.push({ index, matched, addsLevels, skipsLevels });
2730
+ if (matched) {
2731
+ for (const l of outcome.addLevels) if (!addedBy.has(l.level)) addedBy.set(l.level, index);
2732
+ for (const l of outcome.skipLevels) if (!skippedBy.has(l)) skippedBy.set(l, index);
2733
+ }
2734
+ } catch (err) {
2735
+ rules.push({
2736
+ index,
2737
+ matched: false,
2738
+ addsLevels,
2739
+ skipsLevels,
2740
+ error: err.message
2741
+ });
2742
+ }
2743
+ });
2744
+ let mutations;
2745
+ try {
2746
+ mutations = evaluateConditions(conditions, data);
2747
+ } catch {
2748
+ mutations = { addLevels: [], skipLevels: /* @__PURE__ */ new Set() };
2749
+ }
2750
+ const active = [...template.levels, ...mutations.addLevels].filter((l) => !mutations.skipLevels.has(l.level)).sort((a, b) => a.level - b.level);
2751
+ const levels = [];
2752
+ for (const cfg of active) {
2753
+ const fromCondition = addedBy.has(cfg.level);
2754
+ const base = {
2755
+ level: cfg.level,
2756
+ name: cfg.name,
2757
+ mode: cfg.mode,
2758
+ source: fromCondition ? "condition" : "template",
2759
+ ...fromCondition ? { addedByRule: addedBy.get(cfg.level) } : {},
2760
+ resolvedApprovers: [],
2761
+ ...cfg.subWorkflow ? { subWorkflowTemplate: cfg.subWorkflow.templateName } : {}
2762
+ };
2763
+ if (cfg.subWorkflow) {
2764
+ levels.push(base);
2765
+ continue;
2766
+ }
2767
+ try {
2768
+ base.resolvedApprovers = await this.resolver.resolveApprovers(
2769
+ cfg.approvers,
2770
+ submittedBy,
2771
+ data,
2772
+ this.opts.orgProvider,
2773
+ this.opts.outOfOfficeProvider,
2774
+ this.clock.now()
2775
+ );
2776
+ } catch (err) {
2777
+ base.resolutionError = err.message;
2778
+ }
2779
+ levels.push(base);
2780
+ }
2781
+ const skipped = template.levels.filter((l) => mutations.skipLevels.has(l.level)).map((l) => ({
2782
+ level: l.level,
2783
+ name: l.name,
2784
+ skippedByRule: skippedBy.get(l.level) ?? -1
2785
+ })).sort((a, b) => a.level - b.level);
2786
+ return { templateName: template.name, levels, skipped, rules };
2787
+ }
2696
2788
  async canApprove(instanceId, userId) {
2697
2789
  let instance;
2698
2790
  try {