Chio/Docs
LOGIN · JOIN

LearnAnatomy of a Governed Call

The Mediated Call

The kernel evaluates every governed call in one order, from capability admission through guards, budget, dispatch, and a signed receipt.

Overview

A mediated call is one tool invocation the kernel evaluates from request to receipt. The kernel admits the capability, runs the guard pipeline, records the call in durable admission state, authorizes a budget hold, dispatches to the tool server, reconciles the charge, and signs a receipt for the outcome.

The default path leaves an embedder no way around that evaluation. ToolEvaluator::dispatch in crates/kernel/chio-kernel/src/kernel/evaluator.rs returns DirectDispatchUnavailable for every direct dispatch and points the caller at ToolEvaluator::evaluate; an implementor that overrides dispatch takes that boundary on itself. A failed check ends the call in a Deny, and the kernel signs a receipt for that deny as well.


Model

The order of checks

The list below is the order a reader can hold in mind. Each step establishes a precondition for the next: the capability is admitted before guards run, guards clear before a budget hold is authorized, and the hold and dispatch happen before receipt signing.

  1. Verify the capability. Check the token's signature, its expiry, and that the requested operation is a subset of the granted scope, an attenuation check performed by ToolGrant::is_subset_of in crates/core/chio-core-types/src/capability/scope.rs. A request outside the grant denies with OutOfScope. See Capabilities.
  2. Run the guard pipeline. Input guards evaluate the request in order; the first Deny or any Err short-circuits the pipeline with Deny. An Err is recorded as a fail-closed denial. A guard may instead return PendingApproval, suspending the call for a human. See Guards and Human in the Loop.
  3. Commit the admission operation. For a configured monetary or side-effecting call, the kernel commits a durable admission operation before the first authoritative participant mutation, and commits DispatchCommitted before tool handoff. A crash between those points leaves a row a recovery holder resolves under a fenced AdmissionRecoveryLease.
  4. Authorize the budget hold. Before the tool runs, the kernel authorizes a worst-case exposure hold against the budget. An execution nonce, chio.execution_nonce.v1, cross-binds that hold to this call (ADR-0016, the authoritative spend contract). See Budgets & Metering.
  5. Dispatch to the sandboxed tool server. The tool runs in isolation from the agent and other tools.
  6. Reconcile the charge. After the tool returns, the kernel reconciles the hold down to realized cost: authorize, then reconcile, with a refund on deny. This is the authoritative spend point.
  7. Sign the receipt. Each allow or deny decision is signed into an append-only, content-addressed receipt over RFC 8785 canonical JSON. See Receipts.

What the kernel persists

The durable admission states in three bands: before dispatch, prepared, broker_attempt_registered, approval_required, budget_authorized, approval_reserved, ready_to_dispatch, capture_pending; dispatched, dispatch_committed, finalizing; terminal, completed, compensated_before_dispatch, not_accepted_after_dispatch_commit, outcome_unknown_after_dispatch, denied_after_deliverybefore dispatchdispatchedterminalPrepared (before dispatch)preparedBrokerAttemptRegistered (before dispatch)broker_attempt_registeredApprovalRequired (before dispatch)approval_requiredBudgetAuthorized (before dispatch)budget_authorizedApprovalReserved (before dispatch)approval_reservedReadyToDispatch (before dispatch)ready_to_dispatchCapturePending (before dispatch)capture_pendingDispatchCommitteddispatch_committedFinalizingfinalizingCompleted (terminal)completedterminalCompensatedBeforeDispatch (terminal)compensated_before_dispatchterminalNotAcceptedAfterDispatchCommit (terminal)not_accepted_after_dispatch_committerminalOutcomeUnknownAfterDispatch (terminal)outcome_unknown_after_dispatchterminalDeniedAfterDelivery (terminal)denied_after_deliveryterminal18 states, 7 of them terminalthe dispatch kind's legal moves depend on which participants an operation requires, so they are not drawn here
The durable admission states in three bands, with the terminal ones marked. The dispatch kind's legal moves are a predicate over an operation's participants, so they are not drawn.
sourcecrates/kernel/chio-kernel/src/admission_operation.rs:167-189at fe56570

