Chio/Docs

PlatformGuard Catalog

Approval & HITL

ApprovalGuard pauses constrained calls until an authorized operator signs a token that the resumed request presents.

Verdict shape

The kernel keeps 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.

Flow

rendering…
Approval flow: ApprovalGuard returns PendingApproval, the kernel stores and sends the request, and a resumed call presents a signed approval token before the regular guard pipeline runs.

ApprovalRequest

The serializable record persisted in the store and dispatched to channels:

crates/kernel/chio-kernel/src/approval.rs
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

crates/kernel/chio-kernel/src/approval.rs
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::Allow without touching the store.
  • A constraint fires and no token is presented. Builds an ApprovalRequest, stores it, dispatches to every configured channel, and returns HitlVerdict::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::Approved on GovernedApprovalDecision::Approved or HitlVerdict::Deny on GovernedApprovalDecision::Denied.

What triggers approval

  • Constraint::RequireApprovalAbove { threshold_units } when the bound GovernedTransactionIntent carries a max_amount whose units meet or exceed threshold_units. The constraint without a governed intent: Deny (fail-closed).
  • Constraint::MinimumAutonomyTier(GovernedAutonomyTier::Autonomous) paired with a governed intent. Direct / Delegated tiers pass through without approval.
  • force_approval on 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

If the request needs approval but 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. The store prevents double resolution and maintains replay-token bookkeeping in its own transaction:

crates/kernel/chio-kernel/src/approval.rs
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

crates/kernel/chio-kernel/src/approval.rs
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:

crates/kernel/chio-kernel/src/approval_channels.rs
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

On terminal channel failure (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:

crates/core/chio-core-types/src/capability/governance.rs
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,
}

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:

  1. request_id matches theapproval_id.
  2. governed_intent_hash matches the request's parameter_hash.
  3. The presented approver matches the token's embedded approver and the subject of the call matches the token's subject.
  4. The approver public key is in trusted_approvers.
  5. issued_at ≤ now < expires_at.
  6. Lifetime (expires_at - issued_at) does not exceed MAX_APPROVAL_TTL_SECS = 3600. The single-use replay registry pins to that ceiling, so a longer token cannot be safely tracked.
  7. The Ed25519 signature verifies against the approver key.

Replay protection is per-store

The store records consumed tokens by (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:

crates/kernel/chio-kernel/src/approval.rs
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.

crates/kernel/chio-kernel/src/approval.rs
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.

policy.yaml
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: deny

trusted_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:

rust
let guard = ApprovalGuard::new(store)
    .with_channel(Arc::new(WebhookChannel::new(
        "https://approvals.example/inbox",
    )))
    .with_default_ttl(1800);

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