shadimage
approvals-ui
libapprovals-coresource: ok · hit

Approvals core

Headless core for approval policies: Zod schema (gates, quorums, conditions, SLAs), a policy lint with structural errors and best-practice warnings, a step-level diff, and deterministic edit ops for safe LLM or form-driven edits.

npx shadcn add "https://approvals-ui.vercel.app/r/approvals-core.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
formszod

Files5 · source cached 10/8/2026, 1:41:37 PM

lib/approvals-ui/policy.ts
import { z } from "zod";

/**
 * approvals-ui policy schema.
 *
 * An ApprovalPolicy is a DAG of steps. Execution enters at `roots`, follows
 * `next` edges, and ends at terminal steps. Every step carries a `when`
 * condition (a guard): a step whose condition is false for a given request is
 * skipped and its edges pass through. Conditions never live on edges, so the
 * graph stays legible and the diff stays small.
 */

// ---------------------------------------------------------------------------
// Conditions
// ---------------------------------------------------------------------------

export const conditionOps = [">", ">=", "<", "<=", "==", "!="] as const;
export type ConditionOp = (typeof conditionOps)[number];

export type ConditionLeaf = {
  kind: "leaf";
  /** The request field this guard reads, e.g. "amount", "department". */
  field: string;
  op: ConditionOp;
  value: string | number;
};

export type Condition =
  | ConditionLeaf
  | { kind: "always" }
  | { kind: "all"; conditions: Condition[] }
  | { kind: "any"; conditions: Condition[] };

const leafValueSchema = z.union([z.string(), z.number()]);

export const conditionSchema: z.ZodType<Condition> = z.lazy(() =>
  z.discriminatedUnion("kind", [
    z.object({ kind: z.literal("always") }),
    z.object({
      kind: z.literal("leaf"),
      field: z.string().min(1),
      op: z.enum(conditionOps),
      value: leafValueSchema,
    }),
    z.object({
      kind: z.literal("all"),
      conditions: z.array(conditionSchema).min(1),
    }),
    z.object({
      kind: z.literal("any"),
      conditions: z.array(conditionSchema).min(1),
    }),
  ])
);

export const always: Condition = { kind: "always" };

export const leaf = (field: string, op: ConditionOp, value: string | number): Condition => {
  return { kind: "leaf", field, op, value };
};

/** Every leaf in the condition tree, in reading order. */
export const collectLeaves = (condition: Condition): ConditionLeaf[] => {
  switch (condition.kind) {
    case "always": {
      return [];
    }
    case "leaf": {
      return [condition];
    }
    case "all":
    case "any": {
      return condition.conditions.flatMap((c) => collectLeaves(c));
    }
  }
};

const formatValue = (value: string | number): string => {
  return typeof value === "number" ? value.toLocaleString("en-US") : value;
};

/** Human-readable condition, for pills and tooltips. */
export const humanizeCondition = (condition: Condition): string => {
  switch (condition.kind) {
    case "always": {
      return "always";
    }
    case "leaf": {
      return `${condition.field} ${condition.op} ${formatValue(condition.value)}`;
    }
    case "all": {
      return condition.conditions
        .map((c) =>
          c.kind === "all" || c.kind === "any" ? `(${humanizeCondition(c)})` : humanizeCondition(c)
        )
        .join(" and ");
    }
    case "any": {
      return condition.conditions
        .map((c) =>
          c.kind === "all" || c.kind === "any" ? `(${humanizeCondition(c)})` : humanizeCondition(c)
        )
        .join(" or ");
    }
  }
};

/** Canonical string for a condition. Stable, locale-free: used by the diff. */
export const describeCondition = (condition: Condition): string => {
  switch (condition.kind) {
    case "always": {
      return "always";
    }
    case "leaf": {
      return `${condition.field}${condition.op}${String(condition.value)}`;
    }
    case "all": {
      return `all(${condition.conditions.map((c) => describeCondition(c)).join(",")})`;
    }
    case "any": {
      return `any(${condition.conditions.map((c) => describeCondition(c)).join(",")})`;
    }
  }
};

// ---------------------------------------------------------------------------
// Approvers and steps
// ---------------------------------------------------------------------------

export const approverSchema = z.object({
  /** null = seat exists but nobody is assigned yet (surfaced by validation). */
  name: z.string().min(1).nullable(),
  title: z.string().min(1),
});
export type Approver = z.infer<typeof approverSchema>;