The list above is a reading order. It is not the record. What the kernel writes down is AdmissionOperationState, an enum of 18 variants, 7 of them terminal, and the two do not line up one to one. No variant names capability admission or guard evaluation. The budget step appears twice, as budget_authorized and capture_pending. Participants the list folds away get their own variants: approval_required and approval_reserved for a human approver, broker_attempt_registered for a broker.

Four terminal variants record endings the list has no slot for. compensated_before_dispatch ends a call that never reached the tool. not_accepted_after_dispatch_commit records a provider that refused the handoff. outcome_unknown_after_dispatch records that the kernel handed the call off and cannot say what happened next. denied_after_delivery carries its own note in the enum: the delivered output did not match a grant's committed output digest, so a signed deny is persisted, the open hold is released, and zero is captured.

Two operation kinds share the enum. The governed economic-mutation kind moves through one closed table of pairs. The dispatch kind does not: legality there depends on which participants an operation requires, so is_legal_transition decides each move from AdmissionParticipantRequirements rather than from a list of edges.

crates/kernel/chio-kernel/src/admission_operation/state.rs521-623rust
pub(super) fn is_legal_transition(
    kind: AdmissionOperationKind,
    requirements: AdmissionParticipantRequirements,
    from: AdmissionOperationState,
    to: AdmissionOperationState,
) -> bool {
    if kind == AdmissionOperationKind::GovernedEconomicMutation {
        return matches!(
            (from, to),
            (
                AdmissionOperationState::Prepared,
                AdmissionOperationState::MutationReady
            ) | (
                AdmissionOperationState::MutationReady,
                AdmissionOperationState::MutationSubmitted
            ) | (
                AdmissionOperationState::Prepared
                    | AdmissionOperationState::MutationReady
                    | AdmissionOperationState::MutationSubmitted,
                AdmissionOperationState::EconomicMutationNotApplied
            ) | (
                AdmissionOperationState::MutationSubmitted,
                AdmissionOperationState::EconomicMutationApplied
            )
        );
    }
    if !kind.uses_dispatch() {
        return false;
    }
    if to == AdmissionOperationState::CompensatedBeforeDispatch {
        return from.is_pre_dispatch() && predispatch_state_enabled(requirements, from);
    }
    let ready_source = if requirements.approval {
        AdmissionOperationState::ApprovalReserved
    } else if requirements.budget_capture {
        AdmissionOperationState::BudgetAuthorized
    } else {
        AdmissionOperationState::Prepared
    };
    match to {
        AdmissionOperationState::BrokerAttemptRegistered => {
            requirements.broker_attempt && from == AdmissionOperationState::Prepared
        }
        AdmissionOperationState::BudgetAuthorized => {
            requirements.budget_capture
                && ((requirements.approval && from == AdmissionOperationState::ApprovalRequired)
                    || from
                        == if requirements.broker_attempt {
                            AdmissionOperationState::BrokerAttemptRegistered
                        } else {
                            AdmissionOperationState::Prepared
                        })
        }
        AdmissionOperationState::ApprovalRequired => {
            requirements.budget_capture
                && requirements.approval
                && from
                    == if requirements.broker_attempt {
                        AdmissionOperationState::BrokerAttemptRegistered
                    } else {
                        AdmissionOperationState::Prepared
                    }
        }
        AdmissionOperationState::ApprovalReserved => {
            requirements.approval
                && from
                    == if requirements.budget_capture {
                        AdmissionOperationState::BudgetAuthorized
                    } else {
                        AdmissionOperationState::Prepared
                    }
        }
        AdmissionOperationState::ReadyToDispatch => {
            from == ready_source
                || (kind == AdmissionOperationKind::ToolDispatch
                    && requirements.budget_capture
                    && requirements.approval
                    && from == AdmissionOperationState::BudgetAuthorized)
        }
        AdmissionOperationState::CapturePending => {
            requirements.budget_capture && from == AdmissionOperationState::ReadyToDispatch
        }
        AdmissionOperationState::DispatchCommitted => {
            from == if requirements.budget_capture {
                AdmissionOperationState::CapturePending
            } else {
                AdmissionOperationState::ReadyToDispatch
            }
        }
        AdmissionOperationState::Finalizing
        | AdmissionOperationState::NotAcceptedAfterDispatchCommit
        | AdmissionOperationState::OutcomeUnknownAfterDispatch => {
            from == AdmissionOperationState::DispatchCommitted
                || (to == AdmissionOperationState::OutcomeUnknownAfterDispatch
                    && from == AdmissionOperationState::Finalizing)
        }
        AdmissionOperationState::Completed => from == AdmissionOperationState::Finalizing,
        // A delivery-digest mismatch is decided during finalization, so
        // the only legal predecessor is Finalizing.
        AdmissionOperationState::DeniedAfterDelivery => from == AdmissionOperationState::Finalizing,
        _ => false,
    }
}

