Approvals

requiresApproval — a mutation or action that needs a second human before it takes effect, with the pending intent in a durable row and self-approval refused.

A mutation can declare that one person is not enough:

// apps/api/mutations/invoices.refund.mutation.ts
import { defineMutation } from '@voltro/protocol'
import { Schema } from 'effect'

export const refundInvoice = defineMutation({
  name: 'invoices.refund',
  input:  Schema.Struct({ invoiceId: Schema.String, amountCents: Schema.Number }),
  output: Schema.Struct({ ok: Schema.Boolean }),
  // The requester still has to be allowed to ASK.
  guards: [{ scope: 'invoices:refund' }],
  requiresApproval: {
    approvers: [{ scope: 'invoices:approve' }],
    expiresIn: '4h',
    reason: 'refunds move money out of the account',
  },
})

That is the whole declaration. The framework does the rest: the first call is recorded and refused, a second human decides, and the identical call then succeeds exactly once.

Human-in-the-loop already existed inside a durable workflow (awaitSignal, AI-Flows' human step). This is the same idea for an ordinary rpc call — no workflow around it, no status column to hand-roll, and the "who may approve" rule expressed in the same guards: vocabulary as everything else.

What the caller sees

The first call does not run. It fails with a typed ApprovalRequired:

import { errorTag } from '@voltro/protocol'

const result = await refund({ invoiceId, amountCents })
// throws:
// {
//   _tag: 'ApprovalRequired',
//   approvalId: 'apv_01j…',
//   procedure: 'invoices.refund',
//   expiresAt: '2026-08-12T14:00:00.000Z',
//   requiredScopes: ['invoices:approve'],
//   reason: 'refunds move money out of the account',
//   created: true,   // false when an earlier identical call already asked
// }

It is a typed failure rather than a success with a status field on purpose: a mutation that returned its normal output shape when nothing happened is the easiest thing in the world for a client to mis-handle, and every client already branches on _tag.

For a mutation the transaction never opens. For an action the executor's external I/O never happens — which is the only point at which nothing has happened yet, since there is no rollback for an outbound HTTP call.

The two built-in procedures

__voltro.approvals.pending is a reactive query over the approvals table, so both sides of the exchange are live with no polling:

import { useSubscription, useMutation } from '@voltro/client'
import type { PendingApproval } from '@voltro/protocol'

export const ApprovalQueue = () => {
  const { data } = useSubscription<ReadonlyArray<PendingApproval>>(
    'app', '__voltro.approvals.pending', {},
  )
  const decide = useMutation('app', '__voltro.approvals.decide')

  return (
    <ul>
      {(data ?? []).map((a) => (
        <li key={a.id}>
          {a.procedure}{a.relation === 'to-decide' ? 'awaiting you' : `you asked · ${a.status}`}
          {a.relation === 'to-decide' && (
            <button onClick={() => decide.mutate({ approvalId: a.id, decision: 'approve' })}>
              Approve
            </button>
          )}
        </li>
      ))}
    </ul>
  )
}

Each row carries a relation: 'to-decide' (you may act on it) or 'requested' (you asked for it). The requester watches their own row flip pending → approved and re-fires the mutation; the approver's queue appears without a refresh.

The feed is scoped in the handler, not by a descriptor guard — a row appears only if you requested it or satisfy its recorded approver scopes, so an anonymous caller sees an empty list. There is no scope that means "may see my own approval work", and inventing one would be a guard that reads as protection and enforces nothing.

Where the pending intent lives, and why its identity matters

Between the request and the decision the intent is a row in _voltro_approvals — durable, so it survives a restart, a rolling deploy and a replica switch.

Its identity is content-addressed: a digest over the procedure, the requester, the tenant, the canonicalised input, and an optional caller nonce. Both directions of getting that wrong are real bugs:

  • too coarse (keying on the procedure, say) and two different pending intents share one row, so approving one executes the other's payload;
  • too fine (a fresh id per attempt) and every page refresh, client re-send or transaction replay mints a second approval, asking the human twice for one decision.

Content-addressing is the only spelling that is stable across a retry and distinct across intents. A UNIQUE on that key enforces at most one live intent per content. Two deliberately identical requests — the same user really does want to refund the same invoice twice — are expressible by passing a different nonce, which is a decision you state rather than one the framework guesses.

An approval is consumed when it admits a call. A replay after that is a new request, not a free second execution.

The refusals

These are the point of the feature, so they are worth reading as a list.

Self-approval is refused, unconditionally. There is no opt-out flag. The whole content of "a second human" is that it is a second one, and a framework that shipped allowSelfApproval: true would be shipping a control every team turns off under deadline pressure — with the audit row still reading "approved".

The check runs before the authority check, deliberately: a requester who happens to hold the approver scope is told they cannot approve their own request, which is the accurate reason, instead of being quietly let through.

An unauthorised approver gets ApprovalForbidden, naming the scope they lack. The check uses the same guard evaluator the dispatch spine runs, against the scopes recorded on the row — including a resource-scoped guard's resolved resource id, so approvers: [{ scope: 'invoices:approve', resource: (i) => i.invoiceId }] stays scoped to that invoice at decision time rather than widening into a global scope check.

An anonymous decider is refused. Every anonymous caller compares equal to every other, so the identity the control rests on does not exist. For the same reason, requiresApproval combined with openAccess: is refused at declaration.

Expiry fails closed. Past expiresAt the intent can be neither approved nor executed — including an intent that was approved and then aged out before the requester came back. The requester re-submits and a fresh decision is asked for.

One intent, one verdict. A second decision on the same intent gets ApprovalNotPending.

A rejection is reported to the requester once, as ApprovalRejected, on their next attempt. A further attempt after that opens a genuinely new decision — a rejection is a verdict on one request, not a permanent ban on the operation.

Declaration-time refusals

Two shapes read like a control and enforce nothing, so they throw where you can still see both fields:

// ✗ nobody can approve this — every call would park forever
requiresApproval: { approvers: [] }

// ✗ an unauthenticated requester has no identity, so the self-approval
//   refusal cannot compare anything and the control degrades to nothing
openAccess: 'public', requiresApproval: { approvers: [{ scope: 'x' }] }

Expiry as a tunable

Precedence: the descriptor's own expiresIn, then the app default, then the environment, then 24 hours.

// app.config.ts
export default {
  approvals: { expiresIn: '4h' },
}

VOLTRO_APPROVAL_EXPIRY_HOURS overrides the built-in default. There is deliberately no "never expires": an approval queue with no floor is a list of decisions nobody made.

Agent tools

exposeAsTool: { confirm: true } used to be a report — the inventory showed it and nothing enforced it. It is now backed by this primitive: a confirm tool is mountable only if its descriptor also declares requiresApproval, and then the agent's call parks in your approval queue and returns ApprovalRequired to the model. See Agent tools.

The table

_voltro_approvals is created for every app (it is one small table, and the alternative would be a surprise CREATE TABLE on the production boot after somebody adds requiresApproval: to a mutation). It is bounded by the retention sweep — 30 days by default, VOLTRO_APPROVALS_TTL_HOURS to change it.