{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "approvals-core",
  "title": "Approvals core",
  "description": "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.",
  "dependencies": [
    "zod"
  ],
  "files": [
    {
      "path": "lib/approvals-ui/policy.ts",
      "content": "import { z } from \"zod\";\n\n/**\n * approvals-ui policy schema.\n *\n * An ApprovalPolicy is a DAG of steps. Execution enters at `roots`, follows\n * `next` edges, and ends at terminal steps. Every step carries a `when`\n * condition (a guard): a step whose condition is false for a given request is\n * skipped and its edges pass through. Conditions never live on edges, so the\n * graph stays legible and the diff stays small.\n */\n\n// ---------------------------------------------------------------------------\n// Conditions\n// ---------------------------------------------------------------------------\n\nexport const conditionOps = [\">\", \">=\", \"<\", \"<=\", \"==\", \"!=\"] as const;\nexport type ConditionOp = (typeof conditionOps)[number];\n\nexport type ConditionLeaf = {\n  kind: \"leaf\";\n  /** The request field this guard reads, e.g. \"amount\", \"department\". */\n  field: string;\n  op: ConditionOp;\n  value: string | number;\n};\n\nexport type Condition =\n  | ConditionLeaf\n  | { kind: \"always\" }\n  | { kind: \"all\"; conditions: Condition[] }\n  | { kind: \"any\"; conditions: Condition[] };\n\nconst leafValueSchema = z.union([z.string(), z.number()]);\n\nexport const conditionSchema: z.ZodType<Condition> = z.lazy(() =>\n  z.discriminatedUnion(\"kind\", [\n    z.object({ kind: z.literal(\"always\") }),\n    z.object({\n      kind: z.literal(\"leaf\"),\n      field: z.string().min(1),\n      op: z.enum(conditionOps),\n      value: leafValueSchema,\n    }),\n    z.object({\n      kind: z.literal(\"all\"),\n      conditions: z.array(conditionSchema).min(1),\n    }),\n    z.object({\n      kind: z.literal(\"any\"),\n      conditions: z.array(conditionSchema).min(1),\n    }),\n  ])\n);\n\nexport const always: Condition = { kind: \"always\" };\n\nexport const leaf = (field: string, op: ConditionOp, value: string | number): Condition => {\n  return { kind: \"leaf\", field, op, value };\n};\n\n/** Every leaf in the condition tree, in reading order. */\nexport const collectLeaves = (condition: Condition): ConditionLeaf[] => {\n  switch (condition.kind) {\n    case \"always\": {\n      return [];\n    }\n    case \"leaf\": {\n      return [condition];\n    }\n    case \"all\":\n    case \"any\": {\n      return condition.conditions.flatMap((c) => collectLeaves(c));\n    }\n  }\n};\n\nconst formatValue = (value: string | number): string => {\n  return typeof value === \"number\" ? value.toLocaleString(\"en-US\") : value;\n};\n\n/** Human-readable condition, for pills and tooltips. */\nexport const humanizeCondition = (condition: Condition): string => {\n  switch (condition.kind) {\n    case \"always\": {\n      return \"always\";\n    }\n    case \"leaf\": {\n      return `${condition.field} ${condition.op} ${formatValue(condition.value)}`;\n    }\n    case \"all\": {\n      return condition.conditions\n        .map((c) =>\n          c.kind === \"all\" || c.kind === \"any\" ? `(${humanizeCondition(c)})` : humanizeCondition(c)\n        )\n        .join(\" and \");\n    }\n    case \"any\": {\n      return condition.conditions\n        .map((c) =>\n          c.kind === \"all\" || c.kind === \"any\" ? `(${humanizeCondition(c)})` : humanizeCondition(c)\n        )\n        .join(\" or \");\n    }\n  }\n};\n\n/** Canonical string for a condition. Stable, locale-free: used by the diff. */\nexport const describeCondition = (condition: Condition): string => {\n  switch (condition.kind) {\n    case \"always\": {\n      return \"always\";\n    }\n    case \"leaf\": {\n      return `${condition.field}${condition.op}${String(condition.value)}`;\n    }\n    case \"all\": {\n      return `all(${condition.conditions.map((c) => describeCondition(c)).join(\",\")})`;\n    }\n    case \"any\": {\n      return `any(${condition.conditions.map((c) => describeCondition(c)).join(\",\")})`;\n    }\n  }\n};\n\n// ---------------------------------------------------------------------------\n// Approvers and steps\n// ---------------------------------------------------------------------------\n\nexport const approverSchema = z.object({\n  /** null = seat exists but nobody is assigned yet (surfaced by validation). */\n  name: z.string().min(1).nullable(),\n  title: z.string().min(1),\n});\nexport type Approver = z.infer<typeof approverSchema>;\n\nexport const approvalModes = [\"all\", \"any\", \"quorum\"] as const;\nexport type ApprovalMode = (typeof approvalModes)[number];\n\nexport const slaSchema = z.object({\n  hours: z.number().positive(),\n  escalateTo: approverSchema.optional(),\n});\nexport type Sla = z.infer<typeof slaSchema>;\n\nexport const approvalStepSchema = z.object({\n  id: z.string().min(1),\n  kind: z.literal(\"approval\"),\n  label: z.string().min(1),\n  when: conditionSchema,\n  approvers: z.array(approverSchema).min(1),\n  /** all = every approver signs, any = one is enough, quorum = `quorum` of them. */\n  mode: z.enum(approvalModes),\n  quorum: z.number().int().positive().optional(),\n  sla: slaSchema.optional(),\n  next: z.array(z.string()),\n});\nexport type ApprovalStep = z.infer<typeof approvalStepSchema>;\n\nexport const terminalStepSchema = z.object({\n  id: z.string().min(1),\n  kind: z.literal(\"terminal\"),\n  label: z.string().min(1),\n  when: conditionSchema,\n  outcome: z.enum([\"approved\", \"rejected\"]),\n  next: z.array(z.string()).max(0),\n});\nexport type TerminalStep = z.infer<typeof terminalStepSchema>;\n\nexport const policyStepSchema = z.discriminatedUnion(\"kind\", [\n  approvalStepSchema,\n  terminalStepSchema,\n]);\nexport type PolicyStep = z.infer<typeof policyStepSchema>;\n\nexport const approvalPolicySchema = z.object({\n  name: z.string().min(1),\n  steps: z.array(policyStepSchema),\n  /** Entry points: step ids with no incoming edge. */\n  roots: z.array(z.string()).min(1),\n});\nexport type ApprovalPolicy = z.infer<typeof approvalPolicySchema>;\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nexport const isApprovalStep = (step: PolicyStep): step is ApprovalStep => {\n  return step.kind === \"approval\";\n};\n\nexport const isTerminalStep = (step: PolicyStep): step is TerminalStep => {\n  return step.kind === \"terminal\";\n};\n\nexport const stepById = (policy: ApprovalPolicy): Map<string, PolicyStep> => {\n  return new Map(policy.steps.map((s) => [s.id, s]));\n};\n\n/** Assigned approver names on a gate (unassigned seats excluded). */\nexport const approverNames = (step: ApprovalStep): string[] => {\n  return step.approvers.flatMap((a) => (a.name === null ? [] : [a.name]));\n};\n\n/** How many signatures the gate needs before it passes. */\nexport const requiredApprovals = (step: ApprovalStep): number => {\n  switch (step.mode) {\n    case \"all\": {\n      return step.approvers.length;\n    }\n    case \"any\": {\n      return 1;\n    }\n    case \"quorum\": {\n      return step.quorum ?? step.approvers.length;\n    }\n  }\n};\n\n/** \"2 of 3\", \"any of 2\", or \"all\" style summary for badges. */\nexport const summarizeMode = (step: ApprovalStep): string => {\n  const total = step.approvers.length;\n  switch (step.mode) {\n    case \"all\": {\n      return total > 1 ? `all ${total}` : \"1 approver\";\n    }\n    case \"any\": {\n      return `any of ${total}`;\n    }\n    case \"quorum\": {\n      return `${step.quorum ?? total} of ${total}`;\n    }\n  }\n};\n",
      "type": "registry:lib",
      "target": "lib/approvals-ui/policy.ts"
    },
    {
      "path": "lib/approvals-ui/validate.ts",
      "content": "import {\n  type ApprovalPolicy,\n  type ApprovalStep,\n  approverNames,\n  collectLeaves,\n  describeCondition,\n  isApprovalStep,\n  isTerminalStep,\n  type PolicyStep,\n  stepById,\n} from \"./policy\";\n\n/**\n * Deterministic policy validation. Two severities:\n *\n * - \"error\": the graph is structurally broken (dangling edge, cycle,\n *   unreachable step, no approved outcome). Block activation on these.\n * - \"warning\": the graph works but breaks an approval best practice\n *   (unassigned seat, duplicate gate, segregation of duties, a high-value\n *   path with a single approver). Surface them, let the human decide.\n */\n\nexport type IssueSeverity = \"error\" | \"warning\";\n\nexport type PolicyIssue = {\n  severity: IssueSeverity;\n  code: string;\n  message: string;\n  stepIds: string[];\n};\n\nexport type ValidateOptions = {\n  /** Amount above which a path should carry at least two approval gates. */\n  materiality?: number;\n  /** The request field carrying the monetary amount. */\n  amountField?: string;\n};\n\nconst DEFAULTS: Required<ValidateOptions> = {\n  materiality: 25_000,\n  amountField: \"amount\",\n};\n\nexport const validatePolicy = (\n  policy: ApprovalPolicy,\n  options: ValidateOptions = {}\n): PolicyIssue[] => {\n  const opts = { ...DEFAULTS, ...options };\n  const byId = stepById(policy);\n  const issues: PolicyIssue[] = [\n    ...duplicateStepIds(policy),\n    ...danglingEdges(policy, byId),\n    ...rootsValid(policy, byId),\n    ...cycleFree(policy, byId),\n    ...reachability(policy, byId),\n    ...terminals(policy, byId),\n    ...quorumValid(policy),\n    ...unresolvedApprovers(policy),\n    ...duplicateGates(policy),\n    ...pathRules(policy, byId, opts),\n  ];\n  return dedupe(issues);\n};\n\n/** No error-severity issue: safe to activate. */\nexport const isActivatable = (issues: PolicyIssue[]): boolean => {\n  return issues.every((i) => i.severity !== \"error\");\n};\n\n// ---------------------------------------------------------------------------\n// Structural rules (errors)\n// ---------------------------------------------------------------------------\n\nconst duplicateStepIds = (policy: ApprovalPolicy): PolicyIssue[] => {\n  const seen = new Map<string, number>();\n  for (const step of policy.steps) {\n    seen.set(step.id, (seen.get(step.id) ?? 0) + 1);\n  }\n  return [...seen]\n    .filter(([, count]) => count > 1)\n    .map(([id]) => ({\n      severity: \"error\" as const,\n      code: \"duplicate-step-id\",\n      message: `Step id \"${id}\" is used more than once.`,\n      stepIds: [id],\n    }));\n};\n\nconst danglingEdges = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {\n  const issues: PolicyIssue[] = [];\n  for (const step of policy.steps) {\n    for (const nextId of step.next) {\n      if (!byId.has(nextId)) {\n        issues.push({\n          severity: \"error\",\n          code: \"dangling-edge\",\n          message: `\"${step.label}\" routes to a step that does not exist (\"${nextId}\").`,\n          stepIds: [step.id],\n        });\n      }\n    }\n  }\n  return issues;\n};\n\nconst rootsValid = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {\n  if (policy.roots.length === 0) {\n    return [\n      {\n        severity: \"error\",\n        code: \"no-roots\",\n        message: \"The policy has no entry point.\",\n        stepIds: [],\n      },\n    ];\n  }\n  return policy.roots\n    .filter((id) => !byId.has(id))\n    .map((id) => ({\n      severity: \"error\" as const,\n      code: \"unknown-root\",\n      message: `Entry point \"${id}\" is not a step in the policy.`,\n      stepIds: [id],\n    }));\n};\n\n/**\n * Drop one from the indegree of each existing successor of `step`, and queue\n * any successor whose indegree reaches zero. The inner half of Kahn's loop,\n * lifted into its own function so the outer traversal stays a single loop.\n */\nconst relaxSuccessors = (\n  step: PolicyStep,\n  byId: Map<string, PolicyStep>,\n  indegree: Map<string, number>,\n  queue: string[]\n): void => {\n  for (const nextId of step.next) {\n    if (!byId.has(nextId)) continue;\n    const d = (indegree.get(nextId) ?? 0) - 1;\n    indegree.set(nextId, d);\n    if (d === 0) queue.push(nextId);\n  }\n};\n\nconst cycleFree = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {\n  const indegree = new Map<string, number>();\n  for (const step of policy.steps) indegree.set(step.id, 0);\n  for (const step of policy.steps) {\n    for (const nextId of step.next) {\n      if (byId.has(nextId)) {\n        indegree.set(nextId, (indegree.get(nextId) ?? 0) + 1);\n      }\n    }\n  }\n  const queue = [...indegree].filter(([, d]) => d === 0).map(([id]) => id);\n  let processed = 0;\n  while (queue.length > 0) {\n    const id = queue.shift();\n    if (id === undefined) continue;\n    processed += 1;\n    const step = byId.get(id);\n    if (!step) continue;\n    relaxSuccessors(step, byId, indegree, queue);\n  }\n  if (processed >= byId.size) return [];\n  const stuck = [...indegree].filter(([, d]) => d > 0).map(([id]) => id);\n  return [\n    {\n      severity: \"error\",\n      code: \"cycle\",\n      message: \"Approvals must flow one way: the policy contains a cycle.\",\n      stepIds: stuck,\n    },\n  ];\n};\n\nconst reachableFromRoots = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): Set<string> => {\n  const reached = new Set<string>();\n  const queue = policy.roots.filter((id) => byId.has(id));\n  while (queue.length > 0) {\n    const id = queue.shift();\n    if (id === undefined) continue;\n    if (reached.has(id)) continue;\n    reached.add(id);\n    const step = byId.get(id);\n    if (!step) continue;\n    for (const nextId of step.next) {\n      if (byId.has(nextId) && !reached.has(nextId)) queue.push(nextId);\n    }\n  }\n  return reached;\n};\n\nconst reachability = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {\n  const reached = reachableFromRoots(policy, byId);\n  return policy.steps\n    .filter((step) => !reached.has(step.id))\n    .map((step) => ({\n      severity: \"error\" as const,\n      code: \"unreachable-step\",\n      message: `\"${step.label}\" can never be reached from an entry point.`,\n      stepIds: [step.id],\n    }));\n};\n\nconst terminals = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyIssue[] => {\n  const terminalSteps = policy.steps.filter(isTerminalStep);\n  if (terminalSteps.length === 0) {\n    return [\n      {\n        severity: \"error\",\n        code: \"no-terminal\",\n        message: \"The policy has no terminal step, so no request can ever finish.\",\n        stepIds: [],\n      },\n    ];\n  }\n  const approved = terminalSteps.filter((t) => t.outcome === \"approved\");\n  if (approved.length === 0) {\n    return [\n      {\n        severity: \"error\",\n        code: \"no-approved-terminal\",\n        message: \"No terminal step has the approved outcome: nothing can ever pass.\",\n        stepIds: terminalSteps.map((t) => t.id),\n      },\n    ];\n  }\n  const reached = reachableFromRoots(policy, byId);\n  if (approved.every((t) => !reached.has(t.id))) {\n    return [\n      {\n        severity: \"error\",\n        code: \"approved-terminal-unreachable\",\n        message: \"The approved outcome can never be reached from an entry point.\",\n        stepIds: approved.map((t) => t.id),\n      },\n    ];\n  }\n  return [];\n};\n\nconst quorumValid = (policy: ApprovalPolicy): PolicyIssue[] => {\n  const issues: PolicyIssue[] = [];\n  for (const step of policy.steps) {\n    if (!isApprovalStep(step)) continue;\n    if (step.mode === \"quorum\") {\n      if (step.quorum === undefined || step.quorum > step.approvers.length) {\n        issues.push({\n          severity: \"error\",\n          code: \"quorum-invalid\",\n          message: `\"${step.label}\" asks for a quorum of ${step.quorum ?? \"?\"} but has ${step.approvers.length} approver${step.approvers.length === 1 ? \"\" : \"s\"}.`,\n          stepIds: [step.id],\n        });\n      }\n    } else if (step.quorum !== undefined) {\n      issues.push({\n        severity: \"warning\",\n        code: \"quorum-ignored\",\n        message: `\"${step.label}\" sets a quorum but its mode is \"${step.mode}\", so the quorum is ignored.`,\n        stepIds: [step.id],\n      });\n    }\n  }\n  return issues;\n};\n\n// ---------------------------------------------------------------------------\n// Best-practice rules (warnings)\n// ---------------------------------------------------------------------------\n\nconst unresolvedApprovers = (policy: ApprovalPolicy): PolicyIssue[] => {\n  const issues: PolicyIssue[] = [];\n  for (const step of policy.steps) {\n    if (!isApprovalStep(step)) continue;\n    for (const approver of step.approvers) {\n      if (approver.name === null) {\n        issues.push({\n          severity: \"warning\",\n          code: \"unresolved-approver\",\n          message: `\"${step.label}\" has an unassigned ${approver.title} seat.`,\n          stepIds: [step.id],\n        });\n      }\n    }\n  }\n  return issues;\n};\n\nconst duplicateGates = (policy: ApprovalPolicy): PolicyIssue[] => {\n  const seen = new Map<string, ApprovalStep>();\n  const issues: PolicyIssue[] = [];\n  for (const step of policy.steps) {\n    if (!isApprovalStep(step)) continue;\n    const roster = step.approvers.map((a) => `${a.name ?? \"?\"}:${a.title}`);\n    roster.sort((a, b) => a.localeCompare(b));\n    const key = [roster.join(\"|\"), describeCondition(step.when)].join(\"::\");\n    const prior = seen.get(key);\n    if (prior) {\n      issues.push({\n        severity: \"warning\",\n        code: \"duplicate-gate\",\n        message: `\"${prior.label}\" and \"${step.label}\" ask the same approvers under the same condition.`,\n        stepIds: [prior.id, step.id],\n      });\n    } else {\n      seen.set(key, step);\n    }\n  }\n  return issues;\n};\n\n// ---------------------------------------------------------------------------\n// Path rules (walk every root-to-approved path)\n// ---------------------------------------------------------------------------\n\nconst MAX_PATHS = 2000;\n\nconst pathsToApproved = (policy: ApprovalPolicy, byId: Map<string, PolicyStep>): PolicyStep[][] => {\n  const paths: PolicyStep[][] = [];\n  const walk = (step: PolicyStep, path: PolicyStep[], seen: Set<string>) => {\n    if (paths.length >= MAX_PATHS || seen.has(step.id)) return;\n    const nextPath = [...path, step];\n    if (isTerminalStep(step)) {\n      if (step.outcome === \"approved\") paths.push(nextPath);\n      return;\n    }\n    const nextSeen = new Set(seen);\n    nextSeen.add(step.id);\n    for (const nextId of step.next) {\n      const next = byId.get(nextId);\n      if (next) walk(next, nextPath, nextSeen);\n    }\n  };\n  for (const rootId of policy.roots) {\n    const root = byId.get(rootId);\n    if (root) walk(root, [], new Set());\n  }\n  return paths;\n};\n\n/**\n * The lowest amount that traverses this path: the max of the lower bounds\n * set by amount guards along it. A path guarded by \"amount > 25,000\" has a\n * floor of 25,000; an unguarded path has a floor of 0.\n */\nconst amountFloor = (path: PolicyStep[], amountField: string): number => {\n  let floor = 0;\n  for (const step of path) {\n    for (const l of collectLeaves(step.when)) {\n      if (\n        l.field === amountField &&\n        typeof l.value === \"number\" &&\n        (l.op === \">\" || l.op === \">=\")\n      ) {\n        floor = Math.max(floor, l.value);\n      }\n    }\n  }\n  return floor;\n};\n\nconst pathRules = (\n  policy: ApprovalPolicy,\n  byId: Map<string, PolicyStep>,\n  opts: Required<ValidateOptions>\n): PolicyIssue[] => {\n  const issues: PolicyIssue[] = [];\n  for (const path of pathsToApproved(policy, byId)) {\n    const gates = path.filter(isApprovalStep);\n    const terminal = path.at(-1);\n    // Every path built by pathsToApproved is non-empty and ends at a terminal.\n    if (!terminal) continue;\n\n    if (gates.length === 0) {\n      issues.push({\n        severity: \"warning\",\n        code: \"no-approval-before-terminal\",\n        message: `A request can reach \"${terminal.label}\" without any human approval.`,\n        stepIds: [terminal.id],\n      });\n    }\n\n    const floor = amountFloor(path, opts.amountField);\n    if (floor >= opts.materiality && gates.length < 2) {\n      issues.push({\n        severity: \"warning\",\n        code: \"single-approver-high-value\",\n        message: `Requests above ${floor.toLocaleString(\"en-US\")} can pass with ${gates.length === 0 ? \"no approval gate\" : \"a single approval gate\"}.`,\n        stepIds: gates.map((g) => g.id),\n      });\n    }\n\n    const gatesByName = new Map<string, ApprovalStep[]>();\n    for (const gate of gates) {\n      for (const name of approverNames(gate)) {\n        const list = gatesByName.get(name) ?? [];\n        if (!list.includes(gate)) list.push(gate);\n        gatesByName.set(name, list);\n      }\n    }\n    for (const [name, dupGates] of gatesByName) {\n      if (dupGates.length >= 2) {\n        issues.push({\n          severity: \"warning\",\n          code: \"segregation-of-duties\",\n          message: `${name} approves twice on the same path (${dupGates.map((g) => `\"${g.label}\"`).join(\", \")}).`,\n          stepIds: dupGates.map((g) => g.id),\n        });\n      }\n    }\n  }\n  return issues;\n};\n\n// ---------------------------------------------------------------------------\n\nconst dedupe = (issues: PolicyIssue[]): PolicyIssue[] => {\n  const seen = new Set<string>();\n  const out: PolicyIssue[] = [];\n  for (const issue of issues) {\n    const sortedStepIds = [...issue.stepIds];\n    sortedStepIds.sort((a, b) => a.localeCompare(b));\n    const key = `${issue.code}::${sortedStepIds.join(\",\")}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    out.push(issue);\n  }\n  return out;\n};\n",
      "type": "registry:lib",
      "target": "lib/approvals-ui/validate.ts"
    },
    {
      "path": "lib/approvals-ui/diff.ts",
      "content": "import { type ApprovalPolicy, describeCondition, type PolicyStep } from \"./policy\";\n\n/**\n * Step-level diff between two policies, keyed by step id. This is the unit\n * the canvas renders (rings on added/changed/removed nodes) and the unit an\n * edit proposal reports before the human applies it.\n */\nexport type StepChange =\n  | { kind: \"added\"; id: string; label: string }\n  | { kind: \"removed\"; id: string; label: string }\n  | { kind: \"changed\"; id: string; label: string; fields: string[] }\n  | { kind: \"unchanged\"; id: string; label: string };\n\nconst roster = (step: PolicyStep): string => {\n  if (step.kind !== \"approval\") return \"\";\n  return step.approvers.map((a) => `${a.name ?? \"?\"}:${a.title}`).join(\"|\");\n};\n\nconst slaKey = (step: PolicyStep): string => {\n  if (step.kind !== \"approval\" || !step.sla) return \"\";\n  const escalate = step.sla.escalateTo\n    ? `${step.sla.escalateTo.name ?? \"?\"}:${step.sla.escalateTo.title}`\n    : \"\";\n  return `${step.sla.hours}h${escalate}`;\n};\n\nexport const stepFieldDiffs = (before: PolicyStep, after: PolicyStep): string[] => {\n  const fields: string[] = [];\n  if (before.kind !== after.kind) fields.push(\"type\");\n  if (before.label !== after.label) fields.push(\"label\");\n  if (describeCondition(before.when) !== describeCondition(after.when)) {\n    fields.push(\"condition\");\n  }\n  if (roster(before) !== roster(after)) fields.push(\"approvers\");\n  if (\n    before.kind === \"approval\" &&\n    after.kind === \"approval\" &&\n    (before.mode !== after.mode || before.quorum !== after.quorum)\n  ) {\n    fields.push(\"mode\");\n  }\n  if (slaKey(before) !== slaKey(after)) fields.push(\"sla\");\n  if (before.kind === \"terminal\" && after.kind === \"terminal\" && before.outcome !== after.outcome) {\n    fields.push(\"outcome\");\n  }\n  if (before.next.join(\",\") !== after.next.join(\",\")) fields.push(\"routing\");\n  return fields;\n};\n\nexport const diffPolicies = (current: ApprovalPolicy, proposed: ApprovalPolicy): StepChange[] => {\n  const before = new Map(current.steps.map((s) => [s.id, s]));\n  const after = new Map(proposed.steps.map((s) => [s.id, s]));\n  const changes: StepChange[] = [];\n\n  for (const step of proposed.steps) {\n    const prev = before.get(step.id);\n    if (!prev) {\n      changes.push({ kind: \"added\", id: step.id, label: step.label });\n      continue;\n    }\n    const fields = stepFieldDiffs(prev, step);\n    changes.push(\n      fields.length > 0\n        ? { kind: \"changed\", id: step.id, label: step.label, fields }\n        : { kind: \"unchanged\", id: step.id, label: step.label }\n    );\n  }\n\n  for (const step of current.steps) {\n    if (!after.has(step.id)) {\n      changes.push({ kind: \"removed\", id: step.id, label: step.label });\n    }\n  }\n\n  return changes;\n};\n\n/** True when the diff contains a real mutation. */\nexport const hasChanges = (changes: StepChange[]): boolean => {\n  return changes.some((c) => c.kind !== \"unchanged\");\n};\n\n/** \"2 added, 1 changed, 1 removed\" style summary for the proposal banner. */\nexport const summarizeChanges = (changes: StepChange[]): string => {\n  const counts = {\n    added: changes.filter((c) => c.kind === \"added\").length,\n    changed: changes.filter((c) => c.kind === \"changed\").length,\n    removed: changes.filter((c) => c.kind === \"removed\").length,\n  };\n  const parts = Object.entries(counts)\n    .filter(([, n]) => n > 0)\n    .map(([kind, n]) => `${n} ${kind}`);\n  return parts.length > 0 ? parts.join(\", \") : \"no changes\";\n};\n",
      "type": "registry:lib",
      "target": "lib/approvals-ui/diff.ts"
    },
    {
      "path": "lib/approvals-ui/edit-ops.ts",
      "content": "import { z } from \"zod\";\n\nimport { diffPolicies, type StepChange } from \"./diff\";\nimport {\n  approvalModes,\n  type ApprovalPolicy,\n  type ApprovalStep,\n  approverSchema,\n  type Condition,\n  conditionSchema,\n  isApprovalStep,\n} from \"./policy\";\n\n/**\n * Edit ops: the safe write surface for a policy.\n *\n * A proposer (an LLM, a form, the demo parser) never regenerates the whole\n * policy. It emits one small op; `applyEditOp` applies it deterministically\n * and copies everything unrelated verbatim. That keeps proposals reviewable:\n * the diff is exactly what the op touched, nothing can drift.\n *\n * \"none\" and \"clarify\" are part of the contract so a proposer can decline or\n * ask instead of guessing.\n */\n\nconst clarifyOptionsSchema = z.array(z.string()).optional();\n\nconst insertApprovalStepSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  when: conditionSchema.optional(),\n  approvers: z.array(approverSchema).min(1),\n  mode: z.enum(approvalModes).optional(),\n  quorum: z.number().int().positive().optional(),\n});\n\nexport const editOpSchema = z.discriminatedUnion(\"op\", [\n  z.object({\n    op: z.literal(\"set-condition\"),\n    stepId: z.string(),\n    when: conditionSchema,\n  }),\n  z.object({\n    op: z.literal(\"set-threshold\"),\n    stepId: z.string(),\n    value: z.number(),\n    field: z.string().optional(),\n    comparator: z.enum([\">\", \">=\"]).optional(),\n  }),\n  z.object({\n    op: z.literal(\"add-approver\"),\n    stepId: z.string(),\n    approver: approverSchema,\n  }),\n  z.object({\n    op: z.literal(\"remove-approver\"),\n    stepId: z.string(),\n    name: z.string(),\n  }),\n  z.object({\n    op: z.literal(\"set-approvers\"),\n    stepId: z.string(),\n    approvers: z.array(approverSchema).min(1),\n  }),\n  z.object({\n    op: z.literal(\"set-mode\"),\n    stepId: z.string(),\n    mode: z.enum(approvalModes),\n    quorum: z.number().int().positive().optional(),\n  }),\n  z.object({\n    op: z.literal(\"rename-step\"),\n    stepId: z.string(),\n    label: z.string().min(1),\n  }),\n  z.object({ op: z.literal(\"remove-step\"), stepId: z.string() }),\n  z.object({\n    op: z.literal(\"insert-approval-after\"),\n    afterId: z.string(),\n    step: insertApprovalStepSchema,\n  }),\n  z.object({ op: z.literal(\"none\"), reason: z.string() }),\n  z.object({\n    op: z.literal(\"clarify\"),\n    question: z.string(),\n    options: clarifyOptionsSchema,\n  }),\n]);\nexport type EditOp = z.infer<typeof editOpSchema>;\n\nexport class EditError extends Error {}\n\n/**\n * The contract between an edit UI and whatever proposes edits. Wire it to a\n * model (parse its output with `editOpSchema`, apply with `proposeEdits`) or\n * to anything else that returns a proposed policy plus its diff.\n */\nexport type EditProposal = {\n  proposed: ApprovalPolicy;\n  changes: StepChange[];\n  reason?: string;\n  clarify?: { question: string; options?: string[] };\n};\n\nexport type Proposer = (instruction: string, policy: ApprovalPolicy) => Promise<EditProposal>;\n\n// ---------------------------------------------------------------------------\n\nconst mustGetApproval = (policy: ApprovalPolicy, stepId: string): ApprovalStep => {\n  const step = policy.steps.find((s) => s.id === stepId);\n  if (!step) throw new EditError(`Unknown step \"${stepId}\".`);\n  if (!isApprovalStep(step)) {\n    throw new EditError(`\"${step.label}\" is a terminal step, not an approval gate.`);\n  }\n  return step;\n};\n\nconst setThreshold = (\n  when: Condition,\n  field: string,\n  comparator: \">\" | \">=\",\n  value: number\n): { condition: Condition; replaced: boolean } => {\n  switch (when.kind) {\n    case \"always\": {\n      return { condition: { kind: \"leaf\", field, op: comparator, value }, replaced: true };\n    }\n    case \"leaf\": {\n      if (when.field === field) {\n        return { condition: { kind: \"leaf\", field, op: comparator, value }, replaced: true };\n      }\n      return { condition: when, replaced: false };\n    }\n    case \"all\":\n    case \"any\": {\n      let isReplaced = false;\n      const conditions = when.conditions.map((c) => {\n        const result = setThreshold(c, field, comparator, value);\n        isReplaced ||= result.replaced;\n        return result.condition;\n      });\n      return { condition: { kind: when.kind, conditions }, replaced: isReplaced };\n    }\n  }\n};\n\nexport const applyEditOp = (policy: ApprovalPolicy, op: EditOp): ApprovalPolicy => {\n  const next = structuredClone(policy);\n  switch (op.op) {\n    case \"none\":\n    case \"clarify\": {\n      return next;\n    }\n\n    case \"set-condition\": {\n      const step = next.steps.find((s) => s.id === op.stepId);\n      if (!step) throw new EditError(`Unknown step \"${op.stepId}\".`);\n      step.when = op.when;\n      return next;\n    }\n\n    case \"set-threshold\": {\n      const step = next.steps.find((s) => s.id === op.stepId);\n      if (!step) throw new EditError(`Unknown step \"${op.stepId}\".`);\n      const field = op.field ?? \"amount\";\n      const comparator = op.comparator ?? \">\";\n      const result = setThreshold(step.when, field, comparator, op.value);\n      step.when = result.replaced\n        ? result.condition\n        : {\n            kind: \"all\",\n            conditions: [step.when, { kind: \"leaf\", field, op: comparator, value: op.value }],\n          };\n      return next;\n    }\n\n    case \"add-approver\": {\n      const step = mustGetApproval(next, op.stepId);\n      const isExists = step.approvers.some((a) => a.name !== null && a.name === op.approver.name);\n      if (isExists)\n        throw new EditError(`${op.approver.name} is already an approver on \"${step.label}\".`);\n      step.approvers.push(op.approver);\n      return next;\n    }\n\n    case \"remove-approver\": {\n      const step = mustGetApproval(next, op.stepId);\n      const remaining = step.approvers.filter((a) => a.name !== op.name);\n      if (remaining.length === step.approvers.length) {\n        throw new EditError(`${op.name} is not an approver on \"${step.label}\".`);\n      }\n      if (remaining.length === 0) {\n        throw new EditError(\n          `\"${step.label}\" needs at least one approver. Remove the step instead.`\n        );\n      }\n      step.approvers = remaining;\n      if (step.mode === \"quorum\" && step.quorum !== undefined && step.quorum > remaining.length) {\n        step.quorum = remaining.length;\n      }\n      return next;\n    }\n\n    case \"set-approvers\": {\n      const step = mustGetApproval(next, op.stepId);\n      step.approvers = op.approvers;\n      if (\n        step.mode === \"quorum\" &&\n        step.quorum !== undefined &&\n        step.quorum > op.approvers.length\n      ) {\n        step.quorum = op.approvers.length;\n      }\n      return next;\n    }\n\n    case \"set-mode\": {\n      const step = mustGetApproval(next, op.stepId);\n      step.mode = op.mode;\n      step.quorum = op.mode === \"quorum\" ? (op.quorum ?? step.quorum) : undefined;\n      return next;\n    }\n\n    case \"rename-step\": {\n      const step = next.steps.find((s) => s.id === op.stepId);\n      if (!step) throw new EditError(`Unknown step \"${op.stepId}\".`);\n      step.label = op.label;\n      return next;\n    }\n\n    case \"remove-step\": {\n      const removed = next.steps.find((s) => s.id === op.stepId);\n      if (!removed) throw new EditError(`Unknown step \"${op.stepId}\".`);\n      next.steps = next.steps.filter((s) => s.id !== op.stepId);\n      for (const step of next.steps) {\n        if (!step.next.includes(op.stepId)) continue;\n        const rewired = step.next.flatMap((id) =>\n          id === op.stepId ? removed.next.filter((n) => n !== step.id) : [id]\n        );\n        step.next = [...new Set(rewired)];\n      }\n      if (next.roots.includes(op.stepId)) {\n        next.roots = [\n          ...new Set(next.roots.flatMap((id) => (id === op.stepId ? removed.next : [id]))),\n        ];\n      }\n      return next;\n    }\n\n    case \"insert-approval-after\": {\n      if (next.steps.some((s) => s.id === op.step.id)) {\n        throw new EditError(`Step id \"${op.step.id}\" already exists.`);\n      }\n      const after = mustGetApproval(next, op.afterId);\n      const inserted: ApprovalStep = {\n        id: op.step.id,\n        kind: \"approval\",\n        label: op.step.label,\n        when: op.step.when ?? { kind: \"always\" },\n        approvers: op.step.approvers,\n        mode: op.step.mode ?? \"all\",\n        quorum: op.step.quorum,\n        next: [...after.next],\n      };\n      after.next = [inserted.id];\n      next.steps.push(inserted);\n      return next;\n    }\n  }\n};\n\n/**\n * Apply a sequence of ops and report the resulting diff. \"none\" and\n * \"clarify\" ops short-circuit into the proposal metadata instead of mutating.\n */\nexport const proposeEdits = (\n  policy: ApprovalPolicy,\n  ops: EditOp[],\n  reason?: string\n): EditProposal => {\n  let proposed = policy;\n  let clarify: EditProposal[\"clarify\"];\n  let declined: string | undefined;\n  for (const op of ops) {\n    if (op.op === \"clarify\") {\n      clarify = { question: op.question, options: op.options };\n      continue;\n    }\n    if (op.op === \"none\") {\n      declined = op.reason;\n      continue;\n    }\n    proposed = applyEditOp(proposed, op);\n  }\n  return {\n    proposed,\n    changes: diffPolicies(policy, proposed),\n    reason: reason ?? declined,\n    clarify,\n  };\n};\n",
      "type": "registry:lib",
      "target": "lib/approvals-ui/edit-ops.ts"
    },
    {
      "path": "lib/approvals-ui/example-policy.ts",
      "content": "import type { ApprovalPolicy } from \"./policy\";\n\n/**\n * A realistic procurement policy to start from: a manager gate, a finance\n * quorum above 5k, a director sign-off above 25k, then the approved terminal.\n * The director seat is deliberately unassigned so validation has something\n * honest to say out of the box.\n */\nexport const examplePolicy: ApprovalPolicy = {\n  name: \"Procurement approvals\",\n  roots: [\"manager-review\"],\n  steps: [\n    {\n      id: \"manager-review\",\n      kind: \"approval\",\n      label: \"Manager review\",\n      when: { kind: \"always\" },\n      approvers: [{ name: \"Alex Rivera\", title: \"Engineering Manager\" }],\n      mode: \"all\",\n      next: [\"finance-review\"],\n    },\n    {\n      id: \"finance-review\",\n      kind: \"approval\",\n      label: \"Finance review\",\n      when: { kind: \"leaf\", field: \"amount\", op: \">\", value: 5000 },\n      approvers: [\n        { name: \"Priya Patel\", title: \"Controller\" },\n        { name: \"Sam Okafor\", title: \"FP&A Lead\" },\n        { name: \"Maria Chen\", title: \"Finance Ops\" },\n      ],\n      mode: \"quorum\",\n      quorum: 2,\n      next: [\"director-review\"],\n    },\n    {\n      id: \"director-review\",\n      kind: \"approval\",\n      label: \"Director sign-off\",\n      when: { kind: \"leaf\", field: \"amount\", op: \">\", value: 25_000 },\n      approvers: [{ name: null, title: \"Finance Director\" }],\n      mode: \"all\",\n      sla: { hours: 48 },\n      next: [\"approved\"],\n    },\n    {\n      id: \"approved\",\n      kind: \"terminal\",\n      label: \"Approved: post to ERP\",\n      when: { kind: \"always\" },\n      outcome: \"approved\",\n      next: [],\n    },\n  ],\n};\n",
      "type": "registry:lib",
      "target": "lib/approvals-ui/example-policy.ts"
    }
  ],
  "type": "registry:lib"
}