export const approvalModes = ["all", "any", "quorum"] as const;
export type ApprovalMode = (typeof approvalModes)[number];

export const slaSchema = z.object({
  hours: z.number().positive(),
  escalateTo: approverSchema.optional(),
});
export type Sla = z.infer<typeof slaSchema>;

export const approvalStepSchema = z.object({
  id: z.string().min(1),
  kind: z.literal("approval"),
  label: z.string().min(1),
  when: conditionSchema,
  approvers: z.array(approverSchema).min(1),
  /** all = every approver signs, any = one is enough, quorum = `quorum` of them. */
  mode: z.enum(approvalModes),
  quorum: z.number().int().positive().optional(),
  sla: slaSchema.optional(),
  next: z.array(z.string()),
});
export type ApprovalStep = z.infer<typeof approvalStepSchema>;

export const terminalStepSchema = z.object({
  id: z.string().min(1),
  kind: z.literal("terminal"),
  label: z.string().min(1),
  when: conditionSchema,
  outcome: z.enum(["approved", "rejected"]),
  next: z.array(z.string()).max(0),
});
export type TerminalStep = z.infer<typeof terminalStepSchema>;

export const policyStepSchema = z.discriminatedUnion("kind", [
  approvalStepSchema,
  terminalStepSchema,
]);
export type PolicyStep = z.infer<typeof policyStepSchema>;

export const approvalPolicySchema = z.object({
  name: z.string().min(1),
  steps: z.array(policyStepSchema),
  /** Entry points: step ids with no incoming edge. */
  roots: z.array(z.string()).min(1),
});
export type ApprovalPolicy = z.infer<typeof approvalPolicySchema>;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

export const isApprovalStep = (step: PolicyStep): step is ApprovalStep => {
  return step.kind === "approval";
};

export const isTerminalStep = (step: PolicyStep): step is TerminalStep => {
  return step.kind === "terminal";
};

export const stepById = (policy: ApprovalPolicy): Map<string, PolicyStep> => {
  return new Map(policy.steps.map((s) => [s.id, s]));
};

/** Assigned approver names on a gate (unassigned seats excluded). */
export const approverNames = (step: ApprovalStep): string[] => {
  return step.approvers.flatMap((a) => (a.name === null ? [] : [a.name]));
};

/** How many signatures the gate needs before it passes. */
export const requiredApprovals = (step: ApprovalStep): number => {
  switch (step.mode) {
    case "all": {
      return step.approvers.length;
    }
    case "any": {
      return 1;
    }
    case "quorum": {
      return step.quorum ?? step.approvers.length;
    }
  }
};

/** "2 of 3", "any of 2", or "all" style summary for badges. */
export const summarizeMode = (step: ApprovalStep): string => {
  const total = step.approvers.length;
  switch (step.mode) {
    case "all": {
      return total > 1 ? `all ${total}` : "1 approver";
    }
    case "any": {
      return `any of ${total}`;
    }
    case "quorum": {
      return `${step.quorum ?? total} of ${total}`;
    }
  }
};
lib/approvals-ui/validate.ts
import {
  type ApprovalPolicy,
  type ApprovalStep,
  approverNames,
  collectLeaves,
  describeCondition,
  isApprovalStep,
  isTerminalStep,
  type PolicyStep,
  stepById,
} from "./policy";

/**
 * Deterministic policy validation. Two severities:
 *
 * - "error": the graph is structurally broken (dangling edge, cycle,
 *   unreachable step, no approved outcome). Block activation on these.
 * - "warning": the graph works but breaks an approval best practice
 *   (unassigned seat, duplicate gate, segregation of duties, a high-value
 *   path with a single approver). Surface them, let the human decide.
 */

export type IssueSeverity = "error" | "warning";

export type PolicyIssue = {
  severity: IssueSeverity;
  code: string;
  message: string;
  stepIds: string[];
};

export type ValidateOptions = {
  /** Amount above which a path should carry at least two approval gates. */
  materiality?: number;
  /** The request field carrying the monetary amount. */
  amountField?: string;
};

const DEFAULTS: Required<ValidateOptions> = {
  materiality: 25_000,
  amountField: "amount",
};

