@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
currentis ceremony. - You want something that applies the change. Nothing here writes.
resolveDecisionhands 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.
appliedon 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
createdeclares arefvalid only inside the proposal; anything may anchor to it (pendingAnchor(ref)), so a child can name a parent that has no id yet.orderOperationssorts 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.
derivedFromcarries 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,digestof the bytes read,page,locator(§ 4.2),span(half-open character offsets) and the verbatimquote. A citation carries no URL on purpose — the host turnssourceinto 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/evidenceholds 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, withreason(SKIP_REASONS) and optionaldetail. Real workflows partially fail.provenance—model,keySource,costMicros,generatedAt.guardon 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-operationlabel/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).principalonprovenanceand onapplied— 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.reversibilityon an operation —reversible(default: writingcurrentback restores the world),compensable(the write reverts, a side effect does not),irreversible(nothing the host can write undoes it).invertOperationshonours it andreversibilityOfgives 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’sguard(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.readCurrentreturns the live value orundefinedfor “cannot read” (skipped, not reported).invertOperations(applied, createdIds)— the operations that undo an application: updates swapcurrent/proposed(the edited value is the newcurrent), creates become deletes of the ids the host assigned, deletes become creates carryingcurrent(withexistingnaming 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
displayentry per field (label, control, limits), and the agent’sPrincipal. - A host per record type —
load(id),readLeaf(entity, field, variant)andwrite(id, entity, updates). The applier owns everything else: parsing the stored proposal, refusing while an application stands,validateProposal,resolveDecision,detectDriftthrough the host’sreadLeaf(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 });
recordrefuses anything that is not a proposal a reviewer could act on (pending_proposal_invalid).decidelands exactly once — applied, rejected or expired. A second decision returnspending_proposal_already_decidedwith how the first one went; two reviewers deciding at once get one success, because the SQL stores decide with one conditionalUPDATE.- A pending proposal past
expiresAtreads as expired, drops out oflist({ status: 'pending' }), and can no longer be applied or rejected: deciding it records the expiry and returnspending_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
decidewith 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.
approveby the requester isapproval_self_approveand changes nothing. The requester maycanceltheir own; anyone mayreject. - A window, always.
expiresAton the request ordefaultWindowMs(24 h); a request past it reads asexpired, drops out oflist({ 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 oneapproval_already_decided. The handler runs after the claim and before the commit. - Nothing half-applied. A handler that throws or returns
errrolls the transaction back — the claim, the handler’s own writes, the audit entry — and the request stays pending (approval_execution_failedcarries the cause), so a person can try again or reject it. There is nofailedstatus 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 throughrecord(tx, entry)— the entry is the slice of@octabits-io/audit’s withactor,action(approval.request,approval.execute, …),target,reason,before/afterand, on the execution,approvedBy. Anerrfrom 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 } });
subjectis{ id, role?, onBehalfOf?, impersonation? }. An action is never run on someone’s behalf: a delegation or an impersonation marker on the subject refuses withaction_on_behalf_refused, andlistActionsreturns nothing for them.rightsis a seam by shape:can(subject, area)—@octabits-io/server/authz’s gate (forSubject(subject).can(area)) fits — and, for astepUpaction,fresh(subject, action)returningstep_up_requiredwithmaxAgeSeconds,methodsandreasonwhen 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 astepUpaction is refused rather than run. The error passes through untouched, soserver’s mapping turns it into one status (403) andsession’swithStepUpre-prompts on it.auditis@octabits-io/audit’srecord(tx, entry)by shape:actor,actorRole,action(the action’s id),target(the declaration’starget(input), default{ kind: 'action', id }),scopeKey, thereasonthe person typed,before= the input and the confirmed preview,after= the result. A refused entry isaction_audit_failed; a failed run isaction_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 MCPtools/list.validateActionInputis 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 })isrights.fresh:holder(subject)is what carries the proof (the request scope, its session),ruleis the gate’s rule or a function of the action’s summary. A stale proof comes back as the gate’s ownstep_up_required.approvalActionsFor(actions, { rights, audit?, subject?, needsApproval? })registers the actions that need a second person (defaultrisk: 'high') with the action’s input as the payload schema. The approver needs the area and, for astepUpaction, their own fresh proof; a refusal isapproval_execution_failedwith the action error ascause, and the request stays pending.submitActionroutes by the sameneedsApproval.runActionitself does not: a host that offers four eyes callssubmitActionfrom its confirm button and keepsrunActionfor 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.pathis optional; a generic review surface guards it.ChangeDisplaycarrieslabelorlabelKey. Uselabelwhen 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 thenullthat acurrentof “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-uiexists 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.vueand@octabits-io/agent-ui/react/plainrender a proposal and emit aProposalDecision;@octabits-io/agent-uiholds the headless review store.@octabits-io/agent-ledger— the ledger for the apply side; it imports only thePrincipalandReversibilitytypes 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.