Chio/Docs

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

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 surface.

Flow

rendering…
Approval flow: a HITL-flagged guard yields PendingApproval, the kernel persists the request and dispatches to a channel, the human responds with a signed token, the resumed call carries the token, and ApprovalGuard verifies it before the regular guard pipeline runs.

ApprovalRequest

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

crates/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/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; concrete implementations are in-memory and SQLite. The store is responsible for double-resolve protection and replay-token bookkeeping inside its own transaction:

crates/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/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/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

Phase 6.1 contract. Defined in chio-core-types::capability:

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

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:

  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/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/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 cryptographic chain is: signed capability + signed governed intent + signed approval token + signed receipt.


HushSpec snippet

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

Performance 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

Approval & HITL · Chio Docs