How it works

Running one call

chio check evaluates a single tool call against a policy without spawning a subprocess; an in-process probe stands in for the tool server. It scaffolds nothing on its own, so start from chio init, which writes a policy granting one tool.

quickstart · inittranscript
$ chio init my-agent
created Chio scaffold at ~/chio/my-agent

Next steps:
  cd ~/chio/my-agent
  cargo build
  CHIO_BIN=chio cargo run --quiet --bin demo
exit 0

The starter policy grants hello_world on server hello, enables the forbidden_path and shell_command guards, and caps capability lifetime and delegation depth.

crates/products/chio-cli/templates/init/policy.yaml.tmplyaml
# Chio starter policy for {{PROJECT_NAME}}

kernel:
  max_capability_ttl: 600
  delegation_depth_limit: 3
  # The quickstart keeps revocation state in memory so the demo runs without
  # any database setup. Production deployments should remove this opt-in and
  # configure a durable revocation store (for example `--revocation-db`) so a
  # revoked capability stays revoked across restarts.
  allow_ephemeral_revocation_store: true

guards:
  forbidden_path:
    enabled: true
  shell_command:
    enabled: true

capabilities:
  default:
    tools:
      - server: "hello"
        tool: "hello_world"
        operations: [invoke]
        ttl: 300

Each evaluation writes durable admission state, so give each call its own --session-db and point every call at one shared --receipt-db. A call inside the granted scope returns ALLOW and exits 0.

quickstart · check-allowtranscript
$ chio --receipt-db .chio/receipts.db --session-db "$(mktemp -d)/admission.db" check \
    --policy ./policy.yaml --server hello --tool hello_world --params '{"name":"Chio"}'
verdict:    ALLOW
tool:       hello_world
server:     hello
receipt_id: 448440b65fa15559a364d24512cbe4f08631befe2c3d2ab471dad7204b8b69c8
policy:     69e943b96e9ce64d0264bb56bf8930e77bd4e68adf68fcf1395790dae03e6b55
source:     0d35135e8b19230b7aa42b0cc5982579450f5dc4a0280822e8b339e7786d2296
mode:       preflight
fixture:    false
exit 0allowin my-agent

receipt_id is the receipt's content address. chio_receipt_id in crates/core/chio-core-types/src/receipt/body.rs derives it as the hex SHA-256 of the canonical JSON of the receipt's identity fields, so it is 64 lowercase hex characters. policy is the runtime hash of the loaded policy, the value the receipt carries as policy_hash; source is the hash of the policy bytes, which the receipt does not carry. See Receipts for how each is derived.

mode: preflight is the default. Preflight is an admission-only check: given a policy that carries any post-invocation guard, chio check refuses rather than guess at a tool result. Evaluating output-sensitive policy needs --mode full together with an --output-fixture, which supplies the tool output the hooks read.

Admitting the capability

The kernel checks the capability signature, expiry, and whether the requested operation is a subset of what the capability grants, using ToolGrant::is_subset_of for the attenuation check. A capability scoped to read a resource cannot be used to write it. See Capabilities for how grants are constructed and attenuated.

Surviving a crash between effect and receipt

Side effects and signed receipts do not fail together. A tool call that transfers funds or writes to an external system can succeed at the tool and still lose the kernel process before a receipt is signed. The kernel closes that window with the durable admission operation rather than with a separate journal row that is deleted on success: docs/architecture/reliability/RFC-0003-dispatch-intent-journal.md rejects the delete-on-success form and makes the operation authoritative, because deleting a request-keyed row also deletes the replay protection. Budget, payment, approval, nonce, provider acceptance, receipt, observer, and obligation state are participants in one fenced saga keyed by operation_id.

Budget hold, execution nonce, and reconciliation