export const validatePolicy = (
  policy: ApprovalPolicy,
  options: ValidateOptions = {}
): PolicyIssue[] => {
  const opts = { ...DEFAULTS, ...options };
  const byId = stepById(policy);
  const issues: PolicyIssue[] = [
    ...duplicateStepIds(policy),
    ...danglingEdges(policy, byId),
    ...rootsValid(policy, byId),
    ...cycleFree(policy, byId),
    ...reachability(policy, byId),
    ...terminals(policy, byId),
    ...quorumValid(policy),
    ...unresolvedApprovers(policy),
    ...duplicateGates(policy),
    ...pathRules(policy, byId, opts),
  ];
  return dedupe(issues);
};

/** No error-severity issue: safe to activate. */
export const isActivatable = (issues: PolicyIssue[]): boolean => {
  return issues.every((i) => i.severity !== "error");
};

// ---------------------------------------------------------------------------
// Structural rules (errors)
// ---------------------------------------------------------------------------

const duplicateStepIds = (policy: ApprovalPolicy): PolicyIssue[] => {
  const seen = new Map<string, number>();
  for (const step of policy.steps) {
    seen.set(step.id, (seen.get(step.id) ?? 0) + 1);
  }
  return [...seen]
    .filter(([, count]) => count > 1)
    .map(([id]) => ({
      severity: "error" as const,
      code: "duplicate-step-id",
      message: `Step id "${id}" is used more than once.`,
      stepIds: [id],
    }));
};

const danglingEdges = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {
  const issues: PolicyIssue[] = [];
  for (const step of policy.steps) {
    for (const nextId of step.next) {
      if (!byId.has(nextId)) {
        issues.push({
          severity: "error",
          code: "dangling-edge",
          message: `"${step.label}" routes to a step that does not exist ("${nextId}").`,
          stepIds: [step.id],
        });
      }
    }
  }
  return issues;
};

const rootsValid = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {
  if (policy.roots.length === 0) {
    return [
      {
        severity: "error",
        code: "no-roots",
        message: "The policy has no entry point.",
        stepIds: [],
      },
    ];
  }
  return policy.roots
    .filter((id) => !byId.has(id))
    .map((id) => ({
      severity: "error" as const,
      code: "unknown-root",
      message: `Entry point "${id}" is not a step in the policy.`,
      stepIds: [id],
    }));
};

/**
 * Drop one from the indegree of each existing successor of `step`, and queue
 * any successor whose indegree reaches zero. The inner half of Kahn's loop,
 * lifted into its own function so the outer traversal stays a single loop.
 */
const relaxSuccessors = (
  step: PolicyStep,
  byId: Map<string, PolicyStep>,
  indegree: Map<string, number>,
  queue: string[]
): void => {
  for (const nextId of step.next) {
    if (!byId.has(nextId)) continue;
    const d = (indegree.get(nextId) ?? 0) - 1;
    indegree.set(nextId, d);
    if (d === 0) queue.push(nextId);
  }
};

const cycleFree = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {
  const indegree = new Map<string, number>();
  for (const step of policy.steps) indegree.set(step.id, 0);
  for (const step of policy.steps) {
    for (const nextId of step.next) {
      if (byId.has(nextId)) {
        indegree.set(nextId, (indegree.get(nextId) ?? 0) + 1);
      }
    }
  }
  const queue = [...indegree].filter(([, d]) => d === 0).map(([id]) => id);
  let processed = 0;
  while (queue.length > 0) {
    const id = queue.shift();
    if (id === undefined) continue;
    processed += 1;
    const step = byId.get(id);
    if (!step) continue;
    relaxSuccessors(step, byId, indegree, queue);
  }
  if (processed >= byId.size) return [];
  const stuck = [...indegree].filter(([, d]) => d > 0).map(([id]) => id);
  return [
    {
      severity: "error",
      code: "cycle",
      message: "Approvals must flow one way: the policy contains a cycle.",
      stepIds: stuck,
    },
  ];
};

const reachableFromRoots = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): Set<string> => {
  const reached = new Set<string>();
  const queue = policy.roots.filter((id) => byId.has(id));
  while (queue.length > 0) {
    const id = queue.shift();
    if (id === undefined) continue;
    if (reached.has(id)) continue;
    reached.add(id);
    const step = byId.get(id);
    if (!step) continue;
    for (const nextId of step.next) {
      if (byId.has(nextId) && !reached.has(nextId)) queue.push(nextId);
    }
  }
  return reached;
};

