# Environment promotion

Promote a configuration from one environment to another: where from and where to, in each environment's own colour, the diff of what changes, who has approved it and when, and what can and can't be rolled back. Nothing is promoted without a decision: Promote and Don't promote look the same and neither is chosen for the person. Promote stays locked, saying why, while approvals are missing, while the target has changed since the diff was taken, and, for production, until the person ticks that they've read what changes. The tick is for the change shown when it was given: when the diff or `hash` changes, or the target goes stale, it is cleared and the card says why. With the app's `hash`, the decision is tied to exactly this change.

- Registry name: `environment-promotion` (`@candor/environment-promotion`)
- 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/environment-promotion

## Install

```bash
npx candor-ui add environment-promotion
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/environment-promotion`.
It brings in: button, environment-band, locale, locked, metadata-diff, team, tokens, work, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { EnvironmentPromotion, type PromotionDecision } from "@/components/environment-promotion"
import type { FileDiff } from "@/lib/work"

const files: FileDiff[] = [
  {
    path: "config/checkout.json",
    change: "update",
    language: "json",
    hunks: [
      {
        header: "@@ -3,4 +3,4 @@",
        lines: [
          { kind: "context", text: '  "currency": "EUR",', oldNo: 3, newNo: 3 },
          { kind: "remove", text: '  "paymentRetries": 2,', oldNo: 4 },
          { kind: "add", text: '  "paymentRetries": 3,', newNo: 4 },
          { kind: "remove", text: '  "receiptTemplate": "receipt-v2"', oldNo: 5 },
          { kind: "add", text: '  "receiptTemplate": "receipt-v3"', newNo: 5 },
        ],
      },
    ],
  },
]