Before dispatch, the kernel authorizes a hold for the maximum cost of the call. The chio.execution_nonce.v1 nonce binds that hold to one call (ADR-0016). After the tool returns, reconciliation replaces the maximum with the realized cost, and a denial releases the hold. See Budgets & Metering.

Guards and output hooks

Guards run before dispatch and return Allow, Deny, or PendingApproval. A denial or pending approval prevents the tool from running. Output hooks run after dispatch and inspect the tool result. They return Allow, Block, Redact, or Escalate; a hook can redact a secret from tool output before the caller sees it.

On the durable admission path the kernel runs the post-invocation pipeline and, for a grant carrying Constraint::OutputDigestSha256, compares the digest of the delivered value with the digest the grant fixed, before any payment decision; a mismatch is a signed zero-charge deny, described under The Delivery Contract. The legacy non-durable path runs no post-invocation pipeline on a charged call, so the kernel refuses a digest-constrained grant on that path before dispatch.

Signing the receipt

The kernel signs a receipt for each allow or deny decision over RFC 8785 canonical JSON. A guard denial therefore has a receipt even though the call did not reach dispatch. See Receipts.


Guarantees and limits

An error is a denial

A guard that returns Err does not get skipped. GuardPipeline::evaluate in crates/guards/chio-guards/src/pipeline.rs catches the error, appends a GuardEvidence entry reading action=error; reason=fail-closed, and returns a deny for the whole pipeline. If the kernel already authorized a hold, it releases that hold.

A denial, end to end

Ask the same policy for drop_tables, which no grant covers. The request never reaches a guard: capability admission refuses it, the reason names the scope, and chio check exits 2.

quickstart · check-denytranscript
$ chio --receipt-db .chio/receipts.db --session-db "$(mktemp -d)/admission.db" check \
    --policy ./policy.yaml --server hello --tool drop_tables --params '{}'
verdict:    DENY
tool:       drop_tables
server:     hello
reason:     requested tool drop_tables on server hello is not in capability scope
receipt_id: 28b6e2576ca2ccbd031e769d8d2bd504317161115a600f27771a7f932307a284
policy:     69e943b96e9ce64d0264bb56bf8930e77bd4e68adf68fcf1395790dae03e6b55
source:     0d35135e8b19230b7aa42b0cc5982579450f5dc4a0280822e8b339e7786d2296
mode:       preflight
fixture:    false
WARN chio_kernel::kernel::evaluation::async_evaluation_core message=capability rejected request_id=check-001 reason=requested tool drop_tables on server hello is not in capability scope
exit 2denyin my-agent

The refusal still carries a receipt id, so the signing step ran even though the call never reached dispatch. chio receipt explain reads that id back out of the log.

quickstart · receipt-explaintranscript
$ DENY=$(chio --receipt-db .chio/receipts.db receipt list --admin-all \
    | jq -r 'select(.decision.verdict == "deny") | .id')
$ chio --receipt-db .chio/receipts.db receipt explain "$DENY" --admin-all
receipt: 28b6e2576ca2ccbd031e769d8d2bd504317161115a600f27771a7f932307a284
schema: chio.receipt.v1
identity: 28b6e2576ca2ccbd031e769d8d2bd504317161115a600f27771a7f932307a284
decision: deny
reason: requested tool drop_tables on server hello is not in capability scope
guard: kernel
policy_hash: 69e943b96e9ce64d0264bb56bf8930e77bd4e68adf68fcf1395790dae03e6b55
scope_diff: requested scope vs granted scope is not embedded in this receipt
parents: 0
repair_hint: inspect the guard and policy_hash, then mint or narrow a matching capability
exit 0in my-agent

guard: kernel names the component that rendered the verdict, not a guard in the pipeline. The stored record shows the same thing from the other side: it carries no evidence key at all, because no guard ran and the field is omitted when its array is empty.

