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 · lightformszod
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: [],
},
],
};