const reachability = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {
  const reached = reachableFromRoots(policy, byId);
  return policy.steps
    .filter((step) => !reached.has(step.id))
    .map((step) => ({
      severity: "error" as const,
      code: "unreachable-step",
      message: `"${step.label}" can never be reached from an entry point.`,
      stepIds: [step.id],
    }));
};

const terminals = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {
  const terminalSteps = policy.steps.filter(isTerminalStep);
  if (terminalSteps.length === 0) {
    return [
      {
        severity: "error",
        code: "no-terminal",
        message: "The policy has no terminal step, so no request can ever finish.",
        stepIds: [],
      },
    ];
  }
  const approved = terminalSteps.filter((t) => t.outcome === "approved");
  if (approved.length === 0) {
    return [
      {
        severity: "error",
        code: "no-approved-terminal",
        message: "No terminal step has the approved outcome: nothing can ever pass.",
        stepIds: terminalSteps.map((t) => t.id),
      },
    ];
  }
  const reached = reachableFromRoots(policy, byId);
  if (approved.every((t) => !reached.has(t.id))) {
    return [
      {
        severity: "error",
        code: "approved-terminal-unreachable",
        message: "The approved outcome can never be reached from an entry point.",
        stepIds: approved.map((t) => t.id),
      },
    ];
  }
  return [];
};

const quorumValid = (policy: ApprovalPolicy): PolicyIssue[] => {
  const issues: PolicyIssue[] = [];
  for (const step of policy.steps) {
    if (!isApprovalStep(step)) continue;
    if (step.mode === "quorum") {
      if (step.quorum === undefined || step.quorum > step.approvers.length) {
        issues.push({
          severity: "error",
          code: "quorum-invalid",
          message: `"${step.label}" asks for a quorum of ${step.quorum ?? "?"} but has ${step.approvers.length} approver${step.approvers.length === 1 ? "" : "s"}.`,
          stepIds: [step.id],
        });
      }
    } else if (step.quorum !== undefined) {
      issues.push({
        severity: "warning",
        code: "quorum-ignored",
        message: `"${step.label}" sets a quorum but its mode is "${step.mode}", so the quorum is ignored.`,
        stepIds: [step.id],
      });
    }
  }
  return issues;
};

// ---------------------------------------------------------------------------
// Best-practice rules (warnings)
// ---------------------------------------------------------------------------

const unresolvedApprovers = (policy: ApprovalPolicy): PolicyIssue[] => {
  const issues: PolicyIssue[] = [];
  for (const step of policy.steps) {
    if (!isApprovalStep(step)) continue;
    for (const approver of step.approvers) {
      if (approver.name === null) {
        issues.push({
          severity: "warning",
          code: "unresolved-approver",
          message: `"${step.label}" has an unassigned ${approver.title} seat.`,
          stepIds: [step.id],
        });
      }
    }
  }
  return issues;
};

const duplicateGates = (policy: ApprovalPolicy): PolicyIssue[] => {
  const seen = new Map<string, ApprovalStep>();
  const issues: PolicyIssue[] = [];
  for (const step of policy.steps) {
    if (!isApprovalStep(step)) continue;
    const roster = step.approvers.map((a) => `${a.name ?? "?"}:${a.title}`);
    roster.sort((a, b) => a.localeCompare(b));
    const key = [roster.join("|"), describeCondition(step.when)].join("::");
    const prior = seen.get(key);
    if (prior) {
      issues.push({
        severity: "warning",
        code: "duplicate-gate",
        message: `"${prior.label}" and "${step.label}" ask the same approvers under the same condition.`,
        stepIds: [prior.id, step.id],
      });
    } else {
      seen.set(key, step);
    }
  }
  return issues;
};

// ---------------------------------------------------------------------------
// Path rules (walk every root-to-approved path)
// ---------------------------------------------------------------------------

const MAX_PATHS = 2000;

const pathsToApproved = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyStep[][] => {
  const paths: PolicyStep[][] = [];
  const walk = (step: PolicyStep, path: PolicyStep[], seen: Set<string>) => {
    if (paths.length >= MAX_PATHS || seen.has(step.id)) return;
    const nextPath = [...path, step];
    if (isTerminalStep(step)) {
      if (step.outcome === "approved") paths.push(nextPath);
      return;
    }
    const nextSeen = new Set(seen);
    nextSeen.add(step.id);
    for (const nextId of step.next) {
      const next = byId.get(nextId);
      if (next) walk(next, nextPath, nextSeen);
    }
  };
  for (const rootId of policy.roots) {
    const root = byId.get(rootId);
    if (root) walk(root, [], new Set());
  }
  return paths;
};