quickstart · receipt-jsontranscript
$ chio --receipt-db .chio/receipts.db receipt list --admin-all \
    | jq 'select(.decision.verdict == "deny")'
{
  "id": "28b6e2576ca2ccbd031e769d8d2bd504317161115a600f27771a7f932307a284",
  "timestamp": 1788529912,
  "capability_id": "cap-01a06cb0-aae3-7ad3-8ba7-01350a3e01a0",
  "tool_server": "hello",
  "tool_name": "drop_tables",
  "action": {
    "parameters": {},
    "parameter_hash": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
  },
  "decision": {
    "verdict": "deny",
    "reason": "requested tool drop_tables on server hello is not in capability scope",
    "guard": "kernel"
  },
  "receipt_kind": "mediated_decision",
  "boundary_class": "prevent",
  "tool_origin": "caller_executed",
  "redaction_mode": "none",
  "content_hash": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
  "policy_hash": "69e943b96e9ce64d0264bb56bf8930e77bd4e68adf68fcf1395790dae03e6b55",
  "metadata": {
    "attribution": {
      "delegation_depth": 0,
      "issuer_key": "3dbf7b1230475796d5c78cdcf441c84721d9e3c24362fbbfb4225e307d1b9fb4",
      "subject_key": "af2da8097f2c133affd89145302f029c7a5854e4e3a692a92b0cd20f84d07b37"
    },
    "chio_receipt_signing_nonce": "rcpt-01a06cb0-ab51-70d0-bcb3-72a8b78fe057",
    "receipt_context": {
      "request_id": "check-001"
    }
  },
  "trust_level": "mediated",
  "kernel_key": "3dbf7b1230475796d5c78cdcf441c84721d9e3c24362fbbfb4225e307d1b9fb4",
  "signature": "e89f7a9cbe6c6b207cf5ac0c3e197a3957d62d2994217a7dcaeeeb9869abef1233680328432ee7a502a76238eb6ee9e72b19715e2458a3ef85aebfbed0df9202"
}
exit 0denyin my-agent

A guard denial reads differently. The kernel builds the inner message as guard "{name}" denied the request from the denying guard's own name and wraps it in GuardDenied. The pipeline pushes one evidence entry naming that guard, so a guard denial has an evidence key where this capability refusal has none. See Receipts for what the array holds on an allow.

Which check refused

The reason string identifies the check. Each format below is a KernelError variant in crates/kernel/chio-kernel/src/kernel/error.rs.

CheckVariantReason format
Capability admissionOutOfScoperequested tool {tool} on server {server} is not in capability scope
Capability admissionCapabilityRevokedcapability has been revoked: {0}
Guard pipelineGuardDeniedguard denied the request: {0}
Budget holdBudgetExhaustedinvocation budget exhausted for capability {0}

The end-to-end suite drives each of those through the kernel in process. tests/e2e/tests/full_flow.rs asserts Verdict::Deny for a forbidden path, a dangerous shell command, a tool outside the grant, an expired capability, a revoked capability, a revocation cascade, a guard that errors, and an exhausted budget, and it checks that the deny receipt's signature still verifies.

tests/e2e/tests/full_flow.rsrust
// Test: Denied by guard -- forbidden path
#[tokio::test]
async fn full_flow_denied_by_forbidden_path() {
    let (kernel, _ca_kp) = make_kernel_with_guards();
    let agent_kp = Keypair::generate();
    let cap = issue_wildcard_cap(&kernel, &agent_kp.public_key());

    let req = make_request(
        "req-forbidden",
        &cap,
        "read_file",
        serde_json::json!({"path": "/etc/shadow"}),
    );

    let resp = kernel.evaluate_tool_call(&req).await.unwrap();

    assert_eq!(resp.verdict, Verdict::Deny);
    assert!(resp.output.is_none());
    let reason = resp.reason.as_deref().unwrap_or("");
    assert!(
        reason.contains("forbidden") || reason.contains("denied"),
        "expected denial by forbidden_path guard, got: {reason}"
    );

    // The receipt should be Deny and its signature should still verify.
    assert!(resp.receipt.is_denied());
    assert!(
        resp.receipt.verify_signature().unwrap(),
        "deny receipt signature must verify"
    );
}
run the suitebash
cargo test -p chio-e2e --test full_flow

Kernel core and I/O

In chio-kernel-core, signature, expiry, scope-subset, and guard checks perform no I/O. The crate is no_std with alloc, so the same verdict-producing code runs in a browser, a Cloudflare Worker, a mobile app, and the desktop sidecar. Budget updates, admission writes, and receipt persistence happen outside that core. See Portable Kernel.

Next steps