npm library 351 downloads / week MIT

Stop hand-rolling
approval logic.

A TypeScript-first workflow engine for ERP approval chains — optimistic locking, idempotency, tamper-evident audit, SLA tracking, and conditional routing. Drop-in plug-ins for compliance, observability and access control. No BPMN. No infrastructure.

1271 tests 6 extension ports 7 plug-in suites 2 runtime deps
$ npm install hierarchical-approval
See use cases

Approval workflows are deceptively simple until they aren't.

Most teams start with if (amount > 1000) notifyManager() and end up rewriting it every six months. This library gives that logic a permanent, tested home.

Without hierarchical-approval
With hierarchical-approval
Hardcoded approval chains
Named templates — define once, reuse across any document type
Race conditions on concurrent approvals
Optimistic locking with a version field + configurable retry policy
Duplicate submissions on retry or double-click
Built-in idempotency keyed by tenant + document + template
No compliance trail
Immutable audit log with old/new state diff on every mutation
new Date() makes tests unreliable
Injectable Clock interface — freeze time without monkey-patching
"Escalate to skip-level after 48 h"
Built-in escalation scheduler with delegation + time-limited proxying
SLA as an afterthought
SLA tracking baked in; approval:sla_breached event fires automatically
Test suites hit a real database
ApprovalTestKit + ManualClock — deterministic, zero I/O
Kafka/Datadog/BullMQ integration needed
Six pluggable adapter interfaces for every cross-cutting concern

Three calls. Fully wired.

Locking, idempotency, SLA tracking, audit entries, and events happen automatically — no extra code.

Define a template

Declare who approves, in what order, under what conditions. The template is snapshotted at submit time — you can update it without affecting in-flight instances.

  • Chain built fresh on every submit()
  • Conditions evaluated against document data
  • Works with any Zod schema you provide
const engine = new ApprovalEngine({
  adapter: new MemoryAdapter(), // swap PostgresAdapter in prod
  // Resolves the 'finance-team' role used by the Finance level
  orgProvider: {
    getUsersByRole: (role) => (role === 'finance-team' ? ['fin-1'] : []),
  },
});

await engine.defineTemplate({
  name: 'purchase-order',
  documentType: 'purchase_order',
  levels: [
    { level: 1, name: 'Manager',
      approvers: [{ type: 'user', userId: 'mgr-1' }],
      mode: 'any' },
  ],
  conditions: [{
    when: { field: 'amount', operator: '>', value: 10000 },
    addLevels: [{
      level: 2, name: 'Finance',
      approvers: [{ type: 'role', role: 'finance-team' }],
      mode: 'any',
    }],
  }],
  slaDeadlineDays: 2,
});

Submit a document

Conditions are evaluated at submit time, and the engine builds the exact approval chain for this document. Editing the data later with updateData() re-evaluates only the levels not yet reached. Submitting the same document twice returns the existing instance — idempotency built in.

  • Levels already reached stay frozen
  • Duplicate submits return the same instance
  • Audit entry created automatically
const instance = await engine.submit({
  templateName: 'purchase-order',
  documentId: 'po-0042',
  documentType: 'purchase_order',
  submittedBy: 'alice',
  data: { amount: 15000, vendor: 'Acme Corp' },
});

// instance.levels has two levels because amount > 10000
// instance.status === 'pending'
// instance.idempotencyKey prevents duplicates on retry

Approvers act

Each approval advances the chain. The final approval marks the instance complete and emits the approval:completed event. Rejections, delegations, and SLA escalations are handled identically.

  • Optimistic locking on every write
  • CONFLICT thrown on concurrent approvals
  • Events fired after every state change
  • getOpenLevels() reports what it is waiting on
// Listen for completion before the last approval lands
engine.on('approval:completed', (inst) => {
  console.log(inst.status); // 'approved'
  console.log(inst.auditLog.length); // 3 entries
});

// Manager approves
await engine.approve(instance.id, {
  approverId: 'mgr-1',
  comment: 'Looks good',
});

// The levels still collecting decisions: the approval frontier
await engine.getOpenLevels(instance.id); // [2]

// Finance approves — instance status is now 'approved'
await engine.approve(instance.id, { approverId: 'fin-1' });
await engine.getOpenLevels(instance.id); // [] once terminal

Everything ERP approval logic demands.

The hard problems are solved at the library level so you write business rules, not infrastructure.