/**
 * The lowest amount that traverses this path: the max of the lower bounds
 * set by amount guards along it. A path guarded by "amount > 25,000" has a
 * floor of 25,000; an unguarded path has a floor of 0.
 */
const amountFloor = (path: PolicyStep[], amountField: string): number => {
  let floor = 0;
  for (const step of path) {
    for (const l of collectLeaves(step.when)) {
      if (
        l.field === amountField &&
        typeof l.value === "number" &&
        (l.op === ">" || l.op === ">=")
      ) {
        floor = Math.max(floor, l.value);
      }
    }
  }
  return floor;
};

const pathRules = (
  policy: ApprovalPolicy,
  byId: Map<string, PolicyStep>,
  opts: Required<ValidateOptions>
): PolicyIssue[] => {
  const issues: PolicyIssue[] = [];
  for (const path of pathsToApproved(policy, byId)) {
    const gates = path.filter(isApprovalStep);
    const terminal = path.at(-1);
    // Every path built by pathsToApproved is non-empty and ends at a terminal.
    if (!terminal) continue;

    if (gates.length === 0) {
      issues.push({
        severity: "warning",
        code: "no-approval-before-terminal",
        message: `A request can reach "${terminal.label}" without any human approval.`,
        stepIds: [terminal.id],
      });
    }

    const floor = amountFloor(path, opts.amountField);
    if (floor >= opts.materiality && gates.length < 2) {
      issues.push({
        severity: "warning",
        code: "single-approver-high-value",
        message: `Requests above ${floor.toLocaleString("en-US")} can pass with ${gates.length === 0 ? "no approval gate" : "a single approval gate"}.`,
        stepIds: gates.map((g) => g.id),
      });
    }

    const gatesByName = new Map<string, ApprovalStep[]>();
    for (const gate of gates) {
      for (const name of approverNames(gate)) {
        const list = gatesByName.get(name) ?? [];
        if (!list.includes(gate)) list.push(gate);
        gatesByName.set(name, list);
      }
    }
    for (const [name, dupGates] of gatesByName) {
      if (dupGates.length >= 2) {
        issues.push({
          severity: "warning",
          code: "segregation-of-duties",
          message: `${name} approves twice on the same path (${dupGates.map((g) => `"${g.label}"`).join(", ")}).`,
          stepIds: dupGates.map((g) => g.id),
        });
      }
    }
  }
  return issues;
};

// ---------------------------------------------------------------------------

const dedupe = (issues: PolicyIssue[]): PolicyIssue[] => {
  const seen = new Set<string>();
  const out: PolicyIssue[] = [];
  for (const issue of issues) {
    const sortedStepIds = [...issue.stepIds];
    sortedStepIds.sort((a, b) => a.localeCompare(b));
    const key = `${issue.code}::${sortedStepIds.join(",")}`;
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(issue);
  }
  return out;
};
lib/approvals-ui/diff.ts
import { type ApprovalPolicy, describeCondition, type PolicyStep } from "./policy";

/**
 * Step-level diff between two policies, keyed by step id. This is the unit
 * the canvas renders (rings on added/changed/removed nodes) and the unit an
 * edit proposal reports before the human applies it.
 */
export type StepChange =
  | { kind: "added"; id: string; label: string }
  | { kind: "removed"; id: string; label: string }
  | { kind: "changed"; id: string; label: string; fields: string[] }
  | { kind: "unchanged"; id: string; label: string };

const roster = (step: PolicyStep): string => {
  if (step.kind !== "approval") return "";
  return step.approvers.map((a) => `${a.name ?? "?"}:${a.title}`).join("|");
};

const slaKey = (step: PolicyStep): string => {
  if (step.kind !== "approval" || !step.sla) return "";
  const escalate = step.sla.escalateTo
    ? `${step.sla.escalateTo.name ?? "?"}:${step.sla.escalateTo.title}`
    : "";
  return `${step.sla.hours}h${escalate}`;
};

