@octabits-io/proposal

The reviewable-outcome contract for AI edits.

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).

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 — 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.

    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)

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

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 (./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:

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:

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:

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:

// 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.

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:

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:

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):

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):

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):

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

From @octabits-io/proposal@0.6.0 in the platform repository.