# Apply only the record edits a person kept

An agent that cleans up data proposes many edits at once: change this customer's owner, add that one, remove a duplicate. A person should be able to take some and leave others, and the server should apply exactly what they took, not the agent's whole list and not a list that changed while they read it.

When the agent proposes a set, save it on your server with an id for each edit, in order, and send that to the page. ChangeSetReview shows each edit with its fields before and after. Nothing starts kept, so the person keeps or drops each edit (K and D from the keyboard), and edits they leave undecided are not applied. It says what can and can't be undone, from the `undo` your app passes, and while the set is applying every choice is locked with the reason.

On Apply it calls `onApply(kept, hash)`: the kept edits in the set's order, and the sha256 of their canonical JSON from `changeHash`. Send the ids and the hash. Your server takes the edits with those ids from its own copy of the set, in the set's order, hashes them with the same `changeHash`, and applies nothing if the hash differs. Then it checks that each record still looks as the agent read it, and applies the edits together or not at all.

Web page: https://candor.themethodic.com/patterns/apply-reviewed-change-set

## Components

- **Change set review** (`change-set-review`, Pro; https://candor.themethodic.com/docs/change-set-review.md): The proposed edits, each kept or dropped by the person, with Apply calling back with the kept ones and their hash (Pro).
- **Change hash** (`change-hash`, Pro; https://candor.themethodic.com/docs/change-hash.md): The same sha256 of canonical JSON in the browser and on the server, so the hash can be checked where the edits run.
- **Reconcile table** (`reconcile-table`, Pro; https://candor.themethodic.com/docs/reconcile-table.md): When the edits come from comparing two sources: settle each row first, then propose the set (Pro).
- **Approval request** (`approval-request`, Pro; https://candor.themethodic.com/docs/approval-request.md): For one change that a second person approves, tied to its hash in the same way (Pro).
- **Undo window** (`undo-window`, Pro; https://candor.themethodic.com/docs/undo-window.md): After applying, Undo for as long as your server allows, then an honest "can't be undone now" (Pro).
- **Locked** (`locked`, free; https://candor.themethodic.com/docs/locked.md): A control that can't be used says why, such as Apply on a set the viewer asked for.

## Install

```bash
npx candor-ui add change-set-review change-hash locked
```

## Code

`app/api/change-sets/[id]/apply/route.ts`: The route that applies a reviewed set: the kept ids, the hash checked against the server's own copy, and all or nothing.

```tsx
import { changeHash } from "@/lib/candor/change-hash"
import type { ChangeSetEdit } from "@/components/candor/change-set-review"
import { db } from "@/lib/db" // yours: the store that holds proposed sets and the records
import { viewerOf } from "@/lib/auth" // yours: who is asking

/** True when the record no longer looks as it did when the agent proposed the edit. */
function moved(edit: ChangeSetEdit, now: Record<string, string | null> | null) {
  if (edit.kind === "add") return now !== null
  if (now === null) return true
  return edit.fields.some((f) => f.from !== undefined && now[f.field] !== f.from)
}

/** Apply the edits a person kept from a proposed set. The body is `{ ids, hash }`. */
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const viewer = await viewerOf(request)
  if (!viewer) return Response.json({ error: "Sign in to apply changes." }, { status: 401 })
  const { ids, hash } = (await request.json()) as { ids?: unknown; hash?: unknown }
  if (!Array.isArray(ids) || !ids.every((x) => typeof x === "string") || typeof hash !== "string")
    return Response.json({ error: "Send the kept edits' ids and their hash." }, { status: 400 })

  return db.transaction(async (tx) => {
    // The set as the agent proposed it, saved when it proposed it. The browser's copy is never what gets applied.
    const set = await tx.changeSet(id, { lock: true })
    if (!set) return Response.json({ error: "There's no such change set." }, { status: 404 })
    if (set.appliedAt) return Response.json({ error: `This set was already applied by ${set.appliedBy}.` }, { status: 409 })
    if (set.requestedBy === viewer.id) return Response.json({ error: "You asked for these changes, so someone else applies them." }, { status: 403 })

    // The kept edits in the set's own order: the same edits in the same order give the same hash.
    const wanted = new Set(ids)
    const kept = set.edits.filter((e) => wanted.has(e.id))
    if (kept.length === 0 || kept.length !== wanted.size) return Response.json({ error: "Those edits aren't all in this set." }, { status: 409 })
    if ((await changeHash(kept)) !== hash) return Response.json({ error: "These aren't the changes you were shown. Reload and look again." }, { status: 409 })

    // All or nothing: one record that changed since the agent read it stops the whole set.
    for (const edit of kept) {
      if (moved(edit, await tx.record(edit.record.name)))
        return Response.json({ error: `${edit.record.label ?? edit.record.name} changed since the agent proposed this, so nothing was applied.` }, { status: 409 })
    }
    for (const edit of kept) await tx.apply(edit)
    const at = new Date().toISOString()
    await tx.markApplied(id, { by: viewer.id, at, hash, ids: kept.map((e) => e.id) })
    await tx.audit({ at, who: viewer.id, what: `Applied ${kept.length} of ${set.edits.length} edits from ${id} (sha256:${hash}).` })
    return Response.json({ applied: kept.length, left: set.edits.length - kept.length })
  })
}

```

`review-change-set.tsx`: The page's side: ChangeSetReview, and Apply sending the ids and the hash.

```tsx
"use client"

import * as React from "react"
import { ChangeSetReview, type ChangeSetEdit } from "@/components/candor/change-set-review"

export function ReviewChangeSet({ id, edits }: { id: string; edits: ChangeSetEdit[] }) {
  const [applying, setApplying] = React.useState(false)
  const [outcome, setOutcome] = React.useState<string>()
  return (
    <>
      <ChangeSetReview
        edits={edits}
        busy={applying}
        undo={{
          add: { available: true, how: "Delete the new record from its page." },
          change: { available: true, how: "Set each field back from the record's history." },
          remove: { available: false, reason: "a removed record is deleted with its history." },
        }}
        onApply={async (kept, hash) => {
          setApplying(true)
          try {
            // Send the ids and the hash, not the edits: the server applies its own copy of them.
            const res = await fetch(`/api/change-sets/${id}/apply`, {
              method: "POST",
              headers: { "content-type": "application/json" },
              body: JSON.stringify({ ids: kept.map((e) => e.id), hash }),
            })
            const body = (await res.json()) as { applied?: number; error?: string }
            setOutcome(res.ok ? `Applied ${body.applied} changes.` : body.error)
          } catch {
            setOutcome("No answer from the server, so it isn't known whether the changes were applied. Reload to see.")
          } finally {
            setApplying(false)
          }
        }}
      />
      {outcome && <p role="status">{outcome}</p>}
    </>
  )
}

```

## Notes

- Never apply the edits the browser sends. Use the ids to pick edits from the set you saved, so a changed request body can't add an edit nobody saw. The hash then shows the person saw exactly those, in that order.
- ChangeSetReview shows values as text your app formats. Check for drift against the raw values you saved with the set, not the formatted text, and record every field's value before the edit: one left out ("Not recorded") can't be checked.
- A choice the person made is cleared if the agent revises that edit, because it was made on something else. If the agent revises a set on the server, save it as a new set with new ids, so a hash for the old one can't apply the new one.
- Pass `cannotApply` to say who can't apply a set (the person who asked the agent for it, say), and refuse the same person on the server: the UI states the rule and the server enforces it.
- To hash on the server instead, pass `hashOf`, a function that asks your server for the hash of the kept edits. The server still recomputes it when it applies them.