export const stepFieldDiffs = (before: PolicyStep, after: PolicyStep): string[] => {
  const fields: string[] = [];
  if (before.kind !== after.kind) fields.push("type");
  if (before.label !== after.label) fields.push("label");
  if (describeCondition(before.when) !== describeCondition(after.when)) {
    fields.push("condition");
  }
  if (roster(before) !== roster(after)) fields.push("approvers");
  if (
    before.kind === "approval" &&
    after.kind === "approval" &&
    (before.mode !== after.mode || before.quorum !== after.quorum)
  ) {
    fields.push("mode");
  }
  if (slaKey(before) !== slaKey(after)) fields.push("sla");
  if (before.kind === "terminal" && after.kind === "terminal" && before.outcome !== after.outcome) {
    fields.push("outcome");
  }
  if (before.next.join(",") !== after.next.join(",")) fields.push("routing");
  return fields;
};

export const diffPolicies = (current: ApprovalPolicy, proposed: ApprovalPolicy): StepChange[] => {
  const before = new Map(current.steps.map((s) => [s.id, s]));
  const after = new Map(proposed.steps.map((s) => [s.id, s]));
  const changes: StepChange[] = [];

  for (const step of proposed.steps) {
    const prev = before.get(step.id);
    if (!prev) {
      changes.push({ kind: "added", id: step.id, label: step.label });
      continue;
    }
    const fields = stepFieldDiffs(prev, step);
    changes.push(
      fields.length > 0
        ? { kind: "changed", id: step.id, label: step.label, fields }
        : { kind: "unchanged", id: step.id, label: step.label }
    );
  }

  for (const step of current.steps) {
    if (!after.has(step.id)) {
      changes.push({ kind: "removed", id: step.id, label: step.label });
    }
  }

  return changes;
};

/** True when the diff contains a real mutation. */
export const hasChanges = (changes: StepChange[]): boolean => {
  return changes.some((c) => c.kind !== "unchanged");
};

/** "2 added, 1 changed, 1 removed" style summary for the proposal banner. */
export const summarizeChanges = (changes: StepChange[]): string => {
  const counts = {
    added: changes.filter((c) => c.kind === "added").length,
    changed: changes.filter((c) => c.kind === "changed").length,
    removed: changes.filter((c) => c.kind === "removed").length,
  };
  const parts = Object.entries(counts)
    .filter(([, n]) => n > 0)
    .map(([kind, n]) => `${n} ${kind}`);
  return parts.length > 0 ? parts.join(", ") : "no changes";
};
lib/approvals-ui/edit-ops.ts
import { z } from "zod";

import { diffPolicies, type StepChange } from "./diff";
import {
  approvalModes,
  type ApprovalPolicy,
  type ApprovalStep,
  approverSchema,
  type Condition,
  conditionSchema,
  isApprovalStep,
} from "./policy";

/**
 * Edit ops: the safe write surface for a policy.
 *
 * A proposer (an LLM, a form, the demo parser) never regenerates the whole
 * policy. It emits one small op; `applyEditOp` applies it deterministically
 * and copies everything unrelated verbatim. That keeps proposals reviewable:
 * the diff is exactly what the op touched, nothing can drift.
 *
 * "none" and "clarify" are part of the contract so a proposer can decline or
 * ask instead of guessing.
 */

const clarifyOptionsSchema = z.array(z.string()).optional();

const insertApprovalStepSchema = z.object({
  id: z.string().min(1),
  label: z.string().min(1),
  when: conditionSchema.optional(),
  approvers: z.array(approverSchema).min(1),
  mode: z.enum(approvalModes).optional(),
  quorum: z.number().int().positive().optional(),
});

export const editOpSchema = z.discriminatedUnion("op", [
  z.object({
    op: z.literal("set-condition"),
    stepId: z.string(),
    when: conditionSchema,
  }),
  z.object({
    op: z.literal("set-threshold"),
    stepId: z.string(),
    value: z.number(),
    field: z.string().optional(),
    comparator: z.enum([">", ">="]).optional(),
  }),
  z.object({
    op: z.literal("add-approver"),
    stepId: z.string(),
    approver: approverSchema,
  }),
  z.object({
    op: z.literal("remove-approver"),
    stepId: z.string(),
    name: z.string(),
  }),
  z.object({
    op: z.literal("set-approvers"),
    stepId: z.string(),
    approvers: z.array(approverSchema).min(1),
  }),
  z.object({
    op: z.literal("set-mode"),
    stepId: z.string(),
    mode: z.enum(approvalModes),
    quorum: z.number().int().positive().optional(),
  }),
  z.object({
    op: z.literal("rename-step"),
    stepId: z.string(),
    label: z.string().min(1),
  }),
  z.object({ op: z.literal("remove-step"), stepId: z.string() }),
  z.object({
    op: z.literal("insert-approval-after"),
    afterId: z.string(),
    step: insertApprovalStepSchema,
  }),
  z.object({ op: z.literal("none"), reason: z.string() }),
  z.object({
    op: z.literal("clarify"),
    question: z.string(),
    options: clarifyOptionsSchema,
  }),
]);
export type EditOp = z.infer<typeof editOpSchema>;

