# Approval request

A request for second-level approval: what changes, where, what it touches, whether it can be undone, and the hash that ties the decision to exactly this change. Decisions are only shown once recorded. A viewer who can't decide sees why on the buttons. Production requests carry a warning. A request that needs several approvals shows how many it has, and stays pending until it has them all. An agent asking for a person names both. A change to one thing shows its values before and after; a request only certain people may decide carries a badge saying who, and why. `density="compact"` fits a narrow column or a notification. With `onApproveEdited` and the request's `input`, the person deciding can edit the change first: they see what they changed against what the agent proposed, and the approval carries the edited change's own hash.

- Registry name: `approval-request` (`@candor/approval-request`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/approval-request

## Install

```bash
npx candor-ui add approval-request
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/approval-request`.
It brings in: agent-avatar, badge, button, change-hash, collapsible, entity-chip, locale, locked, relative-time, textarea, use-copy, work, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import type { ApprovalRequest as ApprovalData, Decision } from "@/lib/work"
import { ApprovalDecision, ApprovalRequest } from "@/components/approval-request"

const ago = (m: number) => new Date(Date.now() - m * 60_000).toISOString()
const ahead = (h: number) => new Date(Date.now() + h * 3_600_000).toISOString()

const initial: ApprovalData = {
  id: "ap1",
  title: "Add region to customers",
  summary: "A region column on customers, shown on the customer page and backfilled from billing country.",
  requester: { agent: { id: "a-orch", name: "Keel", role: "orchestrator" } },
  environment: "Acme web",
  production: true,
  artifactHash: "9f2c4e81b7a03d55c6e1f0a9b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9",
  impact: {
    components: [
      { kind: "migration", name: "0142_add_customer_region", label: "Add customers.region" },
      { kind: "component", name: "web/src/customers/CustomerHeader.tsx", label: "CustomerHeader" },
      { kind: "job", name: "assign_region", label: "Assign region job" },
    ],
    rows: "unknown",
  },
  rollback: { available: true, how: "Revert the pull request and run the migration's down step; the backfilled values go with the column." },
  createdAt: ago(25),
  expiresAt: ahead(7 * 24 - 1),
  state: "pending",
}

// A change to one thing: its values before and after, decided only by the owner, shown compact as in a notification.
const roleChange: ApprovalData = {
  id: "ap2",
  title: "Change Jonas’s role",
  summary: "Jonas becomes an Admin on Acme web.",
  requester: { name: "Dana" },
  environment: "Acme web",
  production: false,
  detail: "Members",
  artifactHash: "4b1e09c2d7f35a8e6b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a",
  impact: { components: [{ kind: "member", name: "jonas", label: "Jonas" }], rows: 1 },
  rollback: { available: true, how: "Set the role back; nothing else changes." },
  createdAt: ago(5),
  expiresAt: ahead(48),
  state: "pending",
  target: { kind: "member", name: "jonas", label: "Jonas" },
  values: [{ label: "Role", from: "Member", to: "Admin" }],
  restrictedTo: { who: "the owner", reason: "It changes who can approve changes." },
}

// A change the approver may edit first: they see what they changed, and approve the edited change under its own hash.
const batchSize: ApprovalData = {
  id: "ap3",
  title: "Raise the nightly export’s batch size",
  summary: "Bigger batches so the export finishes before the morning reports run.",
  requester: { agent: { id: "a-ops", name: "Relay", role: "operator" } },
  environment: "Acme web",
  detail: "Jobs",
  production: false,
  artifactHash: "c3d9a7e1f05b2468ace0b1d2c3e4f5061728394a5b6c7d8e9f00112233445566",
  impact: { components: [{ kind: "job", name: "nightly_export", label: "Nightly export" }], rows: "unknown" },
  rollback: { available: true, how: "Set the batch size back; the next run uses it." },
  createdAt: ago(12),
  expiresAt: ahead(24),
  state: "pending",
  target: { kind: "job", name: "nightly_export", label: "Nightly export" },
  values: [{ label: "Batch size", from: "2000", to: "10000" }],
  input: { job: "nightly_export", batchSize: 10000 },
}

const limitReached: Decision = {
  id: "d1",
  question: "The backfill needs more than its limit. Raise it, or finish with what’s done?",
  facts: [
    { label: "Spent so far", value: "$1.62 of $2.00" },
    { label: "Done", value: "4,120 of 9,800 customers" },
  ],
  continues: "Showing the region on the customer page carries on; it doesn’t need the backfill.",
  choices: [
    { id: "raise", label: "Raise the limit to $4", detail: "About $1.90 more to finish." },
    { id: "finish", label: "Finish with what’s done", detail: "The rest keep an empty region." },
  ],
  recommended: "raise",
  state: "open",
}

export default function Example() {
  const [request, setRequest] = React.useState(initial)
  const [decision, setDecision] = React.useState(limitReached)
  const [role, setRole] = React.useState(roleChange)
  const [batch, setBatch] = React.useState(batchSize)
  const decide = (state: "approved" | "rejected", note?: string) => setRequest((r) => ({ ...r, state, decision: { by: "You", at: new Date().toISOString(), note } }))
  return (
    <div className="flex max-w-2xl flex-col gap-6">
      <ApprovalRequest request={request} onApprove={() => decide("approved")} onReject={(_hash, note) => decide("rejected", note)} rejectNote />
      <div className="max-w-sm">
        <ApprovalRequest
          density="compact"
          request={role}
          onApprove={() => setRole((r) => ({ ...r, state: "approved", decision: { by: "You", at: new Date().toISOString() } }))}
          onReject={() => setRole((r) => ({ ...r, state: "rejected", decision: { by: "You", at: new Date().toISOString() } }))}
          onOpen={() => {}}
        />
      </div>
      {/* Edit before approving: the edited change gets its own hash, and the record keeps the one proposed. */}
      <ApprovalRequest
        request={batch}
        onApprove={() => setBatch((r) => ({ ...r, state: "approved", decision: { by: "You", at: new Date().toISOString() } }))}
        onApproveEdited={(hash, input) =>
          setBatch((r) => ({ ...r, state: "approved", artifactHash: hash, input, values: [{ label: "Batch size", from: "2000", to: String(input.batchSize) }], decision: { by: "You", at: new Date().toISOString(), editedFrom: r.artifactHash } }))
        }
        onReject={() => setBatch((r) => ({ ...r, state: "rejected", decision: { by: "You", at: new Date().toISOString() } }))}
      />
      {/* ApprovalDecision: two equal answers, nothing pre-selected; the agent's pick is marked, not chosen. */}
      <ApprovalDecision decision={decision} onChoose={(id) => setDecision((d) => ({ ...d, state: "decided", chosen: id }))} />
    </div>
  )
}
```

## <ApprovalRequest>

Props:

- `request: ApprovalData`: The request: the change, where, its impact, whether it can be undone, its hash and state.
- `onApprove?: ((hash: string) => void) | undefined`: Called on approve with the change hash, so the decision binds to exactly this change.
- `onReject?: ((hash: string, note?: string) => void) | undefined`: Called on reject with the change hash, and the rejecter's note when `rejectNote` is on and they wrote one.
- `rejectNote?: boolean | undefined` (default `false`): Reject first opens a box for an optional note saying why, then asks to confirm. Off by default.
- `onWithdraw?: (() => void) | undefined`: Shown to the requester while pending.
- `onRetry?: (() => void) | undefined`: Shows Try again when the decision failed to record; called when it's clicked.
- `change?: React.ReactNode | undefined`: The diff or canonical change, shown on demand.
- `maxChips?: number | undefined` (default `5`): How many affected components show as chips before the rest fold away. Defaults to 5.
- `density?: "default" | "compact" | undefined` (default `"default"`): "compact" for a narrow column, a phone or a notification: the title, the values or summary, where, how many things it touches, the hash, expiry and what happens if nobody answers. A change that can't be undone or that changes production offers only `onOpen` there, never Approve.
- `onOpen?: (() => void) | undefined`: Compact only: opens the full request (a page, a sheet). Required to decide a change that can't be undone.
- `onApproveEdited?: ((hash: string, input: Record<string, unknown>) => void) | undefined`: Lets the person deciding edit the request's `input` before approving (full density only). Called with the edited change's hash and the edited input; an edit that changes nothing approves through `onApprove` as usual. Your server recomputes the hash from the input it receives and refuses the approval if it differs.
- `hashOf?: ((input: Record<string, unknown>) => string | Promise<string>) | undefined` (default `changeHash`): Works out an edited change's hash. Defaults to changeHash: the sha256 of the input's canonical JSON. Use your server's own.
- `className?: string | undefined`

## <ApprovalDecision>

A decision the work stops for, with two answers: the question, the facts to decide on, what carries on meanwhile, and two equal buttons. Nothing is pre-selected and neither answer is styled as the default; the agent's pick is marked, not chosen. Once recorded, the card says what was chosen. A variant of ApprovalRequest for a person's own decision rather than a second-level approval of a change.

Props:

- `decision: Decision`: The question, facts, what continues, the two choices and the state.
- `onChoose?: ((choiceId: string) => void) | undefined`: Called with the chosen answer's id.
- `locked?: string | undefined`: Why the viewer can't answer, if they can't; shown on both buttons.
- `className?: string | undefined`

## Strings (`approvalRequest`)

The fixed words, English by default. Override any of them under `approvalRequest` in `<LocaleProvider strings={{ approvalRequest: { … } }}>`; the table is exported as `approvalRequestStrings`.

- `pending`: Waiting for approval
- `submitting`: Recording decision
- `approved`: Approved
- `rejected`: Rejected
- `rejected-by-system`: Rejected automatically
- `withdrawn`: Withdrawn
- `expired`: Expired
- `failed`: Decision not recorded
- `copied`: Copied
- `copyFailed`: Couldn’t copy
- `days`: { one: "{count} day", other: "{count} days" }
- `hours`: { one: "{count} hour", other: "{count} hours" }
- `label`: Approval request: {title}
- `changesProduction`: Changes production in {environment}
- `approvalsProgress`: { one: "{given} of {count} approval", other: "{given} of {count} approvals" }
- `stale`: {environment} changed since this was requested. The change is re-checked before it applies, and refused if it no longer matches.
- `requestedBy`: Requested by
- `onBehalfOf`: for {name}
- `approvals`: Approvals
- `waitingForDecision`: Waiting for a decision
- `waitingForAnotherDecision`: Waiting for another decision
- `notGiven`: Not given
- `where`: Where
- `production`: Production
- `nonProduction`: Non-production
- `touches`: Touches
- `more`: {count} more
- `records`: Records
- `rowsUnknown`: Unknown until it runs
- `undo`: Undo
- `cantUndo`: Can’t be undone: {reason}
- `changeHash`: Change hash
- `viewChange`: View the change
- `whyReject`: Why reject it?
- `optional`: (optional)
- `cancel`: Cancel
- `reject`: Reject
- `rejectWithNote`: Reject…
- `approve`: Approve
- `recording`: Recording…
- `expires`: Expires
- `withdraw`: Withdraw
- `notRecorded`: The decision wasn’t recorded.
- `failedNothingChanged`: {reason} Nothing was changed.
- `tryAgain`: Try again
- `noDecisionWithin`: No decision within {window}. Ask again to restart it.
- `noDecisionInTime`: No decision in time. Ask again to restart it.
- `factsChanged`: Its facts changed, so it can no longer apply.
- `decidedBy`: {state} by
- `quoted`: “{note}”
- `change`: Change
- `before`: Before
- `after`: After
- `notSet`: Not set
- `notRecordedValue`: Not recorded
- `valueChange`: {label}: {from} to {to}
- `restricted`: {who} only
- `restrictedWhy`: Only {who} can decide this. {reason}
- `touchesCount`: { one: "Touches {count} thing", other: "Touches {count} things" }
- `openToDecide`: Open to decide
- `decideInFull`: {why} Open the full request to decide.
- `cantUndoShort`: This can’t be undone.
- `productionShort`: This changes production.
- `ifNoAnswer`: If nobody decides by then, it expires and nothing changes.
- `editBeforeApproving`: Edit before approving
- `editLabel`: The change, as JSON
- `editInvalid`: That isn’t a JSON object, so it can’t be used.
- `editChanged`: You changed
- `editUnchanged`: Nothing changed yet.
- `editProposedNote`: The values above are what the agent proposed. If you approve, your edit runs instead.
- `useOriginal`: Use the original
- `approveEdited`: Approve as edited
- `editedHash`: Edited change hash
- `hashing`: Working out the edited change’s hash…
- `hashFailed`: The edited change’s hash couldn’t be worked out, so it can’t be approved.
- `editConfirm`: I’ve checked my edit. It runs exactly as shown.
- `editConfirmFirst`: Tick that you’ve checked your edit first.
- `editedBeforeRun`: Edited before it ran
- `proposedHash`: As proposed: {hash}

## Strings (`approvalDecision`)

The fixed words, English by default. Override any of them under `approvalDecision` in `<LocaleProvider strings={{ approvalDecision: { … } }}>`; the table is exported as `approvalDecisionStrings`.

- `needsDecision`: Needs your decision
- `decidedHeading`: Decided
- `meanwhile`: Meanwhile:
- `answerBy`: Answer {time}.
- `agentsPick`: Agent’s pick
- `recording`: Recording your answer…
- `decided`: You chose: {choice}
- `notRecorded`: Your answer wasn’t recorded.
- `tryAgain`: Choose again to retry.

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
