PlatformSession & Approval Guards
Kernel
Approval & HITL
ApprovalGuard pauses constrained calls until an authorized operator signs a token that the resumed request presents.
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 API. The Three Verdicts owns that enum: which components may construct the third value, and what every other runner does when it sees one.Flow
ApprovalRequest
The serializable record persisted in the store and dispatched to channels:
pub struct ApprovalRequest {
/// Unique request identifier. Caller-stable so the approval store
/// can be keyed on this value. Callers should supply a UUIDv7.
pub approval_id: String,
/// The policy / grant identifier that triggered the approval.
pub policy_id: String,
/// The 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 this approval is bound to.
/// A presented approval token must carry the same subject.
#[serde(default, skip_serializing_if = "Option::is_none")]
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 digest of the canonical JSON of the tool arguments
/// / governed intent. Used to bind an approval token to this exact
/// parameter set; a mutated argument payload will not satisfy the
/// same approval.
pub parameter_hash: String,
/// Unix seconds after which the request auto-denies (or escalates,
/// per `timeout_action` in the grant).
pub expires_at: u64,
/// Hint for channels about where the human can respond (e.g. the
/// URL of the dashboard or a Slack permalink). `None` means
/// "dispatcher will fill this in after sending".
#[serde(default, skip_serializing_if = "Option::is_none")]
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. Required for
/// threshold-based approvals so the approver sees the financial
/// envelope they are signing off on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub governed_intent: Option<GovernedTransactionIntent>,
/// Public keys allowed to approve this request. The kernel fails
/// closed when the set is empty or when the presented approver is
/// not in the set.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trusted_approvers: Vec<PublicKey>,
/// Guards that triggered the approval requirement.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
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 (e.g. fail-closed).
Deny { reason: String },
/// Approval is required. Kernel should persist the request and
/// return 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> },
}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; implementations use memory or SQLite. Approval Store & Channels owns that half in full: the tables on one node’s disk, the resolve transaction, the channel dispatch path, and the batch and threshold collectors. The store prevents double resolution and maintains replay-token bookkeeping in its own transaction:
pub trait ApprovalStore: Send + Sync {
/// Persist a new pending request. Idempotent on `approval_id`: a
/// second call with the same id returns without error as long as
/// the stored payload matches.
fn store_pending(&self, request: &ApprovalRequest) -> Result<(), ApprovalStoreError>;
/// Fetch a single pending approval by id.
fn get_pending(&self, id: &str) -> Result<Option<ApprovalRequest>, ApprovalStoreError>;
/// List all pending approvals matching the filter.
fn list_pending(
&self,
filter: &ApprovalFilter,
) -> Result<Vec<ApprovalRequest>, ApprovalStoreError>;
/// Mark a pending approval as resolved. Returns
/// `ApprovalStoreError::AlreadyResolved` if the request has already
/// been resolved (double-resolve protection) and
/// `ApprovalStoreError::Replay` if the bound token has already been
/// consumed on a different request.
fn resolve(&self, id: &str, decision: &ApprovalDecision) -> Result<(), ApprovalStoreError>;
/// Count approved calls for a given subject / grant pair. Used by
/// `Constraint::RequireApprovalAbove` threshold accounting.
fn count_approved(&self, subject_id: &str, policy_id: &str) -> Result<u64, ApprovalStoreError>;
/// Record that a token (by `token_id` and `parameter_hash`) has
/// been consumed. Used to reject replays of the same approval
/// token across a restart. Implementations may also call this from
/// [`resolve`]; exposing it on the trait lets the kernel do the
/// replay check before persisting the resolution, which matters
/// when the store is backed by SQLite and wants to run the check
/// inside the transaction.
fn record_consumed(
&self,
token_id: &str,
parameter_hash: &str,
now: u64,
) -> Result<(), ApprovalStoreError>;
/// Returns `true` if the token has already been consumed.
fn is_consumed(&self, token_id: &str, parameter_hash: &str)
-> Result<bool, ApprovalStoreError>;
/// Fetch the resolution record for a previously resolved approval.
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,
pub approval: &'a ApprovalRequest,
pub callback_url: String,
}The channel fills event with approval_requested and callback_url with /approvals/{id}/respond (approval_channels.rs:74).
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
The governance-token contract. Defined in chio-core-types::capability:
pub enum GovernedApprovalDecision {
Approved,
Denied,
}The signable body binds one approver, one subject, one intent hash and one request id:
pub struct GovernedApprovalTokenBody {
pub id: String,
pub approver: PublicKey,
pub subject: PublicKey,
pub governed_intent_hash: String,
pub request_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_proposal_hash: Option<String>,
pub issued_at: u64,
pub expires_at: u64,
pub decision: GovernedApprovalDecision,
}A receiver verifies the decision from the approver's signature and the request binding. The receiver does not need to trust the kernel that created 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 receipt binds the signed capability, governed intent, and approval token to the recorded decision.
HushSpec snippet
HushSpec configures HITL with the human_in_loop rule, nested directly under the flat top-level rules: key. It compiles to Constraint::RequireApprovalAbove on the matching tool grants; tools matched by require_confirmation collapse the threshold to 0, so those tools require approval at any amount.
hushspec: "0.1.0"
rules:
human_in_loop:
enabled: true
require_confirmation: ["payment.charge"]
approve_above: 50000 # $500.00, integer minor units
approve_above_currency: "USD"
timeout_seconds: 900
on_timeout: denytrusted_approvers, channels, and the default TTL are not HushSpec fields. They are configured on the Rust ApprovalGuard directly, and trusted_approvers is supplied per call via ApprovalContext::trusted_approvers:
let guard = ApprovalGuard::new(store)
.with_channel(Arc::new(WebhookChannel::new(
"https://approvals.example/inbox",
)))
.with_default_ttl(1800);Threshold approval, and why it refuses to compile
Everything above is one approver deciding one call. A threshold requirement is different: it names a set of people and a number, and it is declared in the policy rather than configured on the guard.
extensions:
chio:
human_in_loop:
approvers:
n: 2
of: ["ops-lead@example.com", "security@example.com"]
timeout_seconds: 1800 # optionalChioApproverSet is deny_unknown_fields over exactly those three: n, of, and an optional timeout_seconds that falls back to DEFAULT_THRESHOLD_APPROVAL_TIMEOUT_SECONDS, fifteen minutes.
The interesting part is what happens next. A policy carrying that block validates, and then fails to compile unless the caller supplied an approver directory. Two entry points do: compile_policy_with_approver_directory and compile_policy_with_source_and_approver_directory. The plain compile_policy does not, so a policy that looks fine in review refuses at compile time rather than silently dropping the requirement.
Resolution then has four ways to refuse, all of them CompileError::Invalid:
| Refusal | What it catches |
|---|---|
threshold approvers require an authenticated approver directory | No directory was supplied. Approver identifiers would otherwise be strings nobody vouched for. |
threshold approver `{id}` could not be resolved | The directory has no entry for a named approver. The directory's own error is appended. |
threshold approver directory changed identifier `{id}` | The directory answered with a different identifier than the one asked for. This is the substitution check, and it is the one worth understanding: without it a directory could quietly swap who is on the approver set. |
threshold approvers did not resolve from one versioned directory | A resolved identity carried an empty directory version, or two approvers came from different versions. The whole set has to come from one snapshot. |
What survives compilation is a ThresholdApprovalRequirement carrying the policy hash, the threshold, the resolved identities with their public keys, the directory version they all came from, and the timeout. The identifiers in the policy file are not what the kernel checks against later; the keys the directory returned are. Policy compilation covers the pass this runs in.
Performance class
A non-HITL call scans grant constraints (O(C)). A token-presenting call performs one store lookup, one canonical-JSON hash, a fixed set of binding comparisons, and one Ed25519 verification. Creating an approval performs one canonical-JSON hash, one store insert, and N sequential channel dispatches. Webhook latency dominates; 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.