export default function Example() {
  const [state, setState] = React.useState<"pending" | "promoting" | "promoted" | "declined">("pending")
  const [decision, setDecision] = React.useState<PromotionDecision>()
  return (
    <EnvironmentPromotion
      className="max-w-2xl"
      title="Checkout settings v14"
      from={{ id: "stg", name: "Shop", detail: "staging", production: false, hue: 2, connection: "connected" }}
      to={{ id: "prd", name: "Shop", detail: "production", production: true, hue: 1, connection: "connected" }}
      files={files}
      hash="9f2c41d07be35a18c6e0d2f4"
      requestedBy={{ name: "Release agent", at: "2026-11-10T08:12:00Z" }}
      approvals={{
        needed: 2,
        given: [
          { by: "Priya Shah", at: "2026-11-10T09:30:00Z", note: "Checked the retries against last month’s failures." },
          { by: "Jonas Berg", at: "2026-11-10T10:05:00Z" },
        ],
      }}
      rollback={{
        can: ["The checkout settings, back to version 13"],
        cannot: ["Receipts already sent with the new template", "Payments retried a third time"],
        how: "Promote version 13 again from the history page.",
      }}
      state={state}
      decision={decision}
      onPromote={async () => {
        setState("promoting")
        await new Promise((r) => setTimeout(r, 900))
        setDecision({ by: "You", at: new Date().toISOString() })
        setState("promoted")
      }}
      onDecline={() => {
        setDecision({ by: "You", at: new Date().toISOString() })
        setState("declined")
      }}
    />
  )
}
```

## <EnvironmentPromotion>

Props:

- `title: string`: What is being promoted, e.g. "Checkout settings v14".
- `from: Environment`: The environment it comes from.
- `to: Environment`: The environment it goes to. A production one is warned about and asks the person to tick that they've read the change.
- `files: FileDiff[]`: The diff between the two, one entry per changed file or setting, drawn with ChangeDiff. `[]` when nothing differs.
- `hash?: string | undefined`: The change's hash from your server, shown and passed back to `onPromote`, so the server promotes only what was shown.
- `requestedBy?: { name: string; at: string } | undefined`: Who asked for it, named by the app (a person or an agent), and when (ISO).
- `approvals: { needed: number; given: PromotionApproval[] }`: How many approvals your rules need, and those given. `needed: 0` when none are needed. Approvals count by person: each distinct `by` once, and none from `requestedBy`'s name, so a second entry from one person or the requester's own is listed and marked as not counted. Use the same name for a person in `by` and `requestedBy`.
- `rollback: PromotionRollback`: What can and can't be rolled back.
- `stale?: boolean | undefined` (default `false`): `to` changed since the diff was taken: Promote stays locked until the diff is taken again.
- `state?: "pending" | "promoting" | "promoted" | "declined" | undefined` (default `"pending"`): Where the promotion is: waiting for a decision, under way, promoted or declined.
- `decision?: PromotionDecision | undefined`: Who promoted or declined it and when, once it's promoted or declined.
- `onPromote?: ((hash: string | undefined) => void | Promise<void>) | undefined`: Shows Promote. Called with `hash` when the person promotes. The button waits on a returned promise; if it throws or rejects, the card says promoting failed. Once it resolves, Promote stays locked until you pass a new `state`, `error` or change.
- `onDecline?: (() => void) | undefined`: Shows Don't promote.
- `error?: string | undefined`: Why the last attempt failed, in the app's words.
- `className?: string | undefined`

## Strings (`environmentPromotion`)

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

- `heading`: Promote {title}
- `from`: From
- `to`: To
- `placeLabel`: {label}: 
- `nameDetail`: {name} · {detail}
- `production`: {name} is production: what changes there reaches real users.
- `requestedBy`: Requested by {name} · {when}
- `changes`: What changes
- `nothingDiffers`: Nothing differs between {from} and {to}, so there’s nothing to promote.
- `stale`: {to} changed since this diff was taken. Take the diff again before promoting.
- `approvals`: Approvals
- `approvalsProgress`: { one: "{given} of {count} approval", other: "{given} of {count} approvals" }
- `noApprovalNeeded`: Your rules ask for no approval to promote to {to}.
- `approvedBy`: Approved by {name}
- `approvedAt`:  · {when}
- `notCountedOwn`: Not counted: {name} asked for this promotion.
- `notCountedAgain`: Not counted again: {name} has approved already.
- `notGiven`: Not given yet
- `rollback`: If it has to be rolled back
- `canRollBack`: Can be rolled back
- `cantRollBack`: Can’t be rolled back
- `nothingListed`: Nothing listed.
- `how`: How:
- `acknowledge`: I’ve read what changes in {to} and what can’t be rolled back.
- `ackCleared`: What changes isn’t what you ticked for any more, so the tick was cleared. Read it again, then tick.
- `change`: Change
- `decline`: Don’t promote
- `promote`: Promote to {to}
- `promoting`: Promoting…
- `needsApprovals`: { one: "It needs {count} more approval first.", other: "It needs {count} more approvals first." }
- `needsAck`: Tick that you’ve read what changes first.
- `needsDiff`: Take the diff again first.
- `nothingToPromote`: There’s nothing to promote.
- `busy`: Wait for the promotion to finish.
- `waiting`: Promotion requested. Waiting for the app to show the result.
- `promoted`: Promoted to {to} by {name} · {when}
- `declined`: Not promoted. {name} decided against it · {when}
- `promotedPlain`: Promoted to {to}
- `declinedPlain`: Not promoted
- `quoted`: “{note}”
- `promoteFailed`: Promoting failed.

## Types

### PromotionApproval

One approval given for the promotion, as the app recorded it.

```ts
{
  /** Who approved, by name, from the app's data. */
  by: string;
  /** When (ISO). */
  at: string;
  /** What they said, if anything. */
  note?: string | undefined;
}
```

### PromotionRollback

What can and can't be undone if the promotion goes wrong, in the app's words.

```ts
{
  /** What a rollback puts back: "The settings, to version 13". */
  can: string[];
  /** What it doesn't: "Emails already sent with the new template". */
  cannot: string[];
  /** How to roll back, when it isn't obvious: "Promote version 13 again from the history page." */
  how?: string | undefined;
}
```

### PromotionDecision

Who decided and when, once the promotion is promoted or declined.

```ts
{ by: string; at: string; note?: string | undefined }
```

## Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

### Environment

Import: `import type { Environment } from "@/lib/team"` (item `team`, https://candor.themethodic.com/docs/team.md)

Where agents act: a CRM instance, a database, a repository, a project. The app names them; TermsProvider sets what they're called ("project", "workspace").

```ts
{
  id: string;
  /** The name shown everywhere, e.g. "Shop". */
  name: string;
  /** What tells same-named ones apart, e.g. "dev-2" or "staging". */
  detail?: string | undefined;
  /** Changes here reach real users, so components warn and ask for more care. */
  production: boolean;
  /** A chart slot (1–5) so each environment keeps one colour everywhere it appears. */
  hue?: 1 | 2 | 3 | 4 | 5 | undefined;
  connection: "connected" | "expired" | "not-connected";
}
```

### FileDiff

Import: `import type { FileDiff } from "@/lib/work"` (item `work`, https://candor.themethodic.com/docs/work.md)

```ts
{
  path: string;
  /** What the file is, e.g. a field or a layout. */
  entity?: EntityRef | undefined;
  change: "create" | "update" | "delete";
  /** e.g. "xml", "ts", "python"; shown as a label. */
  language?: string | undefined;
  hunks: DiffHunk[];
}
```

### EntityRef

Import: `import type { EntityRef } from "@/lib/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

```ts
{
  kind: EntityKind;
  /** The identifier, e.g. "orders.status" or "billing.invoice.due_date". */
  name: string;
  /** A friendlier label when there is one, e.g. "Region". */
  label?: string | undefined;
  /** Where it lives, when more than one place is in scope, e.g. "Shop staging". */
  location?: string | undefined;
}
```

### DiffHunk

Import: `import type { DiffHunk } from "@/lib/work"` (item `work`, https://candor.themethodic.com/docs/work.md)

```ts
{ header?: string | undefined; lines: DiffLine[] }
```

## 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`.
