---
title: "@octabits-io/proposal"
summary: "The reviewable-outcome contract for AI edits: a run's result as typed operations (update/create/delete/reorder) against records that already exist, with what each replaces, who proposed it under whose grant, a drift guard, partial accept, and the apply/revert helpers — plus the pending-proposal store for a proposal that waits for a person without a workflow run behind it: record, list what is waiting, decide it exactly once, with an in-memory reference, a plain-SQL store over a structural executor (pg, PGlite, postgres.js) with its DDL, and a Drizzle adapter — and four-eyes approvals on `./approvals`: one person requests an operational action, a different person approves it within a window, and the registered handler runs exactly once in the transaction that marks it approved, the row both ways."
install: "pnpm add @octabits-io/proposal"
availability: "Available from November 2026"
version: "0.6.0"
license: "MIT"
related:
  - name: "@octabits-io/result"
    url: "https://octabits.io/platform/result.md"
canonical_url: "https://octabits.io/platform/proposal"
---

# `@octabits-io/proposal`

The reviewable-outcome contract for AI edits.

```ts
import { buildProposal, proposeFields, resolveDecision } from '@octabits-io/proposal';
import type { Proposal, ProposalDecision } from '@octabits-io/proposal';
```

A workflow's result expressed as a **set of operations against data that already
exists** — not a value the caller has to interpret. Every operation that replaces
something carries what it replaces, captured on the server at emit time, so the
diff is a stored, auditable artifact rather than something a review screen
reconstructs by re-reading the entity in a browser.

Depends only on `zod`. Safe to import in a browser. Engine-agnostic (any
workflow engine — or none — can emit one), ORM-agnostic (applying is the host's
job, and the helpers here are pure), UI-framework-agnostic (a review surface is
one row per operation and a decision out). The whole surface is checked to stay
that way: `pnpm lint` fails on any import other than `zod`.

