Chio/Docs
LOGIN · JOIN

PlatformSession & Approval Guards

Kernel

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. The Three Verdicts owns that enum: which components may construct the third value, and what every other runner does when it sees one.

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.rs44-109rust
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

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

crates/kernel/chio-kernel/src/approval.rs299-345rust
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

crates/kernel/chio-kernel/src/approval.rsrust
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.rs28-32rust
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

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.rs983-986rust
pub enum GovernedApprovalDecision {
    Approved,
    Denied,
}

The signable body binds one approver, one subject, one intent hash and one request id:

crates/core/chio-core-types/src/capability/governance.rs990-1001rust
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:

  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.rsrust
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.rsrust
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.yamlyaml
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);

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.

yaml
extensions:
  chio:
    human_in_loop:
      approvers:
        n: 2
        of: ["ops-lead@example.com", "security@example.com"]
        timeout_seconds: 1800   # optional

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

RefusalWhat it catches
threshold approvers require an authenticated approver directoryNo directory was supplied. Approver identifiers would otherwise be strings nobody vouched for.
threshold approver `{id}` could not be resolvedThe 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 directoryA 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