Verdicts & Approval
Read Allow, Deny, and PendingApproval correctly, and find where a call can be parked for a person's approval.
On this page
An agent's call ends in one of three verdicts, and each one tells its caller something different to do next: read the result, change the request, or wait for a person. Allow and Deny are decisions. PendingApproval parks one request, undispatched, until someone signs for it.
Any entry point can return Allow or Deny. Two hosted paths park a call before dispatch: budget admission, when a cumulative-approval constraint needs a signature, and the host's approval guard, which stores a pending request bound to the request hash. A guard inside the ordinary pipeline cannot park a call.
Each verdict asks the caller for a different next action
Read a verdict under the contract of the entry point that returned it. A portable decision, a hosted execution, and a parked request have different effects.
κcapabilitygguardsBbudget
Budget admission parks the call for approval
A grant with a RequireCumulativeApprovalAbove constraint, presented without approval_tokens, crosses its cumulative threshold. The kernel releases its runtime reservations, returns PendingApproval with the ThresholdApprovalProposal as output, and signs a deny receipt that carries threshold_approval metadata. Nothing is dispatched. The path that mints it.
| Verdict | Dispatched | Caller's next step |
|---|---|---|
| Allow | On hosted calls, yes | Read the outcome; never dispatch again |
| Deny | No | Change request, authority, or policy, then retry |
PendingApproval | No | Get the signed approval, then resume |
A portable Allow dispatches nothing; a hosted response carries its own execution and evidence fields. A pre-dispatch Deny stops execution on that mediated path, and the change goes through the authorized workflow. A resumed request is revalidated before it runs.
The pricing service shows all three on one policy, plus the outcome readers do not expect: an Allow whose output is rewritten before the agent reads it. A case table pins each one, so a policy change that moves a call from one verdict to another fails in CI.
policy: ./policy.yaml
server: pricing
cases:
- name: quote for sku-118
tool: quote
params: {sku: sku-118, percent: 10}
expect: allow
- name: refund above the ceiling
tool: refund
params: {order: order-77, amount: "120.00"}
expect: deny
code: refund-ceiling
- name: discount of 80 percent on sku-301
tool: discount
params: {sku: sku-301, percent: 80}
expect: pending
- name: quote that would leak a customer email
tool: quote
params: {sku: sku-118, percent: 10}
output: {price: "17.99", contact: "priya@harbor-goods.example"}
expect: redactchio policy test verdicts.test.yamlpolicy ./policy.yaml PASS quote for sku-118 allow PASS refund above the ceiling deny rule refund-ceiling PASS discount of 80 percent on sku-301 pending approval: Mara Okafor PASS quote that would leak a customer email redact secret-leak on contact 4 cases: 4 passed
The fourth case is an Allow: the tool ran, and the output hook replaced the address before delivery. The effect has already happened by then, which is why output controls redact and never refuse. Response Sanitization covers the hook.
A transport or storage error is a possible result too, and it grants nothing. If dispatch may already have happened, inspect the hosted operation state before retrying an effect. The Node Runtime Contract owns execution and persistence.
Both crates declare three values; the core produces two
Verdict is declared twice, once in the portable core and once in the hosted shell, with the same three variants, the same derives, and no re-export between them. The core's copy states the asymmetry in its own doc comment:
/// Three-valued outcome of a kernel evaluation step.
///
/// This mirrors `chio_kernel::runtime::Verdict` exactly. The
/// kernel core never emits `PendingApproval` itself; the full `chio-kernel`
/// orchestration shell wraps the core verdict with the human-in-the-loop
/// approval path where needed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
/// The action is allowed.
Allow,
/// The action is denied.
Deny,
/// The action is suspended pending a human decision. Only produced by
/// the full `chio-kernel` shell, never by `chio-kernel-core` directly.
PendingApproval,
}Guards, adapters, and wire types all round-trip through the same enum, so the core names the third value and refuses it. chio_kernel_core::evaluate and its four floor variants return only Allow and Deny, as the field doc on EvaluationVerdict.verdict states.
Two traits are named Guard. The portable one returns Result<Verdict, KernelCoreError> and sees a PortableToolCallRequest with no DPoP proof, governed intent, approval token, or model metadata. The shell one returns Result<GuardDecision, KernelError>, carries GuardEvidence beside the verdict, and adds the dispatch-revalidation hooks; every guard in chio-guards implements it. The browser adapter, the mobile FFI, the C++ kernel FFI, the Lambda extension, and the conformance hot-path tests all pass an empty guard slice, &[], so on those paths the third value has no source.
Read more in Core & Shell.
A guard that returns PendingApproval is refused on both sides
In the core, step four of finish_verified_evaluation is the whole rule. Continuation needs two independent yeses:
for guard in input.guards {
let evaluation = guard.evaluate(&ctx);
let step = match &evaluation {
Ok(verdict) => GuardStep::from(*verdict),
Err(_) => GuardStep::Error,
};
let projected_allows = guard_step_admits(step);
if guard_projection_allows_continuation(projected_allows, step)
&& matches!(&evaluation, Ok(Verdict::Allow))
{
continue;
}
match evaluation {
Ok(_) => {
// PendingApproval is reserved for the full kernel orchestration
// layer (chio-kernel::approval::ApprovalGuard); if a sync guard
// surfaces it here we fail closed.
let core_err = KernelCoreError::GuardDenied {
guard: guard.name().to_string(),
};
return deny(core_err, Some(matched_grant_index), Some(verified));
}
Err(error) => {
let core_err = KernelCoreError::GuardError {
guard: guard.name().to_string(),
reason: error.deny_reason(),
};
return deny(core_err, Some(matched_grant_index), Some(verified));
}
}
}GuardStep::from(Verdict::PendingApproval) is GuardStep::Error, so the pure projection says no: guard_step_admits runs the step through guard_pipeline_allows, whose kernel is core_authorized && guard_allows, with guard_allows true only for GuardStep::Allow. The observed result must also be Ok(Verdict::Allow), and guard_projection_allows_continuation fails closed when the projection and the observation disagree in either direction.
The core's deny is KernelCoreError::GuardDenied, reading guard "pending-guard" denied the request, so on the core path a pending guard and a denying guard produce one string, and no later guard runs. The hosted shell reaches the same refusal by its own loop and keeps the distinction in the reason. evaluate_guards_sequential serves both the inline path and the spawn_blocking offload in run_guards_owned, so the two cannot drift, and it reports returned an unsupported approval verdict. A GuardPipeline that propagates a child's pending value is refused under the name guard-pipeline.
The shell's pending arm, and the property test over the fold
Verdict::PendingApproval => {
// The `Guard` trait does not carry the HITL approval flow; that runs via
// `ApprovalGuard::evaluate`. A `Guard` returning `PendingApproval` is an
// unsupported state, so fail closed.
return Err(GuardRunError::new(
KernelError::GuardDenied(format!(
"guard \"{}\" returned an unsupported approval verdict",
guard.name()
)),
evidence,
));
}A proptest in chio-kernel/tests/guard_decision_equivalence.rs generates sequences of up to sixteen verdicts and asserts that guard_pipeline_allows over the projected steps agrees with core_authorized && every verdict is Allow over the raw ones; pending_approval_and_error_projections_fail_closed and verdict_fold_matches_bounded_pipeline pin the projection, and guard_projection_preserves_deny_and_pending_error_attribution pins the shared deny string. Sixteen bounds the property, and a pipeline may hold any number of guards.
Read more about content-review, the one catalog guard that returns the third value.
Budget admission parks a call before dispatch
For each matched grant the async evaluation core and the nested-flow path run governed-admission validation, the guard pipeline, the runtime admission hook, then check_and_increment_budget. Only that last step returns BudgetAdmissionOutcome::PendingApproval: when the budget store answers BudgetAuthorizeHoldDecision::ApprovalRequired for a RequireCumulativeApprovalAbove constraint and the request carries no approval_tokens. The guard pipeline receives Some(matching.index) from the scope-match loop, and budget admission runs after it.
A matched grant with a cumulative constraint takes the structured path in begin_durable_tool_admission, and the kernel parks the call only on a node wired for durable admission. Each of these is a hard deny:
- The durable admission mode does not cover the request's effect class:
aggregate, cumulative, and supplemental authorization requires durable admission coverage. - No durable admission runtime is attached:
no qualified admission operation store is configured. - The grant carries more than one cumulative constraint:
a matching grant must contain exactly one cumulative approval constraint. - The negotiated peer profile lacks
cumulative_approval_budget:cumulative approval budgets were not negotiated. The local profile turns the feature on; a request with afederated_origin_kernel_iduses the pinned peer profile, which must advertise it. - A monetary call has no qualified payment adapter with a recoverable rail identity.
Before it parks, the kernel runs release_runtime_admission_reservations_for_pre_dispatch_denial and requires the release to be confirmed. A retained lease turns the outcome into a deny reading durable admission failed: runtime admission reservation retained on pending approval, so no reservation is held open for the length of an approval window.
A parked call returns its proposal and signs a deny
build_pending_approval_response_with_metadata builds the one response that carries the third value, from top-level and nested-flow evaluation. Every field is fixed:
- Verdict:
PendingApproval. - Output: the serialized
ThresholdApprovalProposal, the artifact the caller gets signed. - Reason:
None, and no execution nonce is issued. - Terminal state:
Incomplete { reason: "approval_required" }. - Receipt:
Decision::Deny { reason: "cumulative approval required", guard: "kernel" }, with athreshold_approvalmetadata block carrying the proposal id, hash, deadline, and the stateapproval_required.
The receipt's Decision is a separate type with four values, Allow, Deny, Cancelled, and Incomplete, so never decode it as the in-flight verdict. Reconstruct a parked call from its threshold_approval metadata. The Receipt Contract lists the decision values.
Approval constraints stay with the hosted kernel. The core denies a cumulative-approval scope on evaluate_with_full_floor and evaluate_with_full_floor_and_root with capability feature unsupported on this runtime: cumulative approval enforcement. On evaluate and evaluate_with_crypto_floor, portable scope matching treats RequireApprovalAbove and RequireCumulativeApprovalAbove as unevaluable and fails with portable kernel cannot safely evaluate require_approval_above, so a grant carrying either denies on every core-only adapter.
Read more in Approval & HITL, and apply it in Require Human Approval.
Verdict producers and consumers
Search for sidecar, content-review, Tower, or metrics.
chio_kernel_core::evaluate
The core's evaluate and its four floor variants. Only Allow and Deny leave the module.
chio_kernel_core::Guard::evaluate
The third value is type-legal and refused by step four. The trait doc names Allow, Deny, and Err.
chio_kernel::Guard::evaluate
Returns through GuardDecision. GuardDecision::pending_approval(evidence) is a public constructor; the guard runner refuses what it builds.
GuardPipeline
Folds its children and treats pending as a sticky escalation: it keeps iterating so a later child can still deny, and otherwise propagates pending as its own verdict. It registers as one Guard, so its pending value is refused one level up.
content-review
The one guard in chio-guards that returns the third value. On an ExternalApiCall to stripe, paypal, square, braintree, adyen, or plaid, it reads the matched grant's RequireApprovalAbove { threshold_units }, takes an amount from amount_units, amountUnits, or amount, falls back to the governed intent's max_amount, and returns Verdict::PendingApproval when units >= threshold_units, so an amount exactly on the threshold is pending. The runner maps that value to a fail-closed deny, so the call is blocked and no approval request is created.
The monetary check is skipped when matched_grant_index is None, when the grant has no RequireApprovalAbove, or when no amount parses; evaluate_amount_threshold then returns Ok(None) (cannot compare; leave the decision to other guards) and the PII and profanity scan runs. The guard is enabled by the content_review policy key and is outside the seven default guards. Tests stripe_charge_above_threshold_triggers_pending_approval and its governed-intent twin assert the guard's return value.
ApprovalGuard::evaluate
A standalone API beside the Guard trait. On the host path it stores a pending request bound to the request hash and returns PendingApproval; the approver signs, and the host resubmits the stored call. The sidecar also accepts pending records from a caller through POST /approvals/submit, and resume_with_decision, the single-use replay registry, and ten /approvals/* routes behind require_sidecar_control_middleware resolve them. The Python LangGraph SDK's own approval node reuses the name ApprovalGuard in a deny payload and calls no Rust type.
check_and_increment_budget
Feeds build_pending_approval_response_with_metadata, the path that puts Verdict::PendingApproval on a ToolCallResponse, on a node wired for durable admission.
revalidate_non_consuming_guard
Dispatch revalidation is the one place the third value is benign. The helper in chio-guards returns Ok on pending, because admission owns approval adjudication; every guard in that crate that opts into revalidation routes through it. The helper of the same name in chio-data-guards folds pending in with Deny and errors, and the HTTP projection guard in chio-http-core inlines the same deny-with-pending match, so the treatment follows the crate a guard lives in.
x-chio-approval-required
Marking an operation x-chio-approval-required: true forces its route policy to DenyByDefault through DefaultPolicy::for_method_with_extensions: the call then needs a capability token, and the extension produces no pending verdict.
Portable FFI adapters
Each writes the arm defensively behind an empty guard slice. Mobile and the C ABI deny; the browser reports pending_approval. The three wire mappings are side by side in Portable Kernel Core.
provider_verdict.rs
Maps to VerdictResult::Deny with rule_id: "kernel.pending_approval", documented as fail-closed for callers that ignore the approval channel.
HttpAuthorityError::PendingApproval
is_dispatch_failure excludes it, so an approval prompt never feeds chio_dispatch_failure_total or the error latency series. The id is read from metadata approval_id or pending_approval.approval_id, then from the reason text.
chio api protect · proxy
Returns HTTP 409 with error: "chio_approval_required", a fixed message, and kernel_receipt_id. approval_id and resume_path (/approvals/{id}/respond) are added when an id resolves.
POST /v1/evaluate
The kernel-mediated route returns HTTP 200 with status: "pending_approval" and the signed receipt, beside authorized and deny. Branch on status: a client that branches only on the HTTP code reads a parked call as success.
ChioTowerError::Evaluation
Collapses the verdict into an evaluation error, with approval_id= spliced into the message when present. The typed distinction ends at this boundary.
AcpVerdict
Carries the kernel reason, or ACP operation requires approval.
MCP edge
is_error is Deny or a non-completed terminal state. Every pending response carries Incomplete, so it becomes a tool error by terminal state, without a branch on the verdict.
chio.decision
Survives as itself: bridge metadata reports chio.decision: "pending_approval" beside the receipt reference and the terminal state.
ReceiptWriteOutcome::PendingApproval
Its own bucket in the four-value chio_receipt_write_total taxonomy, so approval traffic is counted without inflating error burn rates. A conformance test pins the shipped Prometheus recording rules never to mention the label.
Conformance verdict matrix
Folded to Verdict::Deny with reason code urn:chio:error:guard:denied. The matrix vocabulary is Allow, Deny, and Error.
project_pending_approval_for_test
Each edge exposes a test-only helper that overwrites a completed response with the pending verdict. Protocol edges project a pending value handed to them and never mint one.
Source files
The core rule is step four of chio-kernel-core/src/evaluate.rs, and portable approval constraints fail in constraint_matches in chio-kernel-core/src/scope.rs. The shell runner is evaluate_guards_sequential and run_guards_owned in kernel/dispatch.rs; the parking arms are in async_evaluation_core.rs and nested_flow_evaluation.rs, the preconditions in begin_durable_tool_admission (kernel/admission_coordinator.rs) and cumulative_approval_request_for_grant (kernel/validation.rs), and the response in pending_responses.rs.
Consumers: pending_approval_id in chio-http-core/src/authority.rs, the sidecar's proxy/errors.rs and proxy/mediated.rs, chio-core-types/src/receipt/decision.rs for the four decision values, chio-openapi/src/policy.rs for the OpenAPI extension, and chio-guards/src/lib.rs beside chio-data-guards/src/lib.rs for the two revalidation helpers.