export class EditError extends Error {}

/**
 * The contract between an edit UI and whatever proposes edits. Wire it to a
 * model (parse its output with `editOpSchema`, apply with `proposeEdits`) or
 * to anything else that returns a proposed policy plus its diff.
 */
export type EditProposal = {
  proposed: ApprovalPolicy;
  changes: StepChange[];
  reason?: string;
  clarify?: { question: string; options?: string[] };
};

export type Proposer = (instruction: string, policy: ApprovalPolicy) => Promise<EditProposal>;

// ---------------------------------------------------------------------------

const mustGetApproval = (policy: ApprovalPolicy, stepId: string): ApprovalStep => {
  const step = policy.steps.find((s) => s.id === stepId);
  if (!step) throw new EditError(`Unknown step "${stepId}".`);
  if (!isApprovalStep(step)) {
    throw new EditError(`"${step.label}" is a terminal step, not an approval gate.`);
  }
  return step;
};

const setThreshold = (
  when: Condition,
  field: string,
  comparator: ">" | ">=",
  value: number
): { condition: Condition; replaced: boolean } => {
  switch (when.kind) {
    case "always": {
      return { condition: { kind: "leaf", field, op: comparator, value }, replaced: true };
    }
    case "leaf": {
      if (when.field === field) {
        return { condition: { kind: "leaf", field, op: comparator, value }, replaced: true };
      }
      return { condition: when, replaced: false };
    }
    case "all":
    case "any": {
      let isReplaced = false;
      const conditions = when.conditions.map((c) => {
        const result = setThreshold(c, field, comparator, value);
        isReplaced ||= result.replaced;
        return result.condition;
      });
      return { condition: { kind: when.kind, conditions }, replaced: isReplaced };
    }
  }
};

export const applyEditOp = (policy: ApprovalPolicy, op: EditOp): ApprovalPolicy => {
  const next = structuredClone(policy);
  switch (op.op) {
    case "none":
    case "clarify": {
      return next;
    }

    case "set-condition": {
      const step = next.steps.find((s) => s.id === op.stepId);
      if (!step) throw new EditError(`Unknown step "${op.stepId}".`);
      step.when = op.when;
      return next;
    }

    case "set-threshold": {
      const step = next.steps.find((s) => s.id === op.stepId);
      if (!step) throw new EditError(`Unknown step "${op.stepId}".`);
      const field = op.field ?? "amount";
      const comparator = op.comparator ?? ">";
      const result = setThreshold(step.when, field, comparator, op.value);
      step.when = result.replaced
        ? result.condition
        : {
            kind: "all",
            conditions: [step.when, { kind: "leaf", field, op: comparator, value: op.value }],
          };
      return next;
    }

    case "add-approver": {
      const step = mustGetApproval(next, op.stepId);
      const isExists = step.approvers.some((a) => a.name !== null && a.name === op.approver.name);
      if (isExists)
        throw new EditError(`${op.approver.name} is already an approver on "${step.label}".`);
      step.approvers.push(op.approver);
      return next;
    }

    case "remove-approver": {
      const step = mustGetApproval(next, op.stepId);
      const remaining = step.approvers.filter((a) => a.name !== op.name);
      if (remaining.length === step.approvers.length) {
        throw new EditError(`${op.name} is not an approver on "${step.label}".`);
      }
      if (remaining.length === 0) {
        throw new EditError(
          `"${step.label}" needs at least one approver. Remove the step instead.`
        );
      }
      step.approvers = remaining;
      if (step.mode === "quorum" && step.quorum !== undefined && step.quorum > remaining.length) {
        step.quorum = remaining.length;
      }
      return next;
    }

    case "set-approvers": {
      const step = mustGetApproval(next, op.stepId);
      step.approvers = op.approvers;
      if (
        step.mode === "quorum" &&
        step.quorum !== undefined &&
        step.quorum > op.approvers.length
      ) {
        step.quorum = op.approvers.length;
      }
      return next;
    }

    case "set-mode": {
      const step = mustGetApproval(next, op.stepId);
      step.mode = op.mode;
      step.quorum = op.mode === "quorum" ? (op.quorum ?? step.quorum) : undefined;
      return next;
    }

    case "rename-step": {
      const step = next.steps.find((s) => s.id === op.stepId);
      if (!step) throw new EditError(`Unknown step "${op.stepId}".`);
      step.label = op.label;
      return next;
    }

    case "remove-step": {
      const removed = next.steps.find((s) => s.id === op.stepId);
      if (!removed) throw new EditError(`Unknown step "${op.stepId}".`);
      next.steps = next.steps.filter((s) => s.id !== op.stepId);
      for (const step of next.steps) {
        if (!step.next.includes(op.stepId)) continue;
        const rewired = step.next.flatMap((id) =>
          id === op.stepId ? removed.next.filter((n) => n !== step.id) : [id]
        );
        step.next = [...new Set(rewired)];
      }
      if (next.roots.includes(op.stepId)) {
        next.roots = [
          ...new Set(next.roots.flatMap((id) => (id === op.stepId ? removed.next : [id]))),
        ];
      }
      return next;
    }

    case "insert-approval-after": {
      if (next.steps.some((s) => s.id === op.step.id)) {
        throw new EditError(`Step id "${op.step.id}" already exists.`);
      }
      const after = mustGetApproval(next, op.afterId);
      const inserted: ApprovalStep = {
        id: op.step.id,
        kind: "approval",
        label: op.step.label,
        when: op.step.when ?? { kind: "always" },
        approvers: op.step.approvers,
        mode: op.step.mode ?? "all",
        quorum: op.step.quorum,
        next: [...after.next],
      };
      after.next = [inserted.id];
      next.steps.push(inserted);
      return next;
    }
  }
};

