Core concepts /Human approval

Human approval

When a policy decision is require_approval, the action pauses. A person reviews it and co-signs with a key held on their own device, which lifts the receipt to L1. The cloud never holds a signing key.

Most actions are decided by policy alone. Human approval is the fourth decision: require_approval routes the matched action to one or more people and pauses it until someone signs off. The sign-off is a real Ed25519 signature from the approver, not a checkbox in a dashboard.

What a receipt proves

A receipt proves you authorized an action under your policy. It does not prove the action succeeded downstream.

The flow

A rule with decision = "require_approval" turns one gated call into a two-phase sequence:

  1. Suspend. The action does not run. The SDK pauses it at the gate and stamps a captured (L0) receipt.
  2. Route. The action goes to the people named in the rule’s approvers field, with an optional sla_minutes deadline.
  3. Decide. An approver returns one of three verdicts: approve, reject, or escalate.
  4. Finalize. On approval, the receipt gets a second signature from the approver and re-mints to L1. A rejection stops the action; an escalation hands it on.

This is also the fallback for dangerous lanes that no rule matched. An unmatched payment, delete, account_change, or data_export carries a built-in floor and falls through to require_approval rather than running unattended. A policy can tighten one of these lanes but never bypass it.

Where a human approves

An approver clears the gate in the web console. They open the pending action and approve it; their browser co-signs the action’s canonical bytes with a per-device key, and the private half never leaves the browser. The cloud only relays the resulting signature back to your operator. It holds no signing key, so it cannot mint an approval.

Today the per-device key is a non-extractable WebCrypto key. The signing interface is shaped for WebAuthn, so a platform authenticator or hardware key can replace it later. See client approval trust for what the browser leg guarantees.

One approver or a quorum

Single approver: the first person to sign clears the gate, and the receipt becomes L1.

k-of-n quorum: a rule can require several sign-offs, like 2-of-3. Each person co-signs their own leg, and the receipt carries a multi_approval block instead of a single approver record. A quorum receipt is still L1, not a higher level. See the two shapes of L1 for the detail.

Handling it in the SDK

A gated action that routes to a human suspends instead of running. Catch the suspend, save the action hash, then finalize out of band once a decision exists. Never finalize inside the tool call itself.

Python

A gated call raises SuspendedError carrying action_hash.

gate.pypython
import heso

heso.init()

@heso.tool(verb="payment")
def transfer_funds(amount_usd: float, payee: str):
    return bank.transfer(amount_usd, payee)

try:
    transfer_funds(amount_usd=4200, payee="Globex LLC")
except heso.SuspendedError as err:
    # Routed to a human. Save err.action_hash to resume out of band.
    action_hash = err.action_hash

Resume out of band with heso.resume(action_hash). For a quorum, collect each approver’s receipt and call heso.finalize_quorum(receipts, threshold) (or heso.finalize_l1(receipts) for a single approver). Use heso.append_decision(...) to record an approver verdict against the suspended action.

resume.pypython
# Out of band, once a decision exists for action_hash.
outcome = heso.resume(action_hash)

if outcome is heso.DENIED:
    raise RuntimeError("approver rejected the action")

# Approved: the receipt is now L1 (operator + approver co-signature).
# For a k-of-n rule, collect each approver's receipt and finalize the quorum.
l1 = heso.finalize_quorum(receipts, threshold=2)

Node / TypeScript

A refused gate throws SuspendedError with actionHash.

gate.tsts
import { init, engine, SuspendedError } from "@hesohq/sdk"

init()

try {
  engine.gate("transfer_funds", { amountUsd: 4200, payee: "Globex LLC" }, { verb: "payment" })
  await transferFunds({ amountUsd: 4200, payee: "Globex LLC" })
} catch (err) {
  if (err instanceof SuspendedError) {
    // Routed to a human. err.actionHash keys the out-of-band finalize below.
  }
}

Poll with pollApproval(actionHash) or block with waitForApproval(actionHash) until someone decides, then re-mint the L1 receipt with finalizeL1(suspendedContent, parts). If a reviewer signs in their own browser, submit their signed token with submitApprovalToken(actionHash, input).

resume.tsts
import { waitForApproval, finalizeL1 } from "@hesohq/sdk"

// Second phase, out of band. actionHash came from the SuspendedError.
const resolved = await waitForApproval(actionHash)
if (resolved.status === "approved" && resolved.parts) {
  // Re-mint the L1 receipt off the byte-exact suspended content, then verify.
  const l1 = await finalizeL1(resolved.parts.suspendedContent, resolved.parts)
}
Key rotation fails closed

If the operator key rotates between suspend and finalize, the SDK re-suspends the action under the new key rather than minting a receipt under a stale one. There is no silent bad mint.

Routing and SLA

Both routing fields live on the rule that triggered the approval.

approversstring[]required
Labels for the roles or people a routed action can go to. Required when decision is require_approval. The first to sign clears the gate.
sla_minutesnumber
Optional deadline for a decision. When the SLA lapses, the action can escalate to a fallback rather than sit silently.
heso.tomltoml
[[rule]]
id          = "large-payments"
order       = 10
verb        = "payment"
scope       = "*"
decision    = "require_approval"
approvers   = ["payments-oncall", "finance-lead"]
sla_minutes = 30
conditions  = [
  { field = "amount_usd", op = "gt", value = 1000, display = "amount over $1,000" },
]

See policy for how rules match and how floors constrain dangerous lanes.

Approver verdicts

An approver returns exactly one of three verdicts:

  • approved — with a passing signature, lifts the receipt to L1.
  • rejected — stops the action; no L1 receipt is minted.
  • escalated — hands the decision to a fallback approver.

The verdict lands in the receipt’s content.approver_decision, and the approver’s Ed25519 signature lands in signatures under key_id: "approver". See action receipts for the full envelope.

The approval token

Under the hood, an approver clears the gate with an approval token: an Ed25519 signature bound to the action’s canonical bytes. Binding to the action is what makes the approval non-transferable. It cannot be lifted off one action and replayed on another, because the bytes would no longer match.

bash
# Ed25519 approval token (what the approver returns)
nonce         32 bytes
expiry        8 bytes   (unix seconds)
scope         length-prefixed bytes
signature     64 bytes  (Ed25519 over the action's canonical bytes + this token)
public_key    32 bytes  (the approver's Ed25519 key)

The action bytes are not repeated on the wire; the verifier already has them from the receipt. A receipt’s trust level is re-derived on every verify from the signatures that actually pass, so a receipt cannot claim L1 without a valid approver signature. See trust levels for how L0 and L1 are derived, and verification for the canonicalization.

Next steps