Approval & HITL
Embed the approval guard, store, and channels, and resume a held call with a token bound to its exact request.
On this page
A kernel you embed can stop a constrained call, keep it as a pending request, and run it later only under a person's signature over that exact request.
Four parts carry the decision. ApprovalGuard decides that a call needs approval, ApprovalStore keeps the pending request, an ApprovalChannel presents it to a person, and a GovernedApprovalToken carries the signed decision back. The Quickstart's pricing rule, quote.percent > 50, runs on these parts; Human-in-the-Loop explains the model from the application side.
The approval guard holds a constrained call before any other guard runs
ApprovalGuard runs ahead of the generic guard pipeline and reads the matched grant's constraints. When they call for approval and no token is presented, it stores an ApprovalRequest, dispatches it to every configured channel, and returns HitlVerdict::Pending.
One signature runs one call
The host holds the call and stores pending request r. The approver signs τ. The host checks that r is still pending and τ unspent, runs verify_against, and resolves r in the same store call that records τ as consumed. It resubmits the stored call with τ. The kernel verifies τ again, runs every other check, and reserves τ for this subject, request, and intent hash with reserve_for_dispatch. Only then does the tool run, and the kernel signs an Allow receipt. Resume flow.
In Rust · approval.rs:74415 lines
pub fn resume_with_decision(
store: &dyn ApprovalStore,
decision: &ApprovalDecision,
now: u64,
) -> Result<ApprovalOutcome, KernelError> {
let pending = match store
.get_pending(&decision.approval_id)
// … an unknown or already resolved id is rejected
let already = store
.is_consumed(&decision.token.id, &pending.parameter_hash)
// … a consumed token is rejected as a replay
let token_decision = approval_token.verify_against(&pending, now)?;
// … the HTTP outcome must match the signed decision
store
.resolve(&decision.approval_id, decision)
Constraint::RequireApprovalAbove { threshold_units }fires when the boundGovernedTransactionIntentcarries amax_amountwhoseunitsmeet or exceedthreshold_units. With no governed intent, the constraint denies.Constraint::MinimumAutonomyTier(GovernedAutonomyTier::Autonomous)fires for a call with a governed intent. TheDirectandDelegatedtiers pass without approval.force_approvalon the context fires for integration tests and for host adapters that decided out of band that the call needs a person.
With a token, the guard looks up the pending or resolved record, runs the replay and binding checks, verifies the signature, and returns HitlVerdict::Approved or HitlVerdict::Deny to match the signed decision. With no approval constraint it returns HitlVerdict::Allow and leaves the store untouched. A grant that requires approval names who can give it: with an empty ApprovalContext::trusted_approvers, the guard denies with “approval required but no trusted approvers are configured”.
A channel that fails to deliver leaves the row pending, and the approver finds it with chio approvals pending, the trust-control dashboard, or GET /approvals/pending, then signs with chio approve <id>.
A signed approval resumes only the request it names
The approver's GovernedApprovalToken binds one approver, one subject, one request ID, one intent hash, a decision, and a time window. Before it checks the signature, ApprovalToken::verify_against compares each binding with the stored request, in order.
Every binding check holds; the pending request resolves as Approved, which does not dispatch the call.
In Rust · approval.rs:16717 lines
pub fn verify_against(
&self,
request: &ApprovalRequest,
now: u64,
) -> Result<GovernedApprovalDecision, KernelError> {
// Binding checks: request_id, intent hash, approver identity.
if self.governed_token.request_id != request.approval_id {
return Err(KernelError::ApprovalRejected(
"approval token bound to a different request".into(),
));
}
if self.governed_token.governed_intent_hash != request.parameter_hash {
return Err(KernelError::ApprovalRejected(
"approval token bound to a different parameter set".into(),
));
}
// … approver, trusted set, subject, time window, lifetime, signature
The kernel refuses a token whose lifetime exceeds MAX_APPROVAL_TTL_SECS, 3600 seconds, because the single-use registry tracks tokens up to that ceiling. The store records each consumed (token_id, parameter_hash) pair, so a second presentation is a replay. Single use holds within one consumption registry: kernels that share approvals use one authoritative store, SQLite for callers on one host or a backend with a cross-node transaction contract across nodes.
The parameter hash binds the arguments. On the host path, the host resubmits the stored request with the token, so an argument the model changed afterward hashes to a different parameter_hash and becomes a new pending request. A caller that attaches a token itself binds the arguments through a BoundToolInvocation intent, and the kernel refuses a mismatch before dispatch with KernelError::GovernedTransactionDenied.
Read more in caller-attached approvals and the binding checks.
An approval adds no authority to the call it resumes
A host may obtain the approval before its first submission, and the approved call still meets every other check. An approval leaves capability scope and the budget as they were, so it cannot stand in for a required prepayment adapter.
The shared example invokes governed-sim-srv/compute. Its grant caps one call at $1.00 and total spending at $10.00. The $1.00 intent crosses a $0.50 approval threshold, so the runner signs an approval bound to that request before submitting it.
This is an executable kernel example using an ephemeral store and a simulated payment adapter. The approval is created programmatically; it does not exercise a human interface or a wait-and-resume cycle. Settlement makes no external payment.
adapter configured: allow. Charged $1.00; $9.00 of $10.00 remaining.
governed_validation.rs:1457Self::validate_metered_billing_context(intent, charge_currency, now)?;
if Self::mustprepay_prepaid_units(intent).is_some() && self.payment_adapter.is_none() {
return Err(KernelError::GovernedTransactionDenied(
"governed intent mandates prepayment (settlement_mode=MustPrepay) but no payment adapter is configured"
.to_string(),
));
}
The allowed receipt's payment metadata identifies governed-sim-approval-1. That links the payment record to the supplied approval. Inspecting this identifier alone does not verify the approval signature.
| Captured outcome | Charged | Remaining |
|---|---|---|
Allow with simulated prepayment | $1.00 | $9.00 |
Deny with no payment adapter | $0.00 | $10.00 |
The denial uses a fresh kernel with the same limits and a freshly signed approval. Removing the payment adapter prevents mandatory prepayment, so the kernel refuses the call. Approval cannot satisfy a missing settlement requirement. These are two independent runs, not consecutive debits from one budget.
Run the approval and budget simulation
Use the installed chio. governed-sim supplies its own in-process tool, policy, and simulated payment adapter; it needs no funded account. The command and its recorded result are below.
$ chio mcp governed-sim --governed-mustprepay --payment-adapter sim --out ./compute-allow.jsonExpected output
$ jq '{tool_server, tool_name, decision, receipt_kind, financial: .metadata.financial, budget_authority: .metadata.budget_authority}' ./compute-allow.json{
"tool_server": "governed-sim-srv",
"tool_name": "compute",
"decision": {
"verdict": "allow"
},
"receipt_kind": "mediated_decision",
"financial": {
"budget_remaining": 900,
"budget_total": 1000,
"cost_breakdown": {
"payment": {
"adapter_metadata": {
"adapter": "sim",
"commerce": null,
"governed": {
"approvalTokenId": "governed-sim-approval-1",
"intentHash": "47767e1b5eb79ddbcdb0b2394e957d606ac50bc6b94ab33c2183b3703c8999d7",
"intentId": "governed-sim-intent-1",
"purpose": "no-key CI smoke",
"serverId": "governed-sim-srv",
"toolName": "compute"
},
"mode": "prepaid_no_broadcast"
},
"authorization_id": "sim-268afdc9129342029708eb795c87da08",
"preauthorized_units": 100,
"recorded_units": 100
}
},
"cost_charged": 100,
"currency": "USD",
"delegation_depth": 0,
"grant_index": 0,
"payment_reference": "sim-268afdc9129342029708eb795c87da08",
"root_budget_holder": "e13e1a500b08c64049cf9f9a180fbfef011a1d37ee0205ee6daa3d9acb5aa89b",
"settlement_status": "settled"
},
"budget_authority": {
"authority": {
"authority_id": "kernel:e13e1a500b08c64049cf9f9a180fbfef011a1d37ee0205ee6daa3d9acb5aa89b",
"lease_epoch": 1,
"lease_id": "single-node"
},
"authority_profile": "authoritative_hold_event",
"authorize": {
"budget_commit_index": 1,
"committed_cost_units_after": 100,
"event_id": "budget-hold:governed-sim-req-1:cap-01a07d0c-0238-7443-a0f6-d8a2bac4db3d:0:authorize",
"exposure_units": 100
},
"budget_term": "kernel:e13e1a500b08c64049cf9f9a180fbfef011a1d37ee0205ee6daa3d9acb5aa89b:1",
"guarantee_level": "single_node_atomic",
"hold_id": "budget-hold:governed-sim-req-1:cap-01a07d0c-0238-7443-a0f6-d8a2bac4db3d:0",
"invocation_capture": {
"budget_commit_index": 2,
"event_id": "budget-hold:governed-sim-req-1:cap-01a07d0c-0238-7443-a0f6-d8a2bac4db3d:0:capture-invocation:1",
"invocation_count_after": 1
},
"metering_profile": "max_cost_preauthorize_then_reconcile_actual",
"terminal": {
"budget_commit_index": 3,
"committed_cost_units_after": 100,
"disposition": "reconciled",
"event_id": "budget-hold:governed-sim-req-1:cap-01a07d0c-0238-7443-a0f6-d8a2bac4db3d:0:reconcile",
"exposure_units": 100,
"realized_spend_units": 100
}
}
}To put spending limits on a service you own, continue with the complete metered-service application. It includes the host, tool, signed grant, persistent accounting, and receipts.
Inspect the simulation's missing-payment refusal
When it refuses
$ chio mcp governed-sim --governed-mustprepay --payment-adapter none --out ./compute-deny.jsonerror[CHIO-ECONOMY-PREPAYMENT-REQUIRED]: the governed intent requires prepayment (settlement_mode=MustPrepay) and no payment adapter is configured help: pass --payment-adapter sim for a local run, or --payment-adapter http-x402 with a facilitator URL code: urn:chio:error:economy:prepayment-required docs: https://chio.computer/docs/reference/errors#error-economy-prepayment-required
$ jq '{tool_server, tool_name, decision, financial: .metadata.financial}' ./compute-deny.json{
"tool_server": "governed-sim-srv",
"tool_name": "compute",
"decision": {
"verdict": "deny",
"reason": "governed transaction denied: governed intent mandates prepayment (settlement_mode=MustPrepay) but no payment adapter is configured",
"guard": "kernel"
},
"financial": {
"attempted_cost": 100,
"budget_remaining": 1000,
"budget_total": 1000,
"cost_charged": 0,
"currency": "USD",
"delegation_depth": 0,
"grant_index": 0,
"root_budget_holder": "d91861f13c66c366fd59a0c762982d5ea462c4cb78e10ea6b759f853ad5337ae",
"settlement_status": "not_applicable"
}
}Several approvers sign one proposal resolved from one directory
A threshold requirement names a set of people and how many of them must sign. The policy declares it; the single-approver path above is configured on the guard.
extensions:
chio:
human_in_loop:
approvers:
n: 2
of: ["mara.okafor@larkfield.example", "security@larkfield.example"]
timeout_seconds: 1800 # optionalChioApproverSet is deny_unknown_fields over n, of, and an optional timeout_seconds that falls back to DEFAULT_THRESHOLD_APPROVAL_TIMEOUT_SECONDS, fifteen minutes. The block compiles only through compile_policy_with_approver_directory or compile_policy_with_source_and_approver_directory; plain compile_policy refuses it, so a reviewed policy keeps its requirement. Compilation resolves every approver from one versioned directory into a ThresholdApprovalRequirement holding the policy hash, threshold, resolved identities with their public keys, directory version, and timeout. The kernel checks those keys, never the identifiers in the file.
When a call matches, the policy authority signs a chio.threshold-approval-proposal.v1 proposal over the request ID, governed intent hash, subject, authorizing capability digest, policy hash, threshold, eligible set digest, and deadline. Each approver's token names it in threshold_proposal_hash and counts once per distinct eligible key. The verified set hashes the sorted distinct token digests with the proposal fields, so vote order cannot change the decision.
Read more in Threshold approvals, which runs the program across process restarts with a repeated vote, a refused duplicate signer, a repeated delivery, and completion replay.
Approval evidence and execution evidence are separate records
On the resumed call, the receipt records the approver's public key, the token ID, the approval ID, and the parameter hash, and binds the signed capability, governed intent, and approval token to the decision. The channel's full webhook payload goes to its SIEM stream. The publishing desk in Require human approval before publishing retains evidence at each stage:
| Stage | Retained evidence | Execution |
|---|---|---|
| Proposal pending | Frozen request, subject, hash, deadline, trusted reviewer set | No publication |
| Reviewer decision | Signed token and its request and intent binding | Resolution invokes nothing |
| Host submission | Stored request plus approval correlation metadata | Capability and policy checks run again |
| Published result | Signed execution receipt and the publication file | Compare the file with the approved document |
PendingApproval is a runtime verdict. The signed decision variants are Allow, Deny, Cancelled, and Incomplete, and a host that emits a receipt while a call is suspended records that call with Incomplete. The host connects its approval lifecycle to the matching record; the approval service adds no signed pending variant.
Inspect the captured publication fields
{
"approval_id": "62e59369-69c1-46b4-a8f9-90e1ecaea31f",
"document": {
"publication_id": "62e59369-69c1-46b4-a8f9-90e1ecaea31f",
"text": "The reviewed release is ready.",
"title": "Release notice"
}
}These fields are a reading projection. Inspect the complete captured proposal and execution.
Approval types, configuration, and limits
Try parameter_hash, on_timeout, or MAX_APPROVAL_TTL_SECS.
ApprovalRequest
The serializable record persisted in the store and dispatched to channels. When the approval path needs a decision, it constructs an ApprovalRequest that the configured approval service stores; the host connects notification channels, the human interface, and resubmission. The request carries what a person needs to decide and what the kernel needs to validate the returned token:
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 canonicalized envelope of {server_id, tool_name, arguments, governed_intent}. An argument payload changed after approval fails 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). The kernel keeps Verdict Copy by lifting the approval payload into this sibling type in approval.rs. The public verdict carries the marker Verdict::PendingApproval, and callers that need the request payload read it from the richer HITL API.
The dedicated approval guard sits beside the generic guard pipeline. An ordinary guard that returns PendingApproval starts no approval, and the generic pipeline fails closed on it. Verdicts & Approval owns the enum: which components may construct the third value, and what every other runner does when it sees one.
pub enum Verdict {
/// The action is allowed.
Allow,
/// The action is denied.
Deny,
/// The action is suspended pending a human decision. Look up the
/// associated `ApprovalRequest` via the HITL API.
PendingApproval,
}ApprovalStore
The persistent contract for pending and resolved approvals. The trait is synchronous because the kernel hot path is synchronous, and implementations use memory or SQLite. The store prevents double resolution and keeps replay-token bookkeeping in its own transaction. Approval Store & Channels covers the tables on one node's disk, the resolve transaction, the channel dispatch path, and the batch and threshold collectors.
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 and Mutex: pending requests, resolved rows, and a consumed-token set keyed on token_id ":" parameter_hash. SQLite is the durable path, and the trait stays synchronous so both backends share one call site. Independent nodes that share single-use enforcement need a backend with a cross-node transaction contract, which a local WAL file does not provide.
ApprovalStoreError
pub enum ApprovalStoreError {
NotFound(String),
AlreadyResolved(String),
Replay(String),
Backend(String),
Serialization(String),
}ApprovalChannel
A channel presents an ApprovalRequest to a person. The trait is synchronous; channels that need async I/O run a small dedicated runtime or block through ureq. Add your own by implementing ApprovalChannel. Two channels ship with the kernel.
A channel is a fire-and-forget sink. On a terminal failure (ChannelError::Transport, ::Remote, or ::Config) the kernel records a tracing::warn! and the request stays in the approval store, reachable through GET /approvals/pending. A programmatic approver that polls that route needs no channel, because the request already lives in the store.
WebhookChannel
A blocking HTTP POST of the ApprovalRequest to a configured endpoint, which integrations wire into their own dashboard or ticketing system. The default timeout is 5s, with an optional static auth header (HMAC or 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
An in-memory channel used by tests, host adapters, and the api-poll dispatch mode. It captures every dispatched request in an in-memory ring, and tests assert on captured() that a dispatch fired without standing up an HTTP listener.
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, without trusting 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, and the receipt records the approver's public key for attribution. The signed token as stored and presented:
pub struct GovernedApprovalToken {
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,
/// Signing algorithm. Absent means Ed25519 (the default).
///
/// Informational: verification dispatches off the algorithm encoded in
/// [`GovernedApprovalToken::signature`] and [`GovernedApprovalToken::approver`].
#[serde(default, skip_serializing_if = "is_default_optional_algorithm")]
pub algorithm: Option<SigningAlgorithm>,
pub signature: Signature,
}Token binding checks
ApprovalToken::verify_against runs these checks in order, and each failure refuses the token:
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.- The lifetime
(expires_at - issued_at)is at mostMAX_APPROVAL_TTL_SECS = 3600. The single-use replay registry pins to that ceiling, so a longer token could outlive its replay record. - The Ed25519 signature verifies against the approver key.
Caller-attached approvals: BoundToolInvocation
When a caller attaches a token itself, the argument binding comes from the governed intent. Give that intent a BoundToolInvocation body, which names the authorizing capability and the SHA-256 of the call's RFC 8785 canonical arguments; the token signs the intent's hash. Before dispatch, the kernel recomputes each binding from the submitted call and refuses a mismatch with KernelError::GovernedTransactionDenied:
- Arguments.
npm testrewritten tonpm test && curl ...no longer hashes to the intent'sparameters_hash. Reordering object members leaves the hash unchanged. - Tool or server. The intent's
server_idandtool_nameno longer match the call. - Capability under the same subject. The intent's
capability_idno longer matches the authorizing capability. - Request or subject. The token's
request_idorsubjectno longer matches the call or its capability.
The kernel test bound_tool_invocation_denies_parameter_mutation_with_valid_approval submits five altered argument sets under one valid approval and asserts a denial for each.
Resume entry point
The synchronous resume entry point is resume_with_decision:
pub fn resume_with_decision(
store: &dyn ApprovalStore,
decision: &ApprovalDecision,
now: u64,
) -> Result<ApprovalOutcome, KernelError>;The HTTP layer calls it behind POST /approvals/{id}/respond. It checks that the HTTP envelope's outcome matches the signed token's decision before it writes to the store, because a mismatched pair would otherwise flip resolved=true and corrupt threshold counters.
Batch approvals
A batch approval lets a person pre-approve a class of repetitive calls for a bounded window. BatchApproval carries a recorded approver, subject, and server and tool pattern (* wildcard or prefix), an amount cap per call and in total, a call-count cap, time bounds, and used counters. The store is BatchApprovalStore.
Matching a stored batch and recording its usage are separate operations, so the embedding authenticates batch creation and coordinates authorization with usage accounting; a match by itself reserves none of the remaining allowance. Approval Store & Channels describes the storage boundary.
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 takes a subject, server, tool, optional amount, and the current time, and returns the first unrevoked batch whose subject, patterns, time window, remaining call count, and amount fit the call. record_usage moves used_calls and used_total_units, and revoke ends a batch early. A host wires those calls into its approval path, chio-store-sqlite carries the durable implementation, and each batch's batch_id ties a batch-approved call back to the approval that covered it.
"Approve every pricing call for the next hour"
server_pattern: "pricing"
tool_pattern: "*"
max_calls: None
not_after: now + 3600
"Approve up to 20 order queries in the next 30 minutes"
server_pattern: "orders"
tool_pattern: "sql"
max_calls: Some(20)
not_after: now + 1800
"Approve refunds under $100 for 4 hours, max $500 total"
server_pattern: "pricing"
tool_pattern: "refund"
max_amount_per_call: { units: 10000, currency: USD }
max_total_amount: { units: 50000, currency: USD }
not_after: now + 14400HushSpec human_in_loop rule
HushSpec configures approval 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, and tools matched by require_confirmation collapse the threshold to 0, so they require approval at any amount.
hushspec: "0.1.0"
rules:
human_in_loop:
enabled: true
require_confirmation: ["refund"]
approve_above: 50000 # $500.00, integer minor units
approve_above_currency: "USD"
timeout_seconds: 900
on_timeout: denyAmounts are integer minor units, cents for USD. A second example gates every write and command and sets a $150 threshold:
rules:
human_in_loop:
enabled: true
# Always require approval
require_confirmation:
- "write_*"
- "run_command"
# $150 proposed maximum or more
approve_above: 15000
approve_above_currency: "USD"
# Wait up to 15 minutes
timeout_seconds: 900
# Deny when the deadline passes
on_timeout: denyon_timeout has the type HumanInLoopTimeoutAction, with two values: deny, the #[default], and defer, which keeps the request pending for a later human decision. The parser accepts these fields; optional values may be omitted, and defaulted fields take the parser's defaults:
| Key | Type | Required |
|---|---|---|
enabled | bool | defaulted |
require_ | Vec<String> | defaulted |
approve_above | Option<u64> | optional |
approve_ | Option<String> | optional |
timeout_seconds | Option<u64> | optional |
on_timeout | Human | defaulted |
The rules.human_in_loop schema carries the gate and the deadline. Quorum and escalation live outside it, and it has no per-grant approver list or per-approver contact block. trusted_approvers, channels, and the default TTL are configured on the Rust ApprovalGuard, and trusted_approvers arrives per call through ApprovalContext::trusted_approvers:
let guard = ApprovalGuard::new(store)
.with_channel(Arc::new(WebhookChannel::new(
"https://kernel.larkfield.example/approvals/inbox",
)))
.with_default_ttl(1800);Threshold approver resolution errors
Resolution refuses in four ways, all CompileError::Invalid:
threshold approvers require an authenticated approver directory: no directory was supplied, so approver identifiers would 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 substitution check keeps a directory from swapping 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 comes from one snapshot.
Policy compilation covers the pass this runs in.
Threshold requirement resolver
A host can also install a ThresholdApprovalRequirement directly for a policy hash, server, and tool. The Execution application requires two of three keys (reviewer, release operator, and data steward) for publication/append within 900 seconds:
let requirement = ThresholdApprovalRequirement::new(
policy(),
2,
eligible,
"publication-directory-v1".into(),
900,
)
.map_err(anyhow::Error::msg)?;
kernel.set_threshold_approval_requirement_resolver(Arc::new(
move |hash: &str,
server: &str,
tool: &str|
-> Result<Option<ThresholdApprovalRequirement>, String> {
Ok(
(hash == requirement.policy_hash && server == "publication" && tool == "append")
.then(|| requirement.clone()),
)
},
));With the reviewer's vote alone, the set is collecting: the collector refuses delivery below the threshold and no publication exists. The reviewer voting again under a new token ID is refused, because one key cannot supply a second approver. With the reviewer and the release operator the set is ready, and delivery returns the exact proposal and token set, which the host attaches to the original request for kernel admission.
Host integration checklist
A shared review workflow lets workers in different languages and models use one approval path, through the publishing desk's HTTP contract. Keep signing credentials on the trusted reviewer side and retain the original proposal until it reaches a terminal outcome.
- Configure gated actions, trusted approvers, expiry, and durable approval storage.
- Persist the exact request and show its complete contents to the reviewer.
- Verify the signed decision's subject, hash, request binding, and time window, and consume it once.
- Resubmit the stored request and handle a new refusal from current capability, policy, or budget checks.
- Correlate proposal, decision, execution receipt, and protected resource. Test rejection, tampering, expiry, replay, and interrupted execution.
Performance class
A call without an approval constraint 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, which extend admission time by their latency and configured timeouts. Budget for the whole channel sequence, including failure paths.