/**
 * Apply a sequence of ops and report the resulting diff. "none" and
 * "clarify" ops short-circuit into the proposal metadata instead of mutating.
 */
export const proposeEdits = (
  policy: ApprovalPolicy,
  ops: EditOp[],
  reason?: string
): EditProposal => {
  let proposed = policy;
  let clarify: EditProposal["clarify"];
  let declined: string | undefined;
  for (const op of ops) {
    if (op.op === "clarify") {
      clarify = { question: op.question, options: op.options };
      continue;
    }
    if (op.op === "none") {
      declined = op.reason;
      continue;
    }
    proposed = applyEditOp(proposed, op);
  }
  return {
    proposed,
    changes: diffPolicies(policy, proposed),
    reason: reason ?? declined,
    clarify,
  };
};
lib/approvals-ui/example-policy.ts
import type { ApprovalPolicy } from "./policy";

/**
 * A realistic procurement policy to start from: a manager gate, a finance
 * quorum above 5k, a director sign-off above 25k, then the approved terminal.
 * The director seat is deliberately unassigned so validation has something
 * honest to say out of the box.
 */
export const examplePolicy: ApprovalPolicy = {
  name: "Procurement approvals",
  roots: ["manager-review"],
  steps: [
    {
      id: "manager-review",
      kind: "approval",
      label: "Manager review",
      when: { kind: "always" },
      approvers: [{ name: "Alex Rivera", title: "Engineering Manager" }],
      mode: "all",
      next: ["finance-review"],
    },
    {
      id: "finance-review",
      kind: "approval",
      label: "Finance review",
      when: { kind: "leaf", field: "amount", op: ">", value: 5000 },
      approvers: [
        { name: "Priya Patel", title: "Controller" },
        { name: "Sam Okafor", title: "FP&A Lead" },
        { name: "Maria Chen", title: "Finance Ops" },
      ],
      mode: "quorum",
      quorum: 2,
      next: ["director-review"],
    },
    {
      id: "director-review",
      kind: "approval",
      label: "Director sign-off",
      when: { kind: "leaf", field: "amount", op: ">", value: 25_000 },
      approvers: [{ name: null, title: "Finance Director" }],
      mode: "all",
      sla: { hours: 48 },
      next: ["approved"],
    },
    {
      id: "approved",
      kind: "terminal",
      label: "Approved: post to ERP",
      when: { kind: "always" },
      outcome: "approved",
      next: [],
    },
  ],
};

More from approvals-ui

Browse all components in this library →