Where it came from: fourteen production AI workflows across seven domains in a
multi-tenant booking platform, which had invented this shape twice without naming
it. The contract was then rebuilt against five disagreeing shapes — lists of new
rows, ordered collections, trees whose parents don't exist yet, multi-entity fills
with partial failure — and finally carried, unchanged, into a stack it was not
written in (Next.js, Prisma, React; see [On another stack](#on-another-stack)).

## When not to use it

- **The outcome is a value, not an edit.** A summary to read, a classification,
  a draft the person will paste somewhere: nothing is replaced, so there is
  nothing to diff, guard or revert. Store the step's output and show it; a
  proposal with no `current` is ceremony.
- **You want something that applies the change.** Nothing here writes.
  `resolveDecision` hands the host an ordered list of operations, and the host
  maps them to its ORM in its own transaction, next to the ledger row. A package
  that writes to your tables for you does not exist in this repository, on
  purpose.
- **You need a diff over arbitrary documents.** Four operations against
  addressed slots — an entity anchor, a path, a variant — is not JSON Patch and
  not a text diff. A structural diff of two large JSON trees, or of prose, wants
  a diff library; a proposal is what an agent *proposes*, kept small enough for a
  person to decide.
- **You need to know what was applied.** `applied` on the proposal is a marker
  for the review surface. The record of which application stands, who made it
  and how to undo it is the ledger row —
  [`@octabits-io/agent-ledger`](https://octabits.io/platform/agent-ledger.md) — and revert is
  computed from that row, never from the proposal.
- **You need the review rendered.** No markup here. The decision semantics as a
  headless store are `@octabits-io/agent-ui`; the cards are its
  `./nuxt-ui/*` (Nuxt UI) and `./react/plain`.

## Operations

Four, derived from a survey of real multi-step AI workflows rather than guessed:

| Op | What it carries | Why it exists |
|---|---|---|
| `update` | `target`, structured `path`, optional `variant` (e.g. locale), `current`, `proposed` | field values on an entity that exists, often across a second axis |
| `create` | `collection`, `ref`, `value`, optional `parent` anchor, optional `existing` | new rows, including whole trees whose parents are themselves new |
| `delete` | `target`, `current` | removal a reviewer must see as removal, not as an empty replacement |
| `reorder` | `collection`, `current[]`, `proposed[]` | ordered collections, where sequence is the meaning |

Two mechanisms fall out of that:

- **Pending anchors.** A `create` declares a `ref` valid only inside the proposal;
  anything may anchor to it (`pendingAnchor(ref)`), so a child can name a parent
  that has no id yet. `orderOperations` sorts creates before their dependents and
  reorders last; resolving refs to real ids is the applying host's job.
- **Derivation.** For a translation, "what this replaces" is nothing — the slot is
  empty. `derivedFrom` carries the *source* a reviewer needs to see instead.
  When the value was read out of documents, `derivedFrom.citations[]` pins each
  passage: `source` (the host's document id) plus, as far as the producer knows
  them, `title`, `version`, `digest` of the bytes read, `page`, `locator`
  (`§ 4.2`), `span` (half-open character offsets) and the verbatim `quote`. A
  citation carries no URL on purpose — the host turns `source` into a link, so
  a review surface never renders an address a model wrote.
  `derivedFrom.confidence` (0 to 1) is the producer's probability that the
  value is right as proposed, when its model reports a calibrated one. It is
  for measurement, not for deciding: `@octabits-io/evidence` holds it against
  how often reviewers changed the value, and a review surface may leave it
  unshown so the reviewer checks the value rather than the number.

  ```ts
  proposeFields({
    target, current, proposed,
    derivedFrom: {
      hosting: {
        label: 'DPA',
        citations: [{ source: 'doc-17', version: '2026-05', digest: 'sha256:…', page: 3, quote: 'All data is processed in Frankfurt am Main.' }],
      },
    },
  });
  ```

## Around the operations

- `skipped[]` — what the run considered and did not propose, with `reason`
  (`SKIP_REASONS`) and optional `detail`. Real workflows partially fail.
- `provenance` — `model`, `keySource`, `costMicros`, `generatedAt`.
- `guard` on an update — a drift digest so a host can detect that the target
  moved between the run completing and the review happening.
- `display` — optional per-operation `label`/`labelKey`, `control`, `maxLength`,
  `hint`, `order`, so one generic renderer stands in for a bespoke review
  component per workflow.
- `applied` — set by the host once a decision is committed (`at`, `by`, `accepted`, `principal`).
- `principal` on `provenance` and on `applied` — who acted, for whom, under which
  grant (`{ kind, id, onBehalfOf?, authorizationId? }`). An agent is a principal,
  not a process: the audit row names both parties.
- `reversibility` on an operation — `reversible` (default: writing `current` back
  restores the world), `compensable` (the write reverts, a side effect does not),
  `irreversible` (nothing the host can write undoes it). `invertOperations`
  honours it and `reversibilityOf` gives the worst class for a ledger row.

## Building one (server side)

```ts
const nextId = createIdFactory();
const proposal = buildProposal({
  scope: 'article:88',
  operations: proposeFields({
    target: entityAnchor('article', 88),
    variant: 'de',
    current: { title: 'Erster Entwurf', excerpt: null },
    proposed: { title: 'Erster Entwurf, überarbeitet', excerpt: 'Zwei Absätze.' },
    display: { title: { control: 'text', maxLength: 120 } },
    nextId,
  }),
  skipped: [{ path: ['seoDescription'], reason: SKIP_REASONS.noOutput }],
  provenance: { model, keySource, costMicros },
});
validateProposal(proposal); // → ProposalIssue[]; empty when structurally sound
```

`proposeFields` drops fields the run reproduced unchanged, so a proposal only
ever contains real changes.

## Deciding (review side)

The review surface emits a `ProposalDecision`: the ids it `accepted`, plus
`edits` for values the reviewer changed before accepting. The host then runs

```ts
const ops = resolveDecision(proposal, decision); // ResolvedOperation[], ordered, `edited` flagged
const orphaned = danglingAfterDecision(proposal, decision); // children kept while their parent was rejected
const rejected = rejectedOperations(proposal, decision); // what was turned down, by shape — no values
```

and applies `ops` itself. A decision names only what it accepts; keep
`{ ...decision, rejected }` as the ledger entry's decision so the record says
what was turned down too (`@octabits-io/evidence` counts it), and write that
entry even when nothing was accepted. `rejectedOperations` keeps the field,
the label and the stated confidence, never a value, a citation or an anchor's
label — the ledger is a record nobody rewrites, so nothing personal goes in. **Applying and reverting are the host's job**; the
contract stores enough (`current`, `guard`, the decision) to make both auditable.

Review-side helpers a surface uses to render: `proposalVariants` (the second
axis, e.g. locales), `operationsForVariant` (what to show under one tab —
un-varianted operations appear under every tab), `summarize` (counts per
operation kind), `formatPath`.

## Apply-side helpers

Still pure, still zod-only — the parts every host would otherwise rewrite:

- `driftDigest(value)` — the digest a producer stores in an update's `guard`
  (`stableStringify` + cyrb53, same result in a browser and on a server) and
  the host recomputes over the live value at apply time.
- `detectDrift(operations, readCurrent)` — which accepted updates would
  overwrite something other than what the reviewer saw. `readCurrent` returns
  the live value or `undefined` for "cannot read" (skipped, not reported).
- `invertOperations(applied, createdIds)` — the operations that undo an
  application: updates swap `current`/`proposed` (the edited value is the new
  `current`), creates become deletes of the ids the host assigned, deletes
  become creates carrying `current` (with `existing` naming the original
  entity), reorders swap. Revert is a second proposal, computed from the
  audit row — never re-read from the entity.

## The recipe — what a host writes

Four things, whatever the stack:

| Piece | What it does |
|---|---|
| **Producer** | Re-reads the row at emit time so `current` is a fact about the run; `proposeFields` with `guard: driftDigest(current)`; `skipped[]` for what the model returned empty; `provenance` with the agent as `principal`. Store the proposal as the run's outcome. |
| **Apply** | `proposalSchema.safeParse` the stored proposal → refuse while an application stands → `validateProposal` → `resolveDecision` → `detectDrift` over the live row (a 409, not a silent overwrite) → one mapping from resolved operations to the ORM's update input → write **and** the ledger row in one transaction. |
| **Ledger row** | Per application: the principal (agent, on behalf of whom, under which grant), the decision, the resolved operations as written, the ids assigned to creates, `reversibilityOf(resolved)`, `appliedAt`/`revertedAt`/`revertedBy`. Whether a run is applied is not on the proposal — the host joins its ledger to the run; standing means `revertedAt IS NULL`. |
| **Revert** | `invertOperations` over the ledger row, written through the same path apply used; `irreversible` operations are named, not undone. |

The reference implementation on this stack is the demo server in this repository:
`apps/demo-server/src/ai/workflows.ts`
(the `propose` step) and `apps/demo-server/src/ai/proposals.ts`
(apply, revert, the anchor→table switch), with
[`@octabits-io/agent-ledger`](https://octabits.io/platform/agent-ledger.md) (`./postgres` over any executor, `./drizzle` for Drizzle)
as the ledger. `@octabits-io/agent-ui/nuxt-ui/ProposalReviewCard.vue` (or its `./react/plain` card) is the review surface.

### One loop for many record types

A host that generates fields for more than one kind of record does not need a
producer and an applier per kind. The loop is the same each time; what differs
is where the fields live. Write it once, parameterised by a small host interface:

- **A spec per record type** — the field list, a `display` entry per field
  (label, control, limits), and the agent's `Principal`.
- **A host per record type** — `load(id)`, `readLeaf(entity, field, variant)`
  and `write(id, entity, updates)`. The applier owns everything else: parsing
  the stored proposal, refusing while an application stands, `validateProposal`,
  `resolveDecision`, `detectDrift` through the host's `readLeaf` (the same read
  the producer used, so the digests agree), the write, the ledger row, and
  revert from that row.

Fields stored as columns and fields stored as leaves inside a JSON column then
share one implementation; each host is a few lines. Keep the field list in a
module of its own: the workflow imports it to know what to generate and the
proposal imports it to describe the fields, and a list defined inside either
one puts the two in a cycle.

## Keeping a proposal until someone decides

A workflow run can keep its proposal as the run's output. A tool call from
outside the review surface — an MCP agent asking to cancel a booking — has no
run behind it, so the proposal needs a row of its own until a person decides.
That row is the **pending-proposal store**:

```ts
import { createPostgresPendingProposalStore, pendingProposalDdl } from '@octabits-io/proposal/postgres';

const proposals = createPostgresPendingProposalStore({ db: pool, scope: { column: 'org_id', value: orgId } });

// The tool call: record what is proposed, and return its id as pending.
const recorded = await proposals.record({
  kind: 'cancel_booking',                      // your routing key for rendering and applying
  proposal,                                    // validated against proposalSchema
  summary: 'Cancel booking #123; refund 240 EUR, guest notified',
  proposedBy: 'agent:ops-bot',
  expiresAt: inSevenDays,
});

// The review surface: what is waiting, newest first.
const inbox = await proposals.list({ status: 'pending' });

// The apply: your write path and your ledger entry first, then the decision.
await proposals.decide(id, { status: 'applied', decidedBy: userId, ledgerEntryId: entry.id });
```

- `record` refuses anything that is not a proposal a reviewer could act on
  (`pending_proposal_invalid`).
- `decide` lands **exactly once** — applied, rejected or expired. A second
  decision returns `pending_proposal_already_decided` with how the first one
  went; two reviewers deciding at once get one success, because the SQL stores
  decide with one conditional `UPDATE`.
- A pending proposal past `expiresAt` **reads as expired**, drops out of
  `list({ status: 'pending' })`, and can no longer be applied or rejected:
  deciding it records the expiry and returns `pending_proposal_expired`.
- Deciding is not applying. The store never touches your records; it records
  that the decision happened. Apply the accepted operations through your own
  write path, write the ledger entry, then `decide` with its id.

| Import | What |
|---|---|
| `@octabits-io/proposal` | `PendingProposalStore`, the error keys, `effectivePendingProposalStatus`, `createInMemoryPendingProposalStore` (the reference, for tests) |
| `@octabits-io/proposal/postgres` | `createPostgresPendingProposalStore` over a structural executor (`pg`, PGlite, postgres.js), `pendingProposalDdl` |
| `@octabits-io/proposal/drizzle` | `pendingProposalColumns` to spread beside your scope column, `createDrizzlePendingProposalStore`; `drizzle-orm` an optional peer |

Scoping is construction-time, as in every store here: `scope: { column, value }`
stamps and filters every read and decision; `scope: { column }` alone stamps
from `entry.scopeKey` and reads across scopes, for a system-mode caller; no
`scope` in a single-scope deployment. One conformance suite runs the in-memory,
SQL and Drizzle stores through the same scenario on PGlite.

## Four eyes — an action that needs a second person

Some actions should not run on one person's say-so: deleting a customer
workspace, granting an admin role, lifting a security freeze. **Four eyes**:
one person requests, a *different* person approves within a window, and the
action runs exactly once. The parts hosts usually miss — approver ≠ requester,
expiry, run-once, ending a requester's open requests when they lose access —
are the contract here, on `@octabits-io/proposal/approvals`:

```ts
import { createApprovals, defineApprovalActions, type ApprovalRunContext } from '@octabits-io/proposal/approvals';
import { createPostgresApprovalRequestStore, approvalRequestDdl, type SqlExecutor } from '@octabits-io/proposal/approvals/postgres';

// The register: what an approval may release, with the payload each action accepts.
const actions = defineApprovalActions({
  'workspace.offboard': {
    payload: z.object({ workspaceId: z.string() }),
    async run({ workspaceId }, { tx }: ApprovalRunContext<SqlExecutor>) {
      await offboardWorkspace(tx, workspaceId);           // on the approving transaction
      return { offboarded: workspaceId };                 // the request's `result`
    },
  },
  'role.grant': { payload: z.object({ userId: z.string(), role: z.enum(['admin', 'support']) }), run: grantRole },
});

const approvals = createApprovals({
  store: createPostgresApprovalRequestStore({ db: pool, scope: { column: 'org_id', value: orgId } }),
  actions,
  transaction: (fn) => withTransaction(pool, fn),        // runs fn in one transaction, rolls back on a throw
  audit,                                                 // optional: @octabits-io/audit's log, by shape
});

// One person asks …
const pending = await approvals.request({ action: 'workspace.offboard', payload: { workspaceId }, requestedBy: staffA, reason: 'Contract ended, ticket 4711' });

// … a different person approves, and the handler runs once, inside the approving transaction.
const done = await approvals.approve(pending.value.id, { by: staffB, note: 'Checked the contract' });
```

- **Approver ≠ requester.** `approve` by the requester is `approval_self_approve`
  and changes nothing. The requester may `cancel` their own; anyone may `reject`.
- **A window, always.** `expiresAt` on the request or `defaultWindowMs` (24 h);
  a request past it reads as `expired`, drops out of `list({ status: 'pending' })`
  and can no longer be approved (`approval_expired`, recorded as expired).
- **Exactly once.** The claim is one conditional `UPDATE … WHERE status = 'pending'`
  on the approving transaction, so two approvers at once get one run and one
  `approval_already_decided`. The handler runs after the claim and before the
  commit.
- **Nothing half-applied.** A handler that throws or returns `err` rolls the
  transaction back — the claim, the handler's own writes, the audit entry — and
  the request **stays pending** (`approval_execution_failed` carries the cause),
  so a person can try again or reject it. There is no `failed` status on
  purpose: a failed run leaves no trace in the row, because leaving a trace
  would mean committing something.
- **Validated twice.** The payload is parsed against the action's schema at
  request and again at approval, so a schema that tightened in between refuses
  the stale request (`approval_payload_invalid`) instead of running it; an
  unregistered action is refused at both (`approval_action_unknown`).
- **A requester who loses access.** `cancelBy({ requestedBy })` ends every open
  request of theirs (`cancelled`) and touches nothing else — approvals they gave
  on other people's requests stay approved.
- **Audited, by shape.** With `audit`, every request, approval, rejection,
  cancellation and execution is recorded on the same transaction through
  `record(tx, entry)` — the entry is the slice of `@octabits-io/audit`'s with
  `actor`, `action` (`approval.request`, `approval.execute`, …), `target`,
  `reason`, `before`/`after` and, on the execution, `approvedBy`. An `err` from
  the log rolls the step back (`approval_audit_failed`).

| Import | What |
|---|---|
| `@octabits-io/proposal/approvals` | `defineApprovalActions`, `createApprovals`, `ApprovalRequestStore`, the error keys, `effectiveApprovalStatus`, `createInMemoryApprovalRequestStore` + `inMemoryApprovalTransaction` (the reference, for tests) |
| `@octabits-io/proposal/approvals/postgres` | `createPostgresApprovalRequestStore` over a structural executor (`pg`, PGlite, postgres.js) — reads on `db`, writes on the transaction each call is given — and `approvalRequestDdl` |
| `@octabits-io/proposal/approvals/drizzle` | `approvalRequestColumns` to spread beside your scope column, `createDrizzleApprovalRequestStore` (writes on `db.transaction((tx) => …)`); `drizzle-orm` an optional peer |

Why a row of its own and not the pending-proposal store: that row keeps a
*proposal document* — typed operations against records, validated against
`proposalSchema` — and its decision is `applied | rejected | expired` with a
ledger entry, written by the host after it applied. An approval request carries
an action key and a payload the action's own schema validates, is `cancelled`
as often as it is decided, and must be executed *inside* the transaction that
marks it so. Bending one row to both would make the proposal table a lie for
half of its rows; two small rows stay honest, and the two stores share their
scope semantics and their once-only shape. One conformance suite runs the
in-memory, SQL and Drizzle stores through the same scenario on PGlite
(`src/approvals/conformance.test.ts`).
## Operations that are not row edits — the action register

`Proposal` is "agents propose, humans decide" for **record edits**: typed
operations against rows that exist. An admin surface also has operations that
are not row edits — set a price, flip a switch, block a domain, credit an
account, cancel a run. Without a catalogue, each one is an ad-hoc server
action, and a command palette, an assistant or an MCP tool has nothing to
translate into and nothing to preview before a person confirms.
`@octabits-io/proposal/actions` is that catalogue:

```ts
import { defineActions, previewAction, runAction, listActions, actionCatalog } from '@octabits-io/proposal/actions';
import { z } from 'zod';

export const actions = defineActions([
  {
    id: 'pricing.set', label: 'Set a price', description: 'Set the price of a product.',
    examples: ['set price for ChatGPT to 2.50 EUR'],
    area: 'billing', risk: 'medium', stepUp: false,
    input: z.object({ product: z.string(), price: z.number().positive(), currency: z.enum(['EUR', 'USD']) }),
    target: (input) => ({ kind: 'product', id: input.product }),
    preview: async (actor, input, { tx }) => ok([{ label: `Price of ${input.product}`, before: await currentPrice(tx, input.product), after: input.price }]),
    run: async (actor, input, { tx }) => { await setPrice(tx, input.product, input.price); return ok({ product: input.product, price: input.price }); },
  },
  {
    id: 'settings.set', label: 'Set a setting', description: 'Change a non-secret setting.',
    examples: ['switch VISUAL_ANALYSIS off'], area: 'settings', risk: 'low', stepUp: false,
    input: z.object({ name: z.string(), value: z.string() }),
    refuse: (input) => (isSecret(input.name) ? `${input.name} is a secret` : null),   // excluded by declaration
    preview: …, run: …,
  },
  { id: 'moderation.block-domain', …, area: 'moderation', risk: 'high', stepUp: true, … },
]);
```

An action declares what a palette and a model see (`label`, `description`,
`examples`), the permission `area` the rights seam answers for, its `risk`
(`high` is what a host routes into four eyes: `createApprovals` on
`./approvals`, with the action's `run` as the registered handler, so a second
person approves it and it runs once), whether a fresh proof of
identity is required (`stepUp`), the input as Zod, an optional declared
refusal (`refuse` — how "set a setting" keeps secret fields out of a register
a model can reach), the lines of what would happen (`preview`: reads only),
and the change itself (`run`, on the context's transaction).

The two calls, and the second repeats the first:

```ts
// The person asks. Rights resolved (role × area, never on someone's behalf),
// input validated, previewed — nothing changed.
const preview = await previewAction(actions, { id, input, subject, rights, ctx: { tx, scopeKey, requestId } });
//   → { action, input, lines: [{ label, before, after, note? }] }

// The person confirms with a reason. Rights resolved again, previewed again
// (the same checks they saw), run, one audit entry with input, preview and result.
const ran = await runAction(actions, { id, input, subject, rights, reason, audit, ctx: { tx, scopeKey, requestId } });
```

- **`subject`** is `{ id, role?, onBehalfOf?, impersonation? }`. An action is
  never run on someone's behalf: a delegation or an impersonation marker on
  the subject refuses with `action_on_behalf_refused`, and `listActions`
  returns nothing for them.
- **`rights`** is a seam by shape: `can(subject, area)` — `@octabits-io/server/authz`'s
  gate (`forSubject(subject).can(area)`) fits — and, for a `stepUp` action,
  `fresh(subject, action)` returning `step_up_required` with `maxAgeSeconds`,
  `methods` and `reason` when the proof is stale — `requireFresh(session, rule)`
  from the same package, bound to the request's session, returns exactly that
  and fits as is; without a gate a `stepUp` action is refused rather than run.
  The error passes through untouched, so `server`'s mapping turns it into
  one status (403) and `session`'s `withStepUp` re-prompts on it.
- **`audit`** is `@octabits-io/audit`'s `record(tx, entry)` by shape: `actor`,
  `actorRole`, `action` (the action's id), `target` (the declaration's
  `target(input)`, default `{ kind: 'action', id }`), `scopeKey`, the
  `reason` the person typed, `before` = the input and the confirmed preview,
  `after` = the result. A refused entry is `action_audit_failed`; a failed
  run is `action_failed`; both leave the host's transaction to roll back.
- `actionCatalog(actions)` is the register as data — a JSON Schema per input,
  never a handler — for a model or an MCP `tools/list`. `validateActionInput`
  is the check behind every call, and what a host runs on a proposal that
  arrived from outside before showing it.

**Free text → an action.** `planCommand(text, { register, rules, model?, subject, rights })`
tries deterministic rules first — data, a pattern with named groups and the
input it builds — and only then a model, which is handed the catalogue and
*translates* into exactly one registered action or none, with a reason. The
answer is validated against the catalogue (an unknown id is
`command_model_unknown_action`, never a guess), previewed with the subject's
rights like any other request, and returned as a plan marked `source: 'rule'`
or `source: 'model'`. The model never executes. The model seam is
`(catalogue, text) => Promise<{ id, input, reason } | null>`; a host builds it
on its model client behind `@octabits-io/disclosure`'s `guardModelCall`.

```ts
const rules = [
  { action: 'pricing.set', pattern: /^set price for (?<product>[\w ]+?) to (?<price>\d+(?:[.,]\d+)?) ?(?<currency>EUR|USD)?$/i,
    input: ({ product, price, currency }) => ({ product: product.toLowerCase(), price: Number(price.replace(',', '.')), currency: currency?.toUpperCase() ?? 'EUR' }) },
  { action: 'settings.set', pattern: /^switch (?<name>[A-Z_]+) (?<value>on|off)$/i, input: (g) => ({ name: g.name, value: g.value }) },
];
const plan = await planCommand('set price for ChatGPT to 2,50 USD', { register: actions, rules, model, subject, rights });
//   → { source: 'rule', action, input, lines }   — the palette shows the lines and asks for a reason
```

**An action that waits for a person.** `proposeAction(actions, pendingStore, { id, input, proposedBy, summary?, scopeKey?, expiresAt? })`
records it in the pending-proposal store under `kind: 'action'` — a proposal
of one `create` in the `action` collection whose value is the action id and
its input — so an assistant's or an MCP client's proposed command sits in
the same inbox as every other proposal and is decided exactly once. The input
and the declared refusal are checked at proposal time; **rights are checked at
approval**: `applyActionProposal(actions, pendingStore, pendingId, { subject, rights, reason, audit, ctx })`
runs it with the approver's rights (preview repeated, audit entry written),
then decides the proposal as applied. A failed run leaves it pending.
`readActionProposal(pending)` gives the action and input back for the
inbox row.

A `propose_action` tool on `@octabits-io/server/mcp` is `proposeActionTool`
over those two calls: the catalogue becomes the input's enum and the tool's
description, the call records a pending proposal and never runs anything, and
the tool passes the write discipline because it proposes:

```ts
import { proposeActionTool } from '@octabits-io/server/mcp';
import { actionCatalog, proposeAction } from '@octabits-io/proposal/actions';

registerTools: (server, getScope) => {
  const tool = proposeActionTool({
    actions: actionCatalog(actions),
    propose: ({ action, input, summary }) =>
      proposeAction(actions, getScope().pendingStore, { id: action, input, summary, proposedBy: getScope().principal, scopeKey: getScope().key }),
  });
  server.registerTool(tool.name, disclosedAs(tool.config, 'internal'), tool.handler);
},
```

**Step-up and four eyes, wired once.** `stepUp` and `risk` are declarations;
two helpers connect them to the gates without the register importing either
package:

```ts
import { requireFresh } from '@octabits-io/server/authz';
import { createApprovals } from '@octabits-io/proposal/approvals';
import { actionFreshness, approvalActionsFor, submitAction } from '@octabits-io/proposal/actions';

const rights: ActionRights = {
  can: (subject, area) => authz.forSubject(subject).can(area),
  // every `stepUp` action passes the gate with the request's proof; a rule per action is allowed
  fresh: actionFreshness({ gate: requireFresh, holder: () => ctx, rule: (action) => (action.risk === 'high' ? { maxAgeSeconds: 300, methods: ['passkey'] } : { maxAgeSeconds: 600 }) }),
};

// the `risk: 'high'` actions as approval handlers: `runAction` with the approver's rights, on the approving transaction
const approvals = createApprovals({ store, transaction, audit, actions: approvalActionsFor(actions, { rights, audit }) });

// the confirm button: a high-risk action is previewed with the requester's rights and recorded
// as an approval request; anything else runs now
const submitted = await submitAction(actions, { id, input, subject, rights, reason, audit, approvals, ctx: { tx, scopeKey } });
//   → { status: 'ran', run } | { status: 'requested', preview, request }
await approvals.approve(submitted.value.request.id, { by: secondPerson });   // runs once; the requester is refused
```

- `actionFreshness({ gate, holder, rule })` is `rights.fresh`: `holder(subject)`
  is what carries the proof (the request scope, its session), `rule` is the
  gate's rule or a function of the action's summary. A stale proof comes
  back as the gate's own `step_up_required`.
- `approvalActionsFor(actions, { rights, audit?, subject?, needsApproval? })`
  registers the actions that need a second person (default `risk: 'high'`)
  with the action's input as the payload schema. The approver needs the
  area and, for a `stepUp` action, their own fresh proof; a refusal is
  `approval_execution_failed` with the action error as `cause`, and the
  request stays pending.
- `submitAction` routes by the same `needsApproval`. `runAction` itself does
  not: a host that offers four eyes calls `submitAction` from its confirm
  button and keeps `runAction` for the approved path.

| Import | What |
|---|---|
| `@octabits-io/proposal/actions` | `defineActions`, `listActions`, `previewAction`, `runAction`, `actionCatalog`, `validateActionInput`, `planCommand`, `proposeAction` / `readActionProposal` / `applyActionProposal`, `actionFreshness`, `approvalActionsFor` / `submitAction`, the seam types (`ActionRights`, `ActionAudit`, `ActionTranslator`) and the error keys |

Errors: `action_unknown`, `action_input_invalid` (with `issues`), `forbidden`,
`action_on_behalf_refused`, `action_refused` (with the declaration's `reason`),
`step_up_required`, `action_preview_failed`, `action_failed`,
`action_audit_failed`, `action_reason_required`; from the planner
`command_not_understood` and `command_model_unknown_action`; from the pending
half the store's own keys.

## On another stack

The contract carries the loop into stacks it was not written in. The transfer
test — Next.js 16, Prisma 7, React 19, using this package and nothing else —
wrote the same four things in about 330 lines and drove them in a browser:
propose, review with edits, a refused apply after a hand edit, revert from the
ledger row written in the same transaction as the change. Condensed:

**The producer** (a server action):

```ts
import { buildProposal, createIdFactory, driftDigest, entityAnchor, proposeFields, SKIP_REASONS } from '@octabits-io/proposal';
import type { ChangeDisplay, JsonValue, Principal, SkippedItem } from '@octabits-io/proposal';

const FIELDS = ['title', 'excerpt', 'description'] as const;
const AGENT: Principal = { kind: 'agent', id: 'ai:property-copy', label: 'Property copy' };
const DISPLAY: Record<(typeof FIELDS)[number], ChangeDisplay> = {
  title: { label: 'Title', control: 'text', maxLength: 120, order: 1 },
  excerpt: { label: 'Excerpt', control: 'multiline', maxLength: 300, order: 2 },
  description: { label: 'Description', control: 'multiline', order: 3 },
};

export async function proposePropertyCopy(propertyId: number, onBehalfOf: string) {
  const output = await generateCopy(propertyId); // the model call — any SDK

  // Re-read at emit time: `current` must be what the row holds now, and the
  // drift guard is the digest of exactly that.
  const now = await prisma.property.findUniqueOrThrow({ where: { id: propertyId } });
  const target = entityAnchor('property', propertyId, now.title);
  const current: Record<string, JsonValue> = {};
  const proposed: Record<string, JsonValue | undefined> = {};
  const guard: Record<string, string> = {};
  const skipped: SkippedItem[] = [];
  for (const field of FIELDS) {
    if (output[field] === undefined) { skipped.push({ target, path: [field], reason: SKIP_REASONS.noOutput }); continue; }
    current[field] = now[field] ?? null;
    proposed[field] = output[field];
    guard[field] = driftDigest(current[field]);
  }

  const run = await prisma.run.create({ data: { propertyId, proposal: {} } });
  const proposal = buildProposal({
    scope: `property:${propertyId}`,
    workflowId: run.id,
    operations: proposeFields({ target, current, proposed, guard, display: DISPLAY, nextId: createIdFactory('property-copy') }),
    skipped,
    provenance: { generatedAt: new Date().toISOString(), principal: { ...AGENT, onBehalfOf } },
  });
  await prisma.run.update({ where: { id: run.id }, data: { proposal: proposal as object } });
  return proposal;
}
```

**Apply and revert** (the route handlers behind `POST /api/runs/:id/apply` and `…/revert`):

```ts
import { detectDrift, invertOperations, proposalSchema, resolveDecision, reversibilityOf, validateProposal } from '@octabits-io/proposal';

export async function applyRun(runId: number, decision: ProposalDecision, actorId: string) {
  const run = await prisma.run.findUniqueOrThrow({ where: { id: runId } });
  const proposal = proposalSchema.parse(run.proposal);

  const standing = await prisma.agentLedger.findFirst({ where: { workflowId: String(runId), revertedAt: null } });
  if (standing) return conflict('proposal_applied_conflict');
  if (validateProposal(proposal).length > 0) return invalid('invalid_proposal');

  const resolved = resolveDecision(proposal, decision);
  const property = await prisma.property.findUniqueOrThrow({ where: { id: run.propertyId } });
  const drifted = detectDrift(resolved, (op) => (op.op === 'update' ? property[op.path[0] as Field] ?? null : undefined));
  if (drifted.length > 0) return conflict('proposal_drift_conflict', drifted.map((d) => d.operationId));

  // One transaction: the write and the row that says who did it, together.
  const data = toPrismaUpdate(resolved); // resolved update → { [field]: value }, null clears
  await prisma.$transaction(async (tx) => {
    await tx.property.update({ where: { id: property.id }, data });
    await tx.agentLedger.create({
      data: {
        actorKind: AGENT.kind, actorId: AGENT.id, actorLabel: AGENT.label, onBehalfOf: actorId,
        mode: 'reviewed', scope: proposal.scope, workflowId: String(runId),
        decision: decision as object, operations: resolved as object[], created: {},
        reversibility: reversibilityOf(resolved),
      },
    });
  });
}

export async function revertRun(runId: number, actorId: string) {
  const row = await prisma.agentLedger.findFirstOrThrow({ where: { workflowId: String(runId) }, orderBy: { id: 'desc' } });
  if (row.revertedAt) return conflict('proposal_reverted_conflict');
  const plan = invertOperations(row.operations as ResolvedOperation[], row.created as Record<string, string>);
  await prisma.$transaction(async (tx) => {
    await tx.property.update({ where: { id: propertyIdOf(row.scope) }, data: toPrismaUpdate(plan.operations) });
    await tx.agentLedger.update({ where: { id: row.id }, data: { revertedAt: new Date(), revertedBy: actorId } });
  });
}
```

**The review surface** (React; the whole component is ~100 lines):

```tsx
const [accepted, setAccepted] = useState(() => new Set(updates.map((op) => op.id)));
const [edits, setEdits] = useState<Record<string, string>>({});

const decision: ProposalDecision = {
  accepted: [...accepted],
  edits: Object.entries(edits)
    .filter(([id]) => accepted.has(id))
    .map(([id, value]) => ({ id, value })),
};
// …one row per update: a checkbox, `op.current` struck through, an input or
// textarea over `op.proposed` chosen by `op.display?.control`, then
// POST /api/runs/:id/apply with `decision`.
```

What such a host should know:

- `SkippedItem.path` is optional; a generic review surface guards it.
- `ChangeDisplay` carries `label` or `labelKey`. Use `label` when the host has
  no i18n layer.
- The write-side value type is the ORM's, not `JsonValue`: one mapping from
  resolved updates to the ORM's update input, and a **nullable column for every
  field a proposal may clear**. A non-null column cannot hold the `null` that a
  `current` of "empty" reverts to.
- `proposalDecisionSchema.safeParse(body)` is the whole input validation of an
  apply route.
- Hold the record write and the ledger row in one transaction. The row is the
  undo plan; a write without it cannot be reverted, and a row without the write
  claims something that did not happen.
- Keep the decision semantics on the contract's side: send every accepted
  operation's value (edited or not), render all four operation kinds, and check
  `danglingAfterDecision`. A review surface that re-implements those in a
  template will drift from the next one — the headless review store in
  `@octabits-io/agent-ui` exists for exactly that reason.

## Wire

`proposalSchema` and `proposalDecisionSchema` are zod schemas for both
directions. A proposal is defined against a step's **output schema**, never its
execution trace, so a step whose internals are a tool loop emits the same
proposal as a deterministic one.

## Consumers

- `@octabits-io/agent-ui/nuxt-ui/ProposalReviewCard.vue` and
  `@octabits-io/agent-ui/react/plain` render a proposal and emit a
  `ProposalDecision`; `@octabits-io/agent-ui` holds the headless review store.
- `@octabits-io/agent-ledger` — the ledger for the apply side; it imports only
  the `Principal` and `Reversibility` types from here.
- An engine (octaflow or otherwise) is one producer among many; nothing here
  depends on one.

## Wire format

The proposal and the decision are published as JSON Schema (draft 2020-12),
generated from the Zod in `src/schema.ts` — the same schemas the package
validates with, so the document and the runtime cannot disagree — and
checked in `pnpm lint`:

| Document | What |
|---|---|
| `schema/proposal.schema.json` | `Proposal`: scope, operations discriminated on `op` (`update`, `create`, `delete`, `reorder`), skipped items, provenance, the applied record |
| `schema/proposal-decision.schema.json` | `ProposalDecision`: the accepted ids and the edited values |

They are in the package (`@octabits-io/proposal/schema/proposal.schema.json`)
and at their `$id`, the raw GitHub URL. A producer in another language builds
against the document; the tests validate what this package builds against it
with a JSON Schema validator, not with Zod.

## License

MIT