PlatformDecision Contract
Kernel
The Three Verdicts
Allow and Deny are decisions. PendingApproval is a control-flow value only the hosted shell can mint, and the core denies on sight.
This page owns the type, not the machinery
Three values, two of them decisions
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 carries 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,
}A portable core that declines to produce a value still has to name it, because guards, adapters, and wire types all round-trip through the same enum. Declaring it and refusing it is what makes the seam legible: the third value exists in the type on both sides, and exactly one side may construct it on a live evaluation. That costs one enum variant.
Both enums are shipped. The shell path that mints the third value is wired into the async evaluation core, but it is reachable only on a kernel configured for durable admission. Without a qualified admission operation store, a grant carrying a cumulative-approval constraint is a hard deny, not a park. The receipt vocabulary that would record the parked state does not exist and never has; see Guarantees and limits.
Who may construct which value
| Producer | Values it can return | Note |
|---|---|---|
chio_kernel_core::evaluate and its four floor variants | Allow, Deny | The field doc on EvaluationVerdict.verdict states it: only Allow and Deny flow out of the module. |
chio_kernel_core::Guard::evaluate | Allow, Deny (the third is type-legal and refused) | The trait doc names Allow, Deny, and Err, and does not mention the third value at all. |
chio_kernel::Guard::evaluate, through GuardDecision | All three | GuardDecision::pending_approval(evidence) is a public constructor. The runner refuses what it builds. |
GuardPipeline in chio-guards | All three | Folds its children and treats pending as sticky. It is itself a Guard, so its own pending is refused one level up. |
ApprovalGuard::evaluate | Pending, via HitlVerdict::Pending | A standalone API, not the Guard trait. No non-test caller in crates/. |
check_and_increment_budget, into build_pending_approval_response_with_metadata | Pending, on a real ToolCallResponse | The one shipped path that puts Verdict::PendingApproval in front of a caller, and only on a node wired for durable admission. |
Two of those rows say Guard and mean different traits. The portable trait returns Result<Verdict, KernelCoreError> and sees a PortableToolCallRequest with no DPoP proof, no governed intent, no approval token, and no model metadata. The shell trait returns Result<GuardDecision, KernelError>, carries GuardEvidence alongside the verdict, and adds the dispatch-revalidation hooks. Every guard in chio-guards implements the shell trait. Nothing outside chio-kernel-core’s own test fixtures implements the portable one: tests/portable_build.rs and the evaluate.rs test module hold the only impl Guard blocks against it in the tree. Every caller that reaches the core passes an empty guard slice: the browser adapter, the mobile FFI, the C++ kernel FFI, the Lambda extension, and the conformance hot-path tests all pass &[]. The shell exposes evaluate_portable_verdict, which takes a portable guard slice, and that entry point also has only test callers. On every one of those paths the third value has nowhere to come from at all.
The core: a pending verdict is a deny, worded as a deny
Step four of finish_verified_evaluation is the whole rule:
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));
}
}
}Continuation needs two independent yeses. GuardStep::from(Verdict::PendingApproval) is GuardStep::Error, so the pure projection says no: guard_step_admits runs the single step through guard_pipeline_allows, whose kernel is core_authorized && guard_allows with guard_allows true only for GuardStep::Allow. The observed result then has to be Ok(Verdict::Allow) as well. A disagreement between the projection and the observation is fail-closed in both directions, which is what guard_projection_allows_continuation exists to enforce.
The deny that follows is indistinguishable from a real deny. The Ok(_) arm builds KernelCoreError::GuardDenied, whose reason renders as guard "pending-guard" denied the request with no trace of which value the guard actually returned. The core test guard_projection_preserves_deny_and_pending_error_attribution pins that on purpose: a denying guard and a pending guard produce the same string. A proptest in chio-kernel/tests/guard_decision_equivalence.rs goes further and pins the whole fold, generating sequences of up to sixteen verdicts and asserting that guard_pipeline_allows over the projected steps agrees with core_authorized && every verdict is Allow over the raw ones. The bound is the property’s, not the runtime’s: nothing caps the number of guards a pipeline may hold.
The shell: same refusal, different wording
The hosted shell runs its own guard loop and reaches the same conclusion by a different route. evaluate_guards_sequential is shared by the inline path and the spawn_blocking offload, so the two cannot drift:
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,
));
}Unlike the core, the shell keeps the distinction in the reason string. An operator reading returned an unsupported approval verdict knows a guard asked for something the pipeline cannot give it. On the core path that information is gone by the time the reason is built.
One guard returns it anyway
content-review is the only guard in chio-guards that emits the third value. On an ExternalApiCall to one of six payment services (stripe, paypal, square, braintree, adyen, plaid) it reads the matched grant for a RequireApprovalAbove { threshold_units } constraint, pulls an amount from amount_units, amountUnits, or amount, falls back to the governed intent’s max_amount, and returns Verdict::PendingApproval when the amount is at or above the threshold. The constraint is named RequireApprovalAbove and the comparison is units >= threshold_units, so a payment landing exactly on the threshold parks. Its module doc says the HITL flow takes over from there. It does not: the runner that invokes the guard maps that value onto a fail-closed deny. The call is blocked either way. What does not happen is an approval request being created, dispatched, or made resumable. The guard is opt-in through the content_review policy key and is not one of the seven default guards.
Three conditions silently skip the monetary check rather than failing it. A context whose matched_grant_index is None has no grant to read constraints off. A matched grant with no RequireApprovalAbove has no threshold. And an amount the guard cannot parse from any of the three argument keys or from the intent leaves it nothing to compare, at which point evaluate_amount_threshold returns Ok(None) with the in-code comment cannot compare; leave the decision to other guards and evaluation falls through to the PII and profanity scan. Nothing in the receipt records that the gate did not fire.
GuardPipeline adds a third reading in between. It treats pending as a sticky escalation: it keeps iterating so a later child can still short-circuit to Deny, and otherwise propagates the pending verdict as its own. Since the pipeline registers as a single Guard, that propagated value lands in evaluate_guards_sequential and is refused under the name guard-pipeline, not under the name of the child that raised it.
Two helpers, one name, opposite answers
revalidate_non_consuming_guard in chio-guards returns Ok on pending, reasoning that admission owns approval adjudication and a re-evaluation must not turn an already-adjudicated request into a hard denial at dispatch. Every guard in that crate that opts into revalidation routes through it. The function of the same name in chio-data-guards folds pending in with Deny and errors. The HTTP projection guard in chio-http-core inlines the same deny-with-pending match. Both helpers are crate-private, so this is two independent copies of one policy rather than a shared decision, and which behavior a guard gets depends on which crate it lives in.The path that legitimately mints it
In the async evaluation core the per-grant order is: governed-admission validation, then the guard pipeline, then the runtime admission hook, then check_and_increment_budget. Only the last of those can return BudgetAdmissionOutcome::PendingApproval, and only when the budget store answers BudgetAuthorizeHoldDecision::ApprovalRequired for a RequireCumulativeApprovalAbove constraint and the request carries no approval_tokens. The third value is therefore minted strictly downstream of the guard pipeline that refuses it and strictly upstream of dispatch. The GuardContext doc comment in chio-kernel still says matched_grant_index is populated by check_and_increment_budget before guards run. That comment is stale: the guard pipeline receives Some(matching.index) from the scope-match loop, and budget admission runs after. The order above is the one in the async evaluation core and the nested-flow path; the sync wrapper is not traced here.
Reaching that outcome at all takes more configuration than the sentence above implies. A matched grant carrying a cumulative constraint forces the structured admission path in begin_durable_tool_admission, and each of the following is a hard deny rather than a park:
- The kernel’s 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 matched grant carries more than one cumulative constraint:
a matching grant must contain exactly one cumulative approval constraint. - The negotiated peer profile does not advertise
cumulative_approval_budget:cumulative approval budgets were not negotiated. The kernel’s own local profile turns that feature on; a request arriving with afederated_origin_kernel_iduses the pinned peer profile instead, which must advertise it.
Monetary calls add one more gate: durable monetary admission requires a qualified payment adapter with a recoverable rail identity. The pending verdict is therefore not a property of the guard runtime. It is a property of a node wired for durable admission, and on any other node the same capability denies.
The kernel also checks that it can afford to park before it parks. It runs release_runtime_admission_reservations_for_pre_dispatch_denial and requires the release to be confirmed. A retained lease converts the pending outcome into a fail-closed deny reading durable admission failed: runtime admission reservation retained on pending approval, because telling a caller to go collect a human signature for an operation whose lease was never released would leak the reservation for the length of the approval window.
Every field of the response it builds is pinned. The verdict is PendingApproval; the output is the serialized ThresholdApprovalProposal, so the caller receives the artifact it must get signed; the reason is None; the terminal state is Incomplete { reason: "approval_required" }; no execution nonce is issued. The signed receipt records Decision::Deny { reason: "cumulative approval required", guard: "kernel" } plus a threshold_approval metadata block carrying the proposal id, hash, deadline, and the state string approval_required.
The capability feature that produces the third value in the shell is one the core refuses outright. A scope with a cumulative-approval constraint is denied on the full-floor path (evaluate_with_full_floor and evaluate_with_full_floor_and_root) with capability feature unsupported on this runtime: cumulative approval enforcement rather than admitted unenforced. The plain evaluate and evaluate_with_crypto_floor entry points do not carry that check, but they do not admit the grant either: portable scope matching in chio-kernel-core/src/scope.rs treats both RequireApprovalAbove and RequireCumulativeApprovalAbove as unevaluable and fails the whole resolution with portable kernel cannot safely evaluate require_approval_above. Either way the core declines the token instead of guessing. No approval constraint of any kind is enforceable in the portable core.
Nothing on this path writes an ApprovalStore row. ApprovalGuard::evaluate is the component that would, and it has no non-test caller in crates/. Its module doc points callers at ChioKernel::evaluate_tool_call_with_hitl, which does not exist in the tree. In the shipped sidecar, pending records are created by an explicit POST /approvals/submit from the caller, and the resume flow (resume_with_decision, the single-use replay registry, and ten /approvals/* routes behind the require_sidecar_control_middleware gate) works against records created that way. The Python LangGraph SDK ships its own approval node that reuses the name ApprovalGuard in a deny payload; it does not call the Rust type.
What each consumer does with it
| Consumer | Treatment |
|---|---|
| Portable FFI adapters (browser, mobile UniFFI, C ABI) | Each writes the arm defensively and none can reach it: all three pass an empty guard slice into a core with no code path returning the value. Mobile and the C ABI deny; the browser reports pending_approval. The three wire mappings, side by side, are in Portable Kernel Core. |
Fabric verdict shim (provider_verdict.rs) | VerdictResult::Deny with rule_id: "kernel.pending_approval", stated in the doc comment as fail-closed for callers that ignore the approval channel. |
HTTP authority (chio-http-core) | Typed HttpAuthorityError::PendingApproval { approval_id: Option<String>, kernel_receipt_id }. is_dispatch_failure excludes it, so an approval prompt never feeds chio_dispatch_failure_total or the error latency series. The id is looked up under metadata approval_id or pending_approval.approval_id, then scraped from the reason text; the kernel-minted path supplies none of those, so it resolves to None. |
Sidecar (chio api protect), proxy path | HTTP 409 with error: "chio_approval_required", a fixed message, and kernel_receipt_id. approval_id and resume_path (/approvals/{id}/respond) are added only when an id was resolvable. |
Sidecar, POST /v1/evaluate | Not a 409. The kernel-mediated route returns HTTP 200 with status: "pending_approval" and the signed receipt, alongside authorized and deny. A client that only branches on HTTP status treats a parked call as success. |
| Tower middleware | Collapses to ChioTowerError::Evaluation, message request requires approval; kernel_receipt_id=... with approval_id= spliced in when present. The typed distinction is lost at this boundary. |
| ACP proxy | AcpVerdict { allowed: false } with the kernel reason or ACP operation requires approval. |
| MCP edge | Does not branch on the verdict at all. is_error is Deny or a non-completed terminal state, and every pending response carries Incomplete, so it becomes a tool error by terminal state rather than by verdict. |
| Cross-protocol bridge | Survives as itself: the bridge metadata reports chio.decision: "pending_approval" next to the receipt reference and the terminal state. |
| Edge metrics | Its own ReceiptWriteOutcome::PendingApproval bucket in the four-value chio_receipt_write_total taxonomy, so HITL 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, Error, with no pending state of its own. |
The A2A and ACP protocol edges each expose a project_pending_approval_for_test helper that overwrites a completed response with the pending verdict. Both are #[cfg(test)]. No protocol edge mints the value in production; they only project one handed to them.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | The portable core never emits the third value, and a portable guard that returns it is denied before any later guard runs. | chio-kernel-core/src/evaluate.rs, step 4 |
| Shipped | The shell guard runner refuses it with a distinct reason, on both the inline and the offloaded path, because both call one function. | evaluate_guards_sequential and run_guards_owned, kernel/dispatch.rs |
| Proved by test | The projection of the third value is GuardStep::Error, and no fold over projected steps admits it. A property test over generated sequences of up to sixteen verdicts pins the fold against a direct core_authorized && all Allow check. | pending_approval_and_error_projections_fail_closed, verdict_fold_matches_bounded_pipeline |
| Shipped | Exactly one function constructs a ToolCallResponse carrying the third value, and it releases its runtime reservations first or denies instead. Two call sites reach it: top-level evaluation and nested-flow evaluation. | build_pending_approval_response_with_metadata; the BudgetAdmissionOutcome::PendingApproval arms in async_evaluation_core.rs and nested_flow_evaluation.rs |
| Limit | The pending path is configuration-gated. A cumulative-approval grant forces structured durable admission; a node without the coverage mode, without a qualified admission operation store, or (for monetary calls) without a qualified payment adapter denies rather than parks, as does a grant carrying two cumulative constraints or a federated origin whose pinned profile omits cumulative_approval_budget. | begin_durable_tool_admission in kernel/admission_coordinator.rs; cumulative_approval_request_for_grant in kernel/validation.rs |
| Limit | A parked call carries no approval id. The kernel writes threshold_approval.proposal_id and reason: None; every downstream extractor looks for approval_id, pending_approval.approval_id, or an id embedded in the reason text. On the kernel-minted path the sidecar 409 therefore omits both approval_id and resume_path, and the caller has to read the proposal out of the receipt. | pending_responses.rs against pending_approval_id in chio-http-core/src/authority.rs and proxy/errors.rs |
| Limit | No approval constraint is enforceable in the portable core. RequireApprovalAbove and RequireCumulativeApprovalAbove both fail portable scope matching outright, so a grant carrying either denies on every core-only adapter. | constraint_matches, chio-kernel-core/src/scope.rs |
| Limit | The receipt vocabulary has no pending state. Decision is Allow, Deny, Cancelled, Incomplete, and a parked call signs a Deny. The third verdict is runtime control flow, not an audit value: reconstruct it from the threshold_approval metadata block, not from the decision. | chio-core-types/src/receipt/decision.rs |
| Limit | On the core path the deny reason cannot distinguish a guard that denied from one that asked for approval. Both render guard "X" denied the request. The shell path does distinguish; the core one does not. | KernelCoreError::GuardDenied; the test that pins both strings equal |
| Limit | content-review returns a value the runner it registers with refuses. The call is blocked, but no approval request is created and the guard’s module doc overstates what follows. Its monetary gate also skips silently when the amount cannot be parsed or the matched grant index is absent. | evaluate_amount_threshold against evaluate_guards_sequential; tests stripe_charge_above_threshold_triggers_pending_approval and its governed-intent twin assert the guard’s return value only |
| Limit | Two of the sidecar’s own routes disagree on what a parked call looks like: the proxy path returns 409 chio_approval_required, and POST /v1/evaluate returns 200 with status: "pending_approval". | proxy/errors.rs against proxy/mediated.rs |
| Limit | Marking an OpenAPI operation x-chio-approval-required: true does not produce a pending verdict. It forces the route policy to DenyByDefault, which means the call needs a capability token, not a human. | DefaultPolicy::for_method_with_extensions, chio-openapi/src/policy.rs |
| Limit | Two sibling crates carry a crate-private revalidate_non_consuming_guard with the same signature and opposite treatment of the third value at dispatch revalidation. There is no shared definition to change. | chio-guards/src/lib.rs against chio-data-guards/src/lib.rs |
| Not wired | ApprovalGuard, the component that turns a RequireApprovalAbove or autonomy-tier constraint into a stored, dispatched approval request, has no non-test caller. Its module doc points at a kernel method, evaluate_tool_call_with_hitl, that does not exist. The store, the resume flow, and the sidecar routes around it do ship. | Every ApprovalGuard reference in crates/ is a test or a doc comment; evaluate_tool_call_with_hitl appears once, in that doc comment |
| Design only | Receipt-level approval states. docs/protocols/HUMAN-IN-THE-LOOP-PROTOCOL.md (status: proposed) plans PendingApproval, ApprovedAndExecuted, and HumanDenied variants on Decision. Phase 1 landed the verdict variant and the store traits; the receipt variants did not land. | Protocol document section 1 and the phased plan; the four-variant Decision in the tree |
Next steps
- Core & Shell · the eight-item exclusion list this asymmetry belongs to, and the rest of the Kernel-to-Node seam
- Approval & HITL · the request record, the signed token, the binding checks, and the resume flow
- Approval Store · where a pending record and its consumed-token registry live on one node’s disk
- Portable Kernel Core · the three FFI adapters and how each maps a verdict onto its wire
- Fail-Closed Semantics · the wider rule the third verdict is one instance of
- Budget Store · the cumulative-approval accounting that decides when a call is parked