Optimistic locking

Every instance carries a version counter. Concurrent approvals detect conflicts and throw CONFLICT — the caller re-fetches and retries.

Idempotency

Submitting the same document twice returns the existing in-flight instance. Keyed by SHA-256 of tenant + docType + docId + template.

Immutable audit trail

Every mutation appends an AuditEntry with actor, timestamp, old and new values, and comment. Nothing is ever overwritten.

SLA tracking

Set slaDeadlineDays on a template. The engine computes the deadline at submit time and fires approval:sla_breached automatically.

Conditional chains

Levels activate or skip based on document data. Use built-in operators (>, <, in, not_in) or register custom ones with registerConditionOperator() — they are shared across every engine in the process.

Bulk operations

bulkApprove() and bulkReject() process up to 200 instances per call by default (maxBulkItems). Individual failures are collected in result.failed; a larger batch throws a validation error.

ApprovalTestKit

ApprovalTestKit.create() returns {engine, adapter, clock} pre-wired. ManualClock.advanceDays(n) tests SLA and escalation without real timers.

Six pluggable adapters

Notification, Audit, Metrics, Scheduler, Authorization, Middleware — each a typed interface. Notification and audit failures are logged and swallowed; an authorization policy can deny an operation.

Explicit approval frontier

instance.openLevels lists every level collecting decisions; engine.getOpenLevels(id) returns it, and it is empty once the instance is terminal. currentLevel is only the lowest of them, so inside a parallel group use openLevels to decide who may act.

Running in under a minute.

The MemoryAdapter works out of the box — no database required for development or testing. Swap in PostgresAdapter for production with a one-line change.

index.ts
TypeScript ESM + CJS
import { ApprovalEngine } from 'hierarchical-approval';
import { MemoryAdapter } from 'hierarchical-approval/adapters/memory';

const engine = new ApprovalEngine({
  adapter: new MemoryAdapter(),
  tenantId: 'acme',
  // Resolves the 'finance-team' role used by the Finance level below
  orgProvider: {
    getUsersByRole: (role) => (role === 'finance-team' ? ['fin-1'] : []),
  },
});

// 1 — Define a reusable template
await engine.defineTemplate({
  name: 'purchase-order',
  documentType: 'purchase_order',
  levels: [
    {
      level: 1,
      name: 'Manager',
      approvers: [{ type: 'user', userId: 'mgr-1' }],
      mode: 'any',
    },
  ],
  // Finance level only activates above $10k
  conditions: [
    {
      when: { field: 'amount', operator: '>', value: 10000 },
      addLevels: [
        {
          level: 2,
          name: 'Finance',
          approvers: [{ type: 'role', role: 'finance-team' }],
          mode: 'any',
        },
      ],
    },
  ],
  slaDeadlineDays: 2,
  allowOverride: true,
});

// 2 — Submit a document
const instance = await engine.submit({
  templateName: 'purchase-order',
  documentId: 'po-0042',
  documentType: 'purchase_order',
  submittedBy: 'alice',
  data: { amount: 15000, vendor: 'Acme Corp' },
});
// instance.levels has two levels because amount > 10000

// 3 — Manager approves
await engine.approve(instance.id, {
  approverId: 'mgr-1',
  comment: 'Looks good',
});

// 4 — Finance approves — instance is now 'approved'
await engine.approve(instance.id, { approverId: 'fin-1' });

