Approval & HITL
Human-in-the-loop is a first-class verdict in the chio kernel. The guard pipeline yields Verdict::PendingApproval when an operator must sign off, the request hands back to the caller with a 202-style response, the operator decides through a channel, and a signed GovernedApprovalToken rides the resumed call. Source: crates/chio-kernel/src/approval.rs and crates/chio-kernel/src/approval_channels.rs.
Verdict shape
Verdict Copy by lifting the approval payload into a sibling type: HitlVerdict from approval.rs. The public verdict is a marker Verdict::PendingApproval; callers that need the request payload pull it out of the richer HITL surface.Flow
ApprovalRequest
The serialisable record persisted in the store and dispatched to channels:
pub struct ApprovalRequest {
pub approval_id: String, // UUIDv7 in production
pub policy_id: String,
pub subject_id: AgentId,
pub capability_id: String,
pub subject_public_key: Option<PublicKey>,
pub tool_server: ServerId,
pub tool_name: String,
pub action: String, // e.g. "invoke", "charge"
pub parameter_hash: String, // SHA-256 of canonical JSON
pub expires_at: u64,
pub callback_hint: Option<String>,
pub created_at: u64,
pub summary: String,
pub governed_intent: Option<GovernedTransactionIntent>,
pub trusted_approvers: Vec<PublicKey>,
pub triggered_by: Vec<String>,
}parameter_hash is computed with compute_parameter_hash over the canonicalised envelope of {server_id, tool_name, arguments, governed_intent}. A mutated argument payload after approval will not satisfy the original token because the hash binds the decision to that exact parameter set.
HitlVerdict
pub enum HitlVerdict {
/// Guard passes: no approval required.
Allow,
/// Guard denies without an approval path (fail-closed).
Deny { reason: String },
/// Approval is required. Kernel returns 202; request stays in the store.
Pending {
request: Box<ApprovalRequest>,
verdict: Verdict, // Verdict::PendingApproval
},
/// Approval was supplied with the request and passed verification.
Approved { token: Box<ApprovalToken> },
}The Pending and Approved variants box their payloads so the enum stays cheap to pass by value (clippy large_enum_variant).
ApprovalGuard
The HITL guard runs before the generic guard pipeline. It looks at the matched grant's constraints and decides one of three things:
- No constraint fires. Returns
HitlVerdict::Allowwithout touching the store. - A constraint fires and no token is presented. Builds an
ApprovalRequest, stores it, dispatches to every configured channel, and returnsHitlVerdict::Pending. Channel dispatch failures are logged but the pending row stays in place (fail-closed: a delivery outage cannot quietly drop the request). - A constraint fires and a token is presented. Looks up the pending or resolved record, runs replay and binding checks, verifies the signature, then returns
HitlVerdict::ApprovedonGovernedApprovalDecision::ApprovedorHitlVerdict::DenyonGovernedApprovalDecision::Denied.
What triggers approval
Constraint::RequireApprovalAbove { threshold_units }when the boundGovernedTransactionIntentcarries amax_amountwhoseunitsmeet or exceedthreshold_units. The constraint without a governed intent: Deny (fail-closed).Constraint::MinimumAutonomyTier(GovernedAutonomyTier::Autonomous)paired with a governed intent.Direct/Delegatedtiers pass through without approval.force_approvalon the context. Used by integration tests and by host adapters that decided out-of-band that the call needs human sign-off.
Empty trusted_approvers fail-closes
ApprovalContext::trusted_approvers is empty, the guard returns HitlVerdict::Deny with "approval required but no trusted approvers are configured". A grant that requires approval must declare who can give it.ApprovalStore
Persistent contract for pending and resolved approvals. Sync trait because the kernel hot path is sync; concrete implementations are in-memory and SQLite. The store is responsible for double-resolve protection and replay-token bookkeeping inside its own transaction:
pub trait ApprovalStore: Send + Sync {
fn store_pending(&self, request: &ApprovalRequest)
-> Result<(), ApprovalStoreError>;
fn get_pending(&self, id: &str)
-> Result<Option<ApprovalRequest>, ApprovalStoreError>;
fn list_pending(&self, filter: &ApprovalFilter)
-> Result<Vec<ApprovalRequest>, ApprovalStoreError>;
fn resolve(&self, id: &str, decision: &ApprovalDecision)
-> Result<(), ApprovalStoreError>;
fn count_approved(&self, subject_id: &str, policy_id: &str)
-> Result<u64, ApprovalStoreError>;
fn record_consumed(&self, token_id: &str, parameter_hash: &str, now: u64)
-> Result<(), ApprovalStoreError>;
fn is_consumed(&self, token_id: &str, parameter_hash: &str)
-> Result<bool, ApprovalStoreError>;
fn get_resolution(&self, id: &str)
-> Result<Option<ResolvedApproval>, ApprovalStoreError>;
}InMemoryApprovalStore is the reference implementation. It keeps three maps under RwLock / Mutex: pending requests, resolved rows, and a consumed-token set keyed on token_id ":" parameter_hash. SQLite is the production path; the trait stays sync so both backends share one call site.
ApprovalStoreError
pub enum ApprovalStoreError {
NotFound(String),
AlreadyResolved(String),
Replay(String),
Backend(String),
Serialization(String),
}ApprovalChannel
A channel ships an ApprovalRequest to a human. The trait is sync; channels that need async I/O run a small dedicated runtime or block via ureq. Two channels ship in-tree:
WebhookChannel
Blocking HTTP POST to a configured endpoint. Default timeout 5s. Optional static auth header (HMAC, bearer). The payload is a stable JSON envelope:
pub struct WebhookPayload<'a> {
pub event: &'static str, // "approval_requested"
pub approval: &'a ApprovalRequest,
pub callback_url: String, // /approvals/{id}/respond
}RecordingChannel
In-memory channel used by tests and the api-poll dispatch mode. Captures every dispatched request in an in-memory ring; tests assert on captured() without standing up an HTTP listener.
Dispatch failures stay pending
ChannelError::Transport, ::Remote, or ::Config) the kernel records a tracing::warn! and leaves the row pending. Operators can still serve it via GET /approvals/pending.GovernedApprovalToken
Phase 6.1 contract. Defined in chio-core-types::capability:
pub enum GovernedApprovalDecision {
Approved,
Denied,
}
pub struct GovernedApprovalTokenBody {
pub id: String,
pub approver: PublicKey,
pub subject: PublicKey,
pub governed_intent_hash: String, // matches ApprovalRequest.parameter_hash
pub request_id: String, // matches ApprovalRequest.approval_id
pub issued_at: u64,
pub expires_at: u64,
pub decision: GovernedApprovalDecision,
}The signed token is what makes the approval portable. A receiver can verify the decision without trusting the kernel that issued the request: the approver signs {request_id, governed_intent_hash, subject, decision, expires_at} with their key, the kernel matches the signature against the request's trusted_approvers list, and the receipt records the approver's public key for non-repudiation.
Token binding checks
ApprovalToken::verify_against runs these checks in order, all fail-closed:
request_idmatches theapproval_id.governed_intent_hashmatches the request'sparameter_hash.- The presented approver matches the token's embedded approver and the subject of the call matches the token's subject.
- The approver public key is in
trusted_approvers. issued_at ≤ now < expires_at.- Lifetime
(expires_at - issued_at)does not exceedMAX_APPROVAL_TTL_SECS = 3600. The single-use replay registry pins to that ceiling, so a longer token cannot be safely tracked. - The Ed25519 signature verifies against the approver key.
Replay protection is per-store
(token_id, parameter_hash). Two parallel kernels with separate stores cannot enforce single-use across the pair. Production deployments that serve approvals from more than one kernel use a shared SQLite or other durable backend so a token consumed on one node is rejected on the other.Resume flow
The async resume entry point is resume_with_decision:
pub fn resume_with_decision(
store: &dyn ApprovalStore,
decision: &ApprovalDecision,
now: u64,
) -> Result<ApprovalOutcome, KernelError>;Used by the HTTP layer behind POST /approvals/{id}/respond. It validates that the HTTP envelope's outcome matches the signed-token decision before mutating the store; otherwise a mismatched pair would already have flipped resolved=true and corrupted threshold counters.
Batch approvals
A batch approval lets a human pre-approve a class of calls. BatchApproval carries a signed approver, a server / tool pattern (* wildcard or prefix), an amount cap per call and total, a call-count cap, time bounds, and used counters. The store is BatchApprovalStore.
pub struct BatchApproval {
pub batch_id: String,
pub approver_hex: String,
pub subject_id: AgentId,
pub server_pattern: String,
pub tool_pattern: String,
pub max_amount_per_call: Option<MonetaryAmount>,
pub max_total_amount: Option<MonetaryAmount>,
pub max_calls: Option<u32>,
pub not_before: u64,
pub not_after: u64,
pub used_calls: u32,
pub used_total_units: u64,
pub revoked: bool,
}find_matching on the store returns the first non-revoked batch whose subject, server pattern, tool pattern, time window, call-count remaining, and amount fit the incoming call. record_usage is the bookkeeping side.
Receipt evidence
On the resumed call the receipt records the approver public key, the token id, the approval id, and the parameter hash. The receipt does not embed the full webhook payload; that lives on the channel's SIEM stream. The cryptographic chain is: signed capability + signed governed intent + signed approval token + signed receipt.
HushSpec snippet
hushspec: "0.1.0"
guards:
approval:
enabled: true
default_ttl_secs: 1800
trusted_approvers:
- "ed25519:0123456789abcdef..."
- "ed25519:fedcba9876543210..."
channels:
- kind: webhook
endpoint: "https://approvals.example/inbox"
timeout_secs: 5
header:
name: "x-approval-hmac"
value: "${env.APPROVAL_HMAC}"
- kind: recording
grants:
- id: "ops-payments"
tools: ["payment.charge"]
constraints:
- require_approval_above:
threshold_units: 50_000 # $500.00
- minimum_autonomy_tier: autonomousPerformance class
On a non-HITL call the guard short-circuits after scanning grant constraints (O(C)). On a token-presenting call: one store lookup, one canonical-JSON hash, a fixed set of binding compares, one Ed25519 verify. On a fresh approval: one canonical-JSON hash, one store insert, N channel dispatches in series. Webhook latency dominates the wall clock; the guard does not block on channel completion beyond what the channel itself does.
Next steps
- Computer Use Agent Guards for action surfaces that often pair with approval thresholds.
- Fail-Closed Semantics for the broader invariants approval inherits.
- Memory Governance for resource ceilings that complement HITL on long-running flows.