LearnAnatomy of a Governed Call
Human-in-the-Loop
A policy can require a human signature before a governed action proceeds.
A capability grant can require human approval. The threshold is stored as a RequireApprovalAbove constraint; the approval is a second signature that the receipt chain records alongside the agent's signature. Calls below the threshold proceed under the grant; calls at or above it require a human signature.
Alpha
Three components
HITL has three components:
- Policy-declared approval gate. A HITL policy,
rules.human_in_loopin a HushSpec, compiles to aRequireApprovalAboveconstraint on the matching tool grants. The dedicatedApprovalGuard, evaluated ahead of the generic guard pipeline through the kernel'sevaluate_tool_call_with_hitlentry point, reads that constraint and decides whether a matching request needs a human before it can proceed. - In-flight suspension receipt. When the approval guard returns pending, the kernel signs a receipt with
Decision::Incompletewhose metadata marks the call as awaiting approval, names the approval request id, and records the deadline. The agent receives a"pending approval" response; the trust control plane log records an intermediate state. - Approval callback with a signed token. A human reviews the request through a configured channel or by polling the approval store. Their decision is encoded as a
GovernedApprovalTokensigned with the approver's Ed25519 key. The kernel validates the token, re-runs capability and guard checks, then emits a final Allow or Deny receipt.
State machine
A governed tool call that touches an approval constraint walks the following states:
The priority rule across guards is: any Deny dominates any PendingApproval. If a structural guard such as forbidden-path denies the request, the pipeline does not ask for human approval. Approval is only solicited when the non-approval guards all allow.
Pending verdict
The kernel's Verdict enum carries a third protocol outcome alongside Allow and Deny. The variant is a bare marker: it keeps Verdict's Copy semantics, and the associated ApprovalRequest payload is returned separately through the HITL API (see HitlVerdict below):
pub enum Verdict {
/// The action is allowed.
Allow,
/// The action is denied.
Deny,
/// The action is suspended pending a human decision. Look up the
/// associated `ApprovalRequest` via the HITL API.
PendingApproval,
}A generic Guard cannot drive this flow. The sequential guard loop treats a Guard that returns PendingApproval as an unsupported state and fails closed (denies). Suspension is produced only by the dedicated ApprovalGuard, which does not implement the Guard trait and runs through its own kernel entry point, evaluate_tool_call_with_hitl. Its result type carries the full request:
pub enum HitlVerdict {
/// Guard passes -- no approval required.
Allow,
/// Guard denies without an approval path (e.g. fail-closed).
Deny { reason: String },
/// Approval is required. Kernel persists the request and returns a
/// 202-style response to the caller.
Pending { request: Box<ApprovalRequest>, verdict: Verdict },
/// Approval was supplied with the request and passed verification.
Approved { token: Box<ApprovalToken> },
}Receipt decisions
The signed receipt Decision enum in chio-core-types/src/receipt.rs has four variants. HITL does not add Decision variants. The approval flow reuses these four plus receipt metadata to distinguish states:
pub enum Decision {
/// The tool call was allowed and executed.
Allow,
/// The tool call was denied.
Deny { reason: String, guard: String },
/// The tool call was interrupted by explicit cancellation.
Cancelled { reason: String },
/// The tool call did not reach a complete terminal result.
Incomplete { reason: String },
}Mapping approval lifecycle states onto these four:
- Suspended awaiting approval:
Incompletewith a reason identifying the approval request, and an approval-request id plus deadline carried in receipt metadata. The agent-facing label "IncompleteAwaitingApproval" is a metadata convention, not a new enum variant. - Approved and executed:
Allow, with approver identity, token id, and the referenced approval-request id carried in receipt metadata. - Denied by a human:
Denywithguard = "human-approval"; approver identity and optional reason live in metadata. - Timed out with on_timeout = deny:
Denywithguard = "approval-timeout". - Cancelled before resolution:
Cancelled.
PendingApproval itself is a runtime-only Verdict variant in chio-kernel/src/runtime.rs. It does not appear on a signed receipt; the signed Decision for a suspended call is Incomplete with metadata pointing at the approval request.
Approval request
When a guard returns PendingApproval, it constructs an ApprovalRequest that the kernel will persist and route to channels. The request contains the information for a human decision and for kernel validation of the returned token:
pub struct ApprovalRequest {
/// Unique approval id (UUIDv7). Keys the approval store.
pub approval_id: String,
/// Policy / grant id that triggered the approval.
pub policy_id: String,
/// Calling agent's identifier.
pub subject_id: AgentId,
/// Capability token id bound to this request.
pub capability_id: String,
/// Public key of the capability subject. A presented approval token
/// must carry the same subject.
pub subject_public_key: Option<PublicKey>,
/// Server hosting the target tool.
pub tool_server: ServerId,
/// Tool being invoked.
pub tool_name: String,
/// Short action verb for human summaries (e.g. "invoke", "charge").
pub action: String,
/// SHA-256 hex of the canonical JSON of the tool arguments / intent.
pub parameter_hash: String,
/// Unix seconds after which the request is resolved per `on_timeout`
/// (deny by default).
pub expires_at: u64,
/// Hint for channels about where the human can respond.
pub callback_hint: Option<String>,
/// Unix seconds when the request was created.
pub created_at: u64,
/// Short human-readable summary for dashboards.
pub summary: String,
/// Original governed intent, when one is bound.
pub governed_intent: Option<GovernedTransactionIntent>,
/// Public keys allowed to approve this request. Empty set fails closed.
pub trusted_approvers: Vec<PublicKey>,
/// Guards that triggered the approval requirement.
pub triggered_by: Vec<String>,
}Supported approval triggers
ApprovalGuard::evaluate raises approval through three paths (needs_approval = threshold_hit || tier_hit || always_hit). Two are grant-level constraints on Constraint (see chio-core-types/src/capability/scope.rs); the third is a request attribute:
| Trigger | Fires When |
|---|---|
RequireApprovalAbove { threshold_units: u64 } | The governed intent's max_amount.units meets or exceeds the threshold, measured in minor currency units. A grant with the constraint but no governed intent fails closed. |
MinimumAutonomyTier(Autonomous) | When the request carries a governed intent, an Autonomous tier requirement is treated as "requires human approval"; Direct and Delegated pass through. |
force_approval (request attribute) | A flag on ApprovalContext forces a pending outcome regardless of constraints, letting host integrations and test harnesses enter the HITL flow directly. |
There is no way to route an ordinary guard into this flow. The generic Guard pipeline fails closed on a PendingApproval verdict, so a content-review or secret-leak guard that denies stays a deny: it cannot be reconfigured to suspend for a human.
Missing intent is not a free pass
RequireApprovalAbove is configured but the incoming request does not carry a governed intent, the kernel fails closed. You cannot bypass the threshold by simply omitting the intent.Planned trigger families (not yet shipped)
Constraint enum. Do not rely on them in policy files:RequireApprovalAlways: unconditional human approval for every invocation under a grant.RequireApprovalFirstN: human approval for the first N invocations only.RequireApprovalForActions: human approval when a tool's declared action category matches a listed category (see Action Categories below).RequireApprovalAboveTier: human approval when the governed autonomy tier is at or above a named level.
require_confirmation, which compile to a RequireApprovalAbove threshold of 0 on the matching grants.Action categories (planned)
Design-stage
RequireApprovalForActions trigger described above. They are not yet implemented. This section sketches the intended vocabulary so policy authors can weigh in on the taxonomy before it lands.The target model is that tools self-declare an action category in their manifest, and a future constraint names the categories that require approval. Candidate categories:
Financial: payments, transfers, tradesCommunication: outbound email, SMS, Slack postsInfrastructure: deploys, scale operations, deletesDataMutation: writes, deletes, updates on managed dataSensitiveDataAccess: PII, credentials, keysCustom("name"): declare your own label
Approval flow
Example: a support agent wants to issue a $450 refund, and the grant carries RequireApprovalAbove { threshold_units: 200 }.
- The agent submits a tool call with a governed intent whose
max_amount.units = 450. - The kernel runs guards. Structural guards pass. The approval guard sees the threshold fire and returns
PendingApproval(request). - The kernel persists the request in the approval store, dispatches it to any configured channels (a webhook, or none: a programmatic approver can poll
GET /approvals/pendinginstead), and signs anIncompletereceipt whose metadata marks the call as awaiting approval and carries the approval-request id and deadline. The agent receives a "pending approval" response naming the approval request id and the deadline. - A human reviews the summary, intent, and (optionally) arguments through a channel. They choose approve or deny.
- The channel sends a signed
GovernedApprovalTokentoPOST /approvals/{id}/respond. The kernel validates the signature, the request id binding, the intent hash binding, the approver whitelist, and the expiry. - On approve: the kernel re-runs capability validation (the grant may have been revoked during the wait) and non-approval guards (state may have changed), then dispatches to the tool server and emits an
Allowreceipt. Metadata carries the approver public key, the approval token id, and a reference back to the earlier suspension receipt. - On deny: the kernel emits a
Denyreceipt withguard = "human-approval", carrying the approver's public key and optional reason in metadata.
Approval token
The human's decision is encoded in an GovernedApprovalToken (already defined in chio-core-types::capability and re-used here):
pub struct GovernedApprovalToken {
pub id: String,
pub approver: PublicKey, // Ed25519 key that signed
pub subject: PublicKey, // agent's key
pub governed_intent_hash: String, // binds to one intent
pub request_id: String, // binds to one tool call
pub issued_at: u64,
pub expires_at: u64,
pub decision: GovernedApprovalDecision, // Approved | Denied
pub signature: Signature,
}The token is cryptographically bound to five things: the request id, the intent hash, the approver's key, the agent's key, and a time window. The kernel validates all five before accepting. A token for a different request, a different intent, a different agent, or one outside its time window is rejected.
Replay Protection
Approval tokens are single-use. The kernel combines four mechanisms to enforce this:
- Request binding: a token for request A cannot be replayed against request B.
- Time bounds: outside
[issued_at, expires_at)the token is invalid. - Lifetime cap: the kernel rejects tokens with a lifetime longer than
MAX_APPROVAL_TTL_SECS(one hour) to bound the replay-store window. - Consumption store: an LRU replay store records consumed
(request_id, intent_hash)pairs. A token presented a second time is rejected with "replay detected".
Timeout policies
Every pending approval has a deadline (timeout_seconds). What happens when it passes is set by on_timeout, whose enum (HumanInLoopTimeoutAction) has exactly two variants:
on_timeout | Behavior |
|---|---|
deny | Default. Fail closed: when the deadline passes with no decision, the call is denied, and the kernel signs a deny receipt with guard = approval-timeout. |
defer | Does not fail closed at the deadline; the request is not auto-denied when the deadline elapses and stays pending for a later human decision. |
Deny is the fail-closed default
on_timeout defaults to deny. Leave it there unless your workflow requires requests to remain open past the deadline. Do not treat a lapsed deadline as an implicit allow.Python sketch
The chio_sdk package (PyPI chio-sdk-python) wraps the sidecar's approval endpoints. A host holds a tool call pending approval, an operator lists the queue, and a decision resolves it. The client is async:
import asyncio
from chio_sdk import ChioClient
async def main() -> None:
async with ChioClient("http://127.0.0.1:9090") as chio:
# Host side: hold a tool call pending human approval.
# The parameter hash is derived from the canonical JSON of tool_args.
approval_id = await chio.submit_for_approval(
capability_id="cap-8f1e",
tool_server="payment-server",
tool_name="issue_refund",
tool_args={"customer_id": "cust-9012", "amount": 450, "currency": "USD"},
requested_by="9c7b3f...", # agent Ed25519 public key (hex)
summary="Refund $450 for order #8834",
)
# Operator side: review what is waiting.
for pending in await chio.list_pending_approvals():
print(pending.approval_id, pending.summary, pending.expires_at)
# Resolve it. operator-respond has the sidecar sign the
# GovernedApprovalToken with its own key; use the signed /respond
# route directly when an external approver keypair is required.
await chio.respond_approval(
approval_id,
"approve", # or "deny"; also accepts an ApprovalVerdict value
reason="Verified against the order record.",
)
asyncio.run(main())TypeScript sketch
The TypeScript SDK (@chio-protocol/sdk)'s ChioClient speaks MCP, withStaticBearer then initialize, with tool calls on the returned session, and does not yet wrap the approval endpoints. Until then, call the approval REST API exposed by the sidecar directly:
const sidecar = "http://127.0.0.1:9090";
// Operator side: list what is waiting.
const { approvals } = await fetch(`${sidecar}/approvals/pending`)
.then((r) => r.json());
for (const req of approvals) {
console.log(req.approval_id, req.summary, req.expires_at);
}
// Resolve one. operator-respond has the sidecar sign the
// GovernedApprovalToken with its own key; POST /approvals/{id}/respond
// takes a token signed by an external approver instead.
await fetch(`${sidecar}/approvals/${approvalId}/operator-respond`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ outcome: "approved", reason: "verified" }),
});The calling agent does not block on a pending approval. The host suspends its own workflow (a Temporal signal, a LangGraph interrupt, a Prefect pause) and resumes once an operator resolves the request through one of these endpoints.
Approval channels
A channel presents a request to a human and returns a decision. Chio ships two channel implementations and lets you add your own by implementing the ApprovalChannel trait. A channel is a fire-and-forget sink: if delivery fails, the request stays in the approval store and remains reachable through GET /approvals/pending.
| Channel | What It Does |
|---|---|
WebhookChannel | Blocking HTTP POST of the ApprovalRequest to a configured URL. Production integrations wire this into their own dashboard or ticketing system. |
RecordingChannel | Captures every dispatch in an in-memory ring so tests and host adapters can assert that a dispatch fired without standing up an HTTP listener. |
There is no separate poll channel. A programmatic approver that polls GET /approvals/pending needs no channel at all: the request already lives in the approval store, so the approval store is sufficient.
Configuration
HITL is configured in a HushSpec policy under rules.human_in_loop. The block compiles to a RequireApprovalAbove constraint on the matching tool grants:
rules:
human_in_loop:
enabled: true
# Tool-name globs that always need approval (compile to threshold 0).
require_confirmation: ["write_*", "run_command"]
# Monetary gate, in minor units. Governed calls whose max_amount is at
# or above this value require approval; below it they pass through.
approve_above: 15000
approve_above_currency: "USD"
# Deadline for a pending approval.
timeout_seconds: 900
# deny (default, fail-closed) or defer.
on_timeout: denyThe policy block has six fields: enabled, require_confirmation (globs that collapse to a threshold of 0), approve_above with approve_above_currency, timeout_seconds, and on_timeout. There is no per-grant approver list, per-approver contact block, or escalation-tier config: the trusted-approver set is supplied to the kernel out of band, and quorum and escalation are not part of the shipped policy schema.
Batch approval
Per-call approval creates friction for repetitive operations. Batch approval lets a human pre-approve a class of calls for a bounded window. A BatchApproval (paired with a BatchApprovalStore trait) declares a server pattern, tool pattern, per-call and total monetary ceilings (max_amount_per_call / max_total_amount), a max call count, and a validity window (not_before / not_after), and tracks its own consumption (used_calls, used_total_units, revoked). The kernel consults the batch store before dispatching to channels: if a matching, unexpired batch exists, the call is approved immediately and the batch counters are incremented.
# Examples of batch approval scopes:
"Approve all search calls for the next hour"
server_pattern: "search-server"
tool_pattern: "*"
max_calls: None
not_after: now + 3600
"Approve up to 20 database reads in the next 30 minutes"
server_pattern: "db-server"
tool_pattern: "read_*"
max_calls: Some(20)
not_after: now + 1800
"Approve payments under $100 for 4 hours, max $500 total"
server_pattern: "payment-server"
tool_pattern: "charge"
max_amount_per_call: { units: 100, currency: USD }
max_total_amount: { units: 500, currency: USD }
not_after: now + 14400Each batch carries a batch_id, so a batch-approved call can be tied back to the blanket approval that authorized it. Wiring that id into receipt metadata is not yet part of the kernel's receipt-emission path.
Receipts in the approval chain
Every transition is a signed receipt. An approved call produces a chain of two receipts: an Incomplete receipt at suspension, and an Allow receipt at execution. A denied-by-human call produces a chain of an Incomplete receipt at suspension and a Deny receipt at denial. A timeout under the default on_timeout: deny produces one Incomplete at suspension and one Deny at the deadline. The second receipt's metadata links back to the first by previous_receipt_id.
// Receipt 1: suspended (Decision::Incomplete with approval metadata)
{
"id": "rc-001",
"decision": { "verdict": "incomplete", "reason": "awaiting human approval" },
"metadata": {
"approval_request_id": "ar-d4e5",
"summary": "Agent wants to issue a $450 refund",
"deadline": 1713200400
}
}
// Receipt 2: approved and dispatched (Decision::Allow)
{
"id": "rc-002",
"decision": { "verdict": "allow" },
"metadata": {
"approval_request_id": "ar-d4e5",
"approval_token_id": "at-g6h7",
"approver": "9c7b3f...",
"approval_latency_ms": 127000,
"channel": "webhook",
"previous_receipt_id": "rc-001"
}
}Auditing approval activity
The Receipt Query API exposes filters that make it easy to audit HITL activity. Common queries:
# All suspended calls since a Unix timestamp. Incomplete receipts
# carry the approval request in metadata. --since / --until take Unix
# seconds. The read fails closed unless you pass --admin-all (all
# tenants) or --tenant <id>. Output is JSON Lines: one receipt per line.
chio receipt list --outcome incomplete --since 1713200400 --admin-all
# All denied calls. The guard field in each receipt body distinguishes
# human-approval denials from approval-timeout denials.
chio receipt list --outcome deny --admin-all
# Approved calls, for latency or approver analysis. There is no stats
# subcommand; pipe the JSON Lines output through jq.
chio receipt list --outcome allow --admin-all \
| jq '.metadata.approval_latency_ms'Security properties
- Fail-closed at every step. Channel dispatch failure, invalid signature, expired token, wrong request, capability revoked during wait, or timeout with no response: all produce a deny receipt.
- Kernel-native. The agent never sees the approval token and never talks to the approver directly. The kernel owns the lifecycle, which is why the approval guard runs in the kernel pipeline.
- Non-repudiation. Every decision is signed with the approver's key and recorded in the receipt chain. The chain proves who approved, what they approved, when, and through which channel.
- Separation of concerns. Approvers see only the summary and intent, not raw arguments, unless the policy explicitly exposes them. Tool servers see only validated approved calls; they never learn that HITL was in the loop.
Adopting HITL in an existing deployment
The rollout sequence we recommend:
- Start with
RequireApprovalAboveon a single high-value tool. Pick a threshold that will trigger approvals for only the top few calls per day. - Wire one channel first, a
WebhookChannelinto your dashboard or ticketing system, or start with pollingGET /approvals/pendingand no channel. Add delivery channels incrementally; each adds another path for review. - Keep
on_timeout: deny(the fail-closed default) for the first month. Verify the team is meeting the SLA before consideringon_timeout: defer. - Add batch approval once the team is confident with per-call review. Batch policies are harder to reason about; do not reach for them first.
- Once batch approval is settled, broaden coverage with
require_confirmationglobs for the tool families you want gated (they compile to a threshold of 0). Action-category constraints remain at the design stage; see Supported Approval Triggers and Open Design Questions.
Monitor approval outcomes
Open design questions
These items are on the roadmap but not yet in the stable protocol:
- Multi-approver quorum. "2 of 3 must approve" policies for high-value operations. The kernel enforces single-approver today, but two schema slots for this already exist without enforcement:
Constraint::RequireDualApproval(bool)is present on the constraint enum but is excluded from governed-requirements and always evaluates toOk(false)in request-matching (an inert stub); and a HushSpec extension slot,chio.human_in_loop.approvers = { n, of: [...], timeout_seconds }(ChioApproverSet), declares an n-of-M shape that the kernel does not interpret: it is carried verbatim for chio-bridge consumers. - Approval delegation. Time-bounded handoff of approval authority (vacation coverage) reusing the existing delegation link mechanism.
- Partial approval. A human approves an amended version of the request (approve the refund for $300 instead of $450), requiring the token to carry modified parameters that the kernel re-binds into the intent.
- Cross-kernel approval. In federated deployments, whether an approval from one kernel can satisfy a pending request on another.
Summary
- HITL adds a third verdict to the guard pipeline:
PendingApproval, alongside Allow and Deny. Deny dominates; approval is solicited only when non-approval guards allow. - The request is suspended in a signed
Decision::Incompletereceipt whose metadata carries the approval request id and deadline, and routed to human-facing channels. - The human's decision is a signed
GovernedApprovalTokenbound to the request, intent, approver, agent, and a time window, with replay protection. - On approve the kernel re-validates capability and guards before dispatch, then emits an
Allowreceipt whose metadata identifies the approver and token. On deny it signs aDenyreceipt withguard = "human-approval". On timeout it follows the configured policy. - The protocol is alpha; expect additions for quorum, delegation, partial approval, and cross-kernel approval.