await engine.shutdown();
PostgreSQL for production: install pg @types/pg, swap MemoryAdapter for new PostgresAdapter({ connectionString }, call adapter.migrate() once.

Why not just use…

approval-flow is single-level only; last published 2020. workflow-engine is a generic state machine — you implement every guard and audit entry yourself. Hand-rolled will eventually hit the concurrent-approval race condition. This library has 195 tests covering it. One honest trade-off: hand-rolling a single if/else is simpler to start — this library requires a defineTemplate call.

Feature hierarchical-approval approval-flow workflow-engine hand-rolled
TypeScript-first ✓ — — —
Multi-tenancy ✓ — — —
Optimistic locking ✓ — — ?
Idempotency ✓ — — —
Immutable audit trail ✓ — — —
SLA tracking ✓ — — —
Conditional chains ✓ — ~ ?
Escalation + delegation ✓ — — —
Test utilities ✓ — — —
Actively maintained ✓ — ~ ✓
Zero config to start ~ ✓ — ✓

Plug in. Don't fork.

Six typed interfaces let you wire the engine into your stack. Notification and audit adapters are fail-safe — errors are logged and swallowed, so a broken Kafka connection will never prevent an approval from completing. An authorization policy, by design, can deny an operation. Need them ready-made? Jump to the built-in plug-ins ↓

INotificationAdapter
Notify on every approval event

Slack, email, push — fires after submit, approve, reject, escalate, and more.

class SlackNotifier implements INotificationAdapter {
  async notify(event: NotificationEvent) {
    // event.type, event.recipients, event.instanceId
    if (event.type === 'approval:level_advanced') {
      await slack.post(event.recipients, 'Approval needed');
    }
  }
}
IAuditAdapter
Write-once external audit sink

Kafka, S3, CloudTrail — called in addition to the built-in audit log.

class KafkaAudit implements IAuditAdapter {
  async append(tenantId, instanceId, entry, instance) {
    await producer.send({
      topic: 'approvals.audit',
      messages: [{ value: JSON.stringify({
        entry, version: instance.version
      }) }],
    });
  }
}
IMetricsAdapter
Prometheus / Datadog / OpenTelemetry

17 built-in metric names including approval.operation_duration_ms.

class PrometheusAdapter implements IMetricsAdapter {
  increment(metric: MetricName, labels?: Record<string, string>) {
    counters[metric].labels(labels ?? {}).inc();
  }
  timing(metric, durationMs, labels) {
    histograms[metric].observe(durationMs / 1000);
  }
}
ISchedulerAdapter
BullMQ, Temporal, EventBridge

Replace the built-in setInterval escalation poll with a proper job queue.

class BullMQScheduler implements ISchedulerAdapter {
  async scheduleAt(id, runAt, callback) {
    const job = await queue.add('tick', { id },
      { delay: runAt.getTime() - Date.now() });
    return job.id;
  }
  async cancel(handle) { await queue.remove(handle); }
  async shutdown() { await queue.close(); }
}
IAuthorizationPolicy
Per-operation access control

Signing authority caps, department restrictions, time-window gates.

class SigningPolicy implements IAuthorizationPolicy {
  async authorize(ctx: AuthorizationContext) {
    if (ctx.operation === 'override') {
      const cap = await budgetSystem.getSigningCap(ctx.actorId);
      if (ctx.instance.data.amount > cap)
        return 'Signing cap exceeded';
    }
    return undefined; // allow
  }
}
IOperationMiddleware
Before / after / onError hooks

OpenTelemetry tracing, audit enrichment, per-tenant quota enforcement.

class TracingMiddleware implements IOperationMiddleware {
  async before(ctx: OperationContext) {
    const span = tracer.startSpan('approval.' + ctx.operation);
    this.spans.set(ctx.instanceId, span);
  }
  async after(ctx, result) {
    this.spans.get(ctx.instanceId)?.finish();
  }
}

Production plug-ins, not just interfaces.

The adapter ports above are contracts. These are the implementations — drop-in, dependency-free modules for the concerns every enterprise audit asks about. Each ships on its own import path, so you bundle only what you use.

Tamper-evident audit

hierarchical-approval/plugins/audit

Make the immutable audit trail provably immutable — and safe to ship to external sinks.

  • HashChainAuditAdapter SHA-256 chain; verify() catches edits, deletions, reorders & truncation
  • RedactingAuditAdapter strips PII before external sinks — originals never mutated
  • CompositeAuditAdapter fan out to Kafka, S3, CloudTrail with per-sink fault isolation
import { HashChainAuditAdapter } from
  'hierarchical-approval/plugins/audit';

const audit = new HashChainAuditAdapter();
const engine = new ApprovalEngine({ adapter, auditAdapter: audit });

// Prove the log was never altered:
await audit.verify(tenantId, instanceId);
// → { ok: true } | { ok: false, brokenAt: 3 }

Observability

hierarchical-approval/plugins/metrics

A Prometheus endpoint and dashboard-ready snapshots, with zero dependencies.

  • PrometheusMetricsAdapter scrape() returns valid Prometheus exposition text
  • InMemoryMetricsAdapter snapshot() with count/sum/min/max/avg/p50/p95
  • CompositeMetricsAdapter feed several backends from one engine
import { PrometheusMetricsAdapter } from
  'hierarchical-approval/plugins/metrics';

const metrics = new PrometheusMetricsAdapter();
new ApprovalEngine({ adapter, metricsAdapter: metrics });

// Expose on /metrics:
res.type('text/plain').send(metrics.scrape());

Resilience & access

hierarchical-approval/plugins/resilience

Role-based access, abuse protection and structured logging — closed by default.

  • RbacAuthorizationPolicy per-operation role rules, default-deny posture
  • RateLimitMiddleware token-bucket throttling per actor + operation
  • LoggingMiddleware structured before / after / onError with timings
import { RbacAuthorizationPolicy, RateLimitMiddleware }
  from 'hierarchical-approval/plugins/resilience';

new ApprovalEngine({
  adapter,
  authorizationPolicy: new RbacAuthorizationPolicy({
    defaultMode: 'deny',
    rules: { override: { roles: ['admin'] } },
    roleProvider,
  }),
  middleware: [new RateLimitMiddleware({
    capacity: 20, refillTokensPerSecond: 5,
  })],
});

Reliable delivery

hierarchical-approval/plugins/notify

At-least-once notifications with retries, backoff and a dead-letter queue.

  • OutboxNotificationAdapter transactional outbox + exponential backoff + dead-letter
  • TemplatedNotificationAdapter render a human message per event type
  • CompositeNotificationAdapter multi-channel fan-out, fault-isolated
import { OutboxNotificationAdapter } from
  'hierarchical-approval/plugins/notify';

const outbox = new OutboxNotificationAdapter({
  transport: (event) => emailGateway.send(event),
  maxAttempts: 5,
});
new ApprovalEngine({ adapter, notificationAdapter: outbox });

await outbox.drain();        // deliver due items
await outbox.deadLettered(); // inspect failures

Distributed tracing

hierarchical-approval/plugins/tracing

One span per approval operation, on the OpenTelemetry shape — without the OpenTelemetry dependency.

  • TracingMiddleware span approval.<op> with tenant, actor, instance & error-code attributes
  • Tracer port structurally satisfied by a real @opentelemetry/api Tracer
  • noopTracer the default — tracing costs nothing until you wire it
import { TracingMiddleware } from
  'hierarchical-approval/plugins/tracing';

// A real OTel tracer satisfies the port structurally —
// no adapter, no extra dependency.
new ApprovalEngine({
  adapter,
  middleware: [new TracingMiddleware({
    tracer: otel.trace.getTracer('approvals'),
  })],
});

Signed webhooks

hierarchical-approval/plugins/webhook

Deliver every lifecycle event to your endpoint, signed, retried, and verifiable at the far end.

  • WebhookNotificationAdapter HMAC-SHA256 signature, per-request timeout, jittered backoff
  • Retry-After aware retries 5xx / 408 / 429; other 4xx fail fast
  • HttpClient port satisfied by the global fetch — zero new dependencies
import { WebhookNotificationAdapter } from
  'hierarchical-approval/plugins/webhook';

new ApprovalEngine({
  adapter,
  notificationAdapter: new WebhookNotificationAdapter({
    url: 'https://erp.example.com/hooks/approvals',
    secret: process.env.WEBHOOK_SECRET,
  }),
});

// Receiver verifies: HMAC over `<timestamp>.<body>`
// sent as  X-Approval-Signature: t=...,v1=...

Pluggable scheduling

hierarchical-approval/plugins/scheduler

Drive escalation and SLA deadlines from your own job runner instead of an in-process timer.

  • InMemorySchedulerAdapter setTimeout-based reference implementation, cancellable
  • ISchedulerAdapter port back it with BullMQ, Temporal, cron — anything one-shot
  • Replaces polling supply one and the built-in setInterval never starts
import { InMemorySchedulerAdapter } from
  'hierarchical-approval/plugins/scheduler';

// Or implement ISchedulerAdapter over BullMQ / Temporal
// to survive restarts across a fleet.
new ApprovalEngine({
  adapter,
  schedulerAdapter: new InMemorySchedulerAdapter(),
});
Zero runtime dependencies Fully tree-shakeable Injectable clock & logger for tests

From idea to production in hours.

Every use case ships with complete TypeScript code and handles the edge cases you'll actually hit.

Your next approval workflow
ships faster than the last one.

Drop it into any Node.js project. No database required for local development.

$ npm install hierarchical-approval

Peer dep for Postgres: npm install pg @types/pg