Chio/Docs
LOGIN · JOIN

Govern Any Provider

Send Anthropic and Bedrock tool calls through the Chio kernel and return provider-native results under each call's original ID.

Guide chio@5f63e6e

On this page

A multi-provider agent keeps one execution authority. Anthropic tool_use blocks and Bedrock toolUse blocks reach the same kernel as OpenAI function calls, and the kernel admits each one before its registered tool runs.

Each provider adapter lifts the native call into one shared ToolInvocation, and your host supplies the capability and target server. After the kernel decides, the adapter lowers the result into the provider's own reply format under the originating call ID.

Start

Three formats lift into one invocation the kernel admits

A ToolInvocation names the provider, the tool, the canonical-JSON argument bytes, and a ProvenanceStamp that binds the call to its upstream request ID and principal. The provenance describes where the call came from; authorization still comes from the capability your host resolves.

Provider tool callsChoose an outcome.

κcapabilityσscopegguardsBbudget

Lift, admit, lower, with the same call ID

The adapter lifts the native call into a ToolInvocation that names the provider, the tool, the canonical-JSON argument bytes, and a provenance stamp. The kernel admits it and dispatches the registered tool, and the adapter lowers the output into a native reply with the same call ID. Govern Any Provider.

One provider tool call lifted into the shared invocation contract, admitted by the kernel, and lowered back to the provider’s format. Provider transport and the shared error taxonomy are left out.

An allowed call runs the registered tool once, and the adapter lowers its output into a native reply with the same call ID. A refused call comes back as the provider's error form, and the tool never runs. A streamed call waits at the block boundary until its verdict arrives.

1. Add the adapters to an embedded host

Start from the embedded host project, which creates a kernel, registers an application-owned tool, issues a bounded capability, and saves receipts. Then add the provider crates your host needs.

Cargo.tomltoml
[dependencies]
chio-anthropic-tools-adapter = { git = "https://github.com/backbay-labs/chio" }
chio-bedrock-converse-adapter = { git = "https://github.com/backbay-labs/chio" }
chio-tool-call-fabric = { git = "https://github.com/backbay-labs/chio" }
chio-kernel = { git = "https://github.com/backbay-labs/chio" }
chio-core = { package = "chio-core-types", git = "https://github.com/backbay-labs/chio" }
serde_json = "1"
anyhow = "1"
aws-config = "1" # Bedrock host setup
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Run cargo build. You should see it resolve the adapter, fabric, and kernel crates from https://github.com/backbay-labs/chio; keep only the providers you use. Live requests need provider credentials. The fixtures and replay checks in step 6 run offline, and a long-lived service needs durable state configured on its kernel.

2. Execute each invocation once and keep its output

The kernel's provider_verdict module turns a ToolInvocation into a kernel request. This helper validates the invocation, evaluates it, runs the registered tool on allow, and returns the verdict with the executed output for lowering.

execute_invocation.rsrust
use chio_core::{capability::token::CapabilityToken, canonical::canonical_json_bytes};
use chio_kernel::{AgentId, ChioKernel, ServerId, ToolCallOutput};
use chio_kernel::provider_verdict::{build_tool_call_request, verdict_result_from_response};
use chio_tool_call_fabric::{ToolInvocation, ToolResult, VerdictResult};

pub async fn execute_invocation(
    kernel: &ChioKernel,
    invocation: &ToolInvocation,
    capability: CapabilityToken,
    agent_id: AgentId,
    server_id: ServerId,
) -> anyhow::Result<(VerdictResult, ToolResult)> {
    invocation.validate()?;
    let request = build_tool_call_request(invocation, capability, agent_id, server_id)?;
    let response = kernel.evaluate_tool_call(&request).await?;
    let verdict = verdict_result_from_response(invocation, &response);
    let output = match (&verdict, response.output) {
        (VerdictResult::Allow { .. }, Some(ToolCallOutput::Value(value))) => value,
        (VerdictResult::Allow { .. }, Some(ToolCallOutput::Stream(_))) => {
            anyhow::bail!("handle stream output in the host; this invocation already executed")
        }
        _ => serde_json::Value::Null,
    };
    Ok((verdict, ToolResult(canonical_json_bytes(&output)?)))
}

You should see one evaluation per invocation; the adapter makes no second backend call. The host supplies the complete capability, agent, and target server, and the adapter's principal label replaces none of them. The basic builder carries the provider call ID, tool name, and argument JSON, and leaves the full provenance stamp and the extra authorization fields out: for a grant that requires DPoP, an execution nonce, an approval, or model metadata, set those fields on the built request before evaluation.

The fabric verdict is Allow or Deny. verdict_result_from_response lowers a pending approval, and an allow that has not reached the completed terminal state, to Deny, and it emits an empty redaction list. The deny reason's rule_id reads kernel.pending_approval or kernel.execution_nonce_preflight for those two cases; the receipt holds the full decision. verdict_for_provider_invocation evaluates the same request but returns only the verdict, so calling the tool again after it would repeat execution.

3. Mediate an Anthropic Messages turn

chio-anthropic-tools-adapter owns the transport to api.anthropic.com. send_messages forwards a native messages.create body to /v1/messages with the x-api-key and pinned anthropic-version: 2023-06-01 headers, then lifts every tool_use block in the response.

Anthropic host integration (excerpt)rust
use std::sync::Arc;
use chio_anthropic_tools_adapter::{
    anthropic_transport_from_env, AnthropicAdapter, AnthropicAdapterConfig,
};
use chio_tool_call_fabric::{ToolResult, VerdictResult};

// ::new pins api_version to ANTHROPIC_VERSION ("2023-06-01"). workspace_id
// populates Principal::AnthropicWorkspace on every emitted ProvenanceStamp.
let config = AnthropicAdapterConfig::new(
    "anthropic-front",                        // server_id
    "Anthropic Messages",                     // server_name
    "1.0.0",                                  // server_version
    std::env::var("CHIO_SERVER_PUBLIC_KEY")?, // hex Ed25519 public key
    "wks_prod",                               // Anthropic workspace id
);

// Reads x-api-key from ANTHROPIC_API_KEY.
let transport = anthropic_transport_from_env()?;
let adapter = AnthropicAdapter::new(config, Arc::new(transport));

// Forward the messages.create body; lift every tool_use block in the reply.
let invocations = adapter.send_messages(&request_body).await?;

for invocation in &invocations {
    invocation.validate()?;                 // fail closed before the kernel
    let (verdict, output) = execute_invocation(
        &kernel, invocation, capability.clone(), agent_id.clone(), server_id.clone(),
    ).await?;

    // Lower the verdict + executed output into a tool_result content block
    // for the next user turn. The request_id is the tool_use id (toolu_...).
    let block = adapter.lower_tool_result_block(
        &invocation.provenance.request_id,
        verdict,
        output,
    )?;
    // block.is_error is true on deny; its content carries the DenyReason.
}

You should see one tool_result block per tool_use ID. On deny the block has is_error: true and its content describes the DenyReason, so the model receives the refusal. An input refusal prevents dispatch; a post-invocation refusal can follow tool execution. To drive the batch path yourself, lift_batch takes a ProviderRequest holding a response payload and returns the same Vec<ToolInvocation>.

The native computer_use, bash, and text_editor tools pass two gates: the computer-use cargo feature and a server_tools entry in the manifest. AnthropicAdapter::new starts with a deny-all gate, so build with new_with_manifest when you use them; the native tool gate entry lists the names.

4. Mediate a Bedrock Converse turn

chio-bedrock-converse-adapter drives the Bedrock Runtime Converse operation through the AWS SDK for Rust, SigV4-signed, and lifts every toolUse block. BEDROCK_REGION is us-east-1 and BEDROCK_CONVERSE_API_VERSION is bedrock.converse.v1; BedrockAdapterConfig::validate and BedrockAdapter::new reject any other pair, and the transport re-checks the region on every call.

Bedrock host integration (excerpt)rust
use std::sync::Arc;
use aws_config::BehaviorVersion;
use chio_bedrock_converse_adapter::{
    AwsSdkTransport, BedrockAdapter, BedrockAdapterConfig, ConverseRequest,
    DEFAULT_IAM_PRINCIPALS_CONFIG_PATH,
};
use chio_tool_call_fabric::{ToolResult, VerdictResult};

// ::new pins region to us-east-1 and api_version to bedrock.converse.v1.
let config = BedrockAdapterConfig::new(
    "bedrock-front",
    "Bedrock Converse",
    "1.0.0",
    std::env::var("CHIO_SERVER_PUBLIC_KEY")?,
    "arn:aws:iam::123456789012:role/ChioAgentRole", // overwritten on the signed path
    "123456789012",
);

let sdk_config = aws_config::defaults(BehaviorVersion::latest())
    .region("us-east-1")
    .load()
    .await;
let transport = AwsSdkTransport::from_sdk_config(&sdk_config)?;

// Production init resolves the IAM caller from a signed map before any
// tool traffic can be lifted (see below).
let adapter = BedrockAdapter::new_with_signed_iam_principals_config_from_sts(
    config,
    Arc::new(transport),
    &sts_provider,
    DEFAULT_IAM_PRINCIPALS_CONFIG_PATH,
    &verifier,
    &expected_identity,
).await?;

// model_id is a tool-capable model enabled for your AWS account.
// Drive one Converse turn and lift every toolUse block.
let request = ConverseRequest::new(model_id, messages);
let invocations = adapter.converse(request).await?;

for invocation in &invocations {
    let (verdict, output) = execute_invocation(
        &kernel, invocation, capability.clone(), agent_id.clone(), server_id.clone(),
    ).await?;
    // response.0 is Bedrock JSON: { "toolResult": { toolUseId, content, status } }
    let response = adapter.lower_tool_result(
        &invocation.provenance.request_id, // toolUseId
        verdict,
        output,
    )?;
}

You should see one toolResult per toolUseId. On allow, lower_tool_result applies JSON Pointer redactions and wraps the output as Bedrock content; on deny it emits status: "error" carrying a chio denial payload with the verdict, receipt ID, and reason. When the response carries a toolConfig, lift_batch refuses any lifted tool name the config does not declare, as Malformed.

Bedrock has no per-request principal header, so the adapter resolves the calling IAM identity once per process from an operator-signed map, before any tool traffic is lifted. The IAM caller entry gives the file format and its fail-closed conditions.

5. Gate streamed tool-call blocks

Both adapters drive the streaming state machine in chio-tool-call-fabric. It buffers frames from content_block_start through content_block_stop, or contentBlockStart through contentBlockStop on Bedrock, evaluates the completed block, then flushes it on allow or drops it on deny. StreamPhase walks Idle, Buffering, Emitting, Closed.

Streaming evaluator contract (integration excerpt)rust
// Anthropic SSE: buffer each tool_use block through content_block_stop,
// evaluate it, and release its bytes only if the verdict allows.
let gated = adapter.gate_sse_stream(&sse_bytes, |invocation| {
    evaluate_block(invocation) // host: Result<VerdictResult, ProviderError>
})?;

// gated.bytes:       SSE frames that are safe to forward downstream.
// gated.invocations: the tool_use blocks evaluated, in stream order.
// gated.verdicts:    the verdict returned for each, in the same order.

// Bedrock is the mirror image: gate_converse_stream buffers each block
// from contentBlockStart through contentBlockStop and releases it on
// allow, returning a GatedConverseStream. That struct adds a fourth
// field, "events", which GatedSseStream does not have.

You should see only allowed blocks in gated.bytes. The callback is synchronous and yours: define whether it authorizes forwarding or executes the tool, enforce its deadline, and keep the downstream consumer from executing a completed invocation a second time.

A block is capped at DEFAULT_MAX_BUFFERED_BLOCK_BYTES (1 MiB) and DEFAULT_MAX_BUFFERED_RAW_FRAMES (4096 frames), and an overflow fails the stream. The adapters leave timing to your evaluator: they preserve the ProviderError::VerdictBudgetExceeded it returns, with observed_ms and budget_ms, and fail the stream closed, so a slow verdict never becomes an allow. The streaming path refuses an allow that carries redactions; apply redactions on the non-streaming path.

6. Verify the adapters, then one refused call

The first three checks run without provider credentials. A live refused call after the list ends in a signed receipt.

  1. Construction holds your pinned values. BedrockAdapter::new returns a Result and rejects any region or API version other than the pinned pair, so a misconfigured deployment fails at boot. The Anthropic config pins its API version the same way.
  2. A lifted invocation validates. Call invocation.validate() before the kernel, every time, replay paths included. Assert on a mismatched principal variant and on non-canonical argument bytes.
  3. A capture replays. Run chio replay traffic --from <capture> --json over a recorded stream and assert ok and passes; add --against to compare verdicts.
  4. A recognized native tool is refused without its manifest entry. Construct with new, then confirm each recognized native-tool call fails closed even with the computer-use feature compiled.
Replay a capture as one JSON envelopetranscript
$ chio replay traffic \
  --from ./capture.ndjson --json
{"first_error":null,"from":"<chio-source>/crates/products/chio-cli/tests/replay_traffic/fixtures/clean_match.ndjson","ok":true,"passes":1,"schema":"chio-tee-frame.v1","total":1}
exit 0

You should see "ok":true with every frame passing. Then send one live call outside the grant: the Anthropic reply carries is_error: true, and the Bedrock reply's chio payload names the receipt ID of the signed refusal.

Each adapter pins its provider's API and identity

Three crates implement the ProviderAdapter trait. Each pins a provider API version and stamps a principal from that provider's own identity boundary.

AdapterNative callPrincipalPinned API
chio-anthropic-tools-adaptertool_useAnthropicWorkspace2023-06-01
chio-bedrock-converse-adaptertoolUseBedrockIambedrock.converse.v1
chio-openai-adapterfunction_callOpenAiOrgresponses.2026-04-25

Gemini, Groq, Mistral, Cohere, and Ollama use the same ToolInvocation, ProviderError, and VerdictResult vocabulary in their own lift and lower functions without implementing the trait. All eight appear in ProviderId, listed in the reference below.

Tool-call fabric reference

Provider identities

chio-tool-call-fabric defines the shape a native adapter's lift produces, the verdict shape its lower consumes, and the streaming state machine. It holds no provider-specific logic and performs no I/O. Each ProviderId carries a Principal variant scoped to that provider's native identity boundary.

ProviderIdPrincipal variantIdentity scope
open_ai
OpenAiOrg
org_id
anthropic
AnthropicWorkspace
workspace_id
bedrock
BedrockIam
caller_arn, account_id, optional assumed_role_session_arn
gemini
GeminiProject
project_id
groq
GroqProject
project_id
mistral
MistralProject
project_id
cohere
CohereOrg
org_id
ollama
OllamaHost
host (local daemon, no upstream identity provider)

Lift and lower contract

A ToolInvocation is what each adapter's lift emits.

crates/protocol/chio-tool-call-fabric/src/types.rs67-74rust
pub struct ToolInvocation {
    pub provider: ProviderId,
    pub tool_name: String,
    /// Canonical-JSON bytes (RFC 8785). Stored as raw bytes so the kernel can
    /// hash without re-serializing.
    pub arguments: Vec<u8>,
    pub provenance: ProvenanceStamp,
}
crates/protocol/chio-tool-call-fabric/src/types.rs58-64rust
pub struct ProvenanceStamp {
    pub provider: ProviderId,
    pub request_id: String,
    pub api_version: String,
    pub principal: Principal,
    pub received_at: SystemTime,
}

arguments is a byte vector, so a serialized ToolInvocation shows it as an array of integers. This is the Anthropic single-tool fixture the crate pins its wire contract against; its seventeen bytes decode to {"ticker":"ANTH"}.

Anthropic single-tool fixturetranscript
$ jq . crates/protocol/chio-tool-call-fabric/fixtures/lift_lower/anthropic/single_tool_use.json
{
  "arguments": [
    123,
    34,
    116,
    105,
    99,
    107,
    101,
    114,
    34,
    58,
    34,
    65,
    78,
    84,
    72,
    34,
    125
  ],
  "provenance": {
    "api_version": "anthropic.2023-06-01",
    "principal": {
      "kind": "anthropic_workspace",
      "workspace_id": "wks_chio_demo"
    },
    "provider": "anthropic",
    "received_at": {
      "nanos_since_epoch": 0,
      "secs_since_epoch": 1745452803
    },
    "request_id": "msg_01abcdEFGHJK1234567890mn"
  },
  "provider": "anthropic",
  "tool_name": "get_stock_price"
}
exit 0

The keys are alphabetical because the file is canonical JSON, and api_version here is the fixture's own label rather than the adapter constant. The Bedrock fixtures carry the richer principal.

Bedrock principal fixturetranscript
$ jq '.provenance.principal' \
  crates/protocol/chio-tool-call-fabric/fixtures/lift_lower/bedrock/assumed_role.json
{
  "account_id": "123456789012",
  "assumed_role_session_arn": "arn:aws:sts::123456789012:assumed-role/ChioAgentRole/session-1",
  "caller_arn": "arn:aws:iam::123456789012:role/ChioAgentRole",
  "kind": "bedrock_iam"
}
exit 0

ToolInvocation::validate is the fail-closed check an adapter or replay consumer runs on a value that crossed a boundary. It enforces what the struct shape alone cannot:

  • provider equals provenance.provider.
  • The Principal variant matches the provider; a Bedrock invocation carrying an AnthropicWorkspace principal is rejected.
  • tool_name, request_id, and api_version are non-empty, free of surrounding whitespace, and free of control characters.
  • The same rules hold for the principal's fields: org_id, workspace_id, caller_arn, account_id, assumed_role_session_arn, project_id, and host.
  • arguments re-canonicalizes to itself byte for byte. Bytes that are not JSON fail first with InvalidArgumentJson; JSON that is not canonical fails with NonCanonicalArguments.

On the way back, the kernel returns a VerdictResult, which the adapter lowers into the provider's tool-result shape.

crates/protocol/chio-tool-call-fabric/src/types.rs242-251rust
pub enum VerdictResult {
    Allow {
        redactions: Vec<Redaction>,
        receipt_id: ReceiptId,
    },
    Deny {
        reason: DenyReason,
        receipt_id: ReceiptId,
    },
}
crates/protocol/chio-tool-call-fabric/src/types.rs232-238rust
pub enum DenyReason {
    PolicyDeny { rule_id: String },
    GuardDeny { guard_id: String, detail: String },
    CapabilityExpired,
    PrincipalUnknown,
    BudgetExceeded,
}

A Redaction is a JSON Pointer path plus a replacement string, applied to the tool result on the allow path; a path that does not start with / is refused. The kernel-side conversion always returns an empty redaction list, so redactions come from an adapter or a caller. Three opaque byte wrappers keep wire handling in the adapter: ProviderRequest (upstream request bytes an adapter lifts), ProviderResponse (bytes lower hands back to the transport), and ToolResult (canonical-JSON tool output for consistent downstream verification).

Anthropic native tool gate

The adapter recognizes the native names computer_use, bash, and text_editor, plus their date-suffixed forms, and fails closed on them unless both gates are open: the computer-use cargo feature is compiled in (it also adds the anthropic-beta: computer-use-2025-01-24 header), and the manifest's server_tools list names the matching stable entry. Only tools named in server_tools pass ensure_tool_allowed.

Adapter-recognized nameManifest entry
computer_use, computer_use_YYYYMMDD
computer_use
bash, bash_YYYYMMDD
bash
text_editor, text_editor_YYYYMMDD
text_editor

Any 8-digit date suffix folds onto the bare name, so a wire bump from bash_20241022 to a later date stays behind the same entry. Build the gate from a validated manifest with AnthropicAdapter::new_with_manifest; AnthropicAdapter::new denies every recognized native-tool call, even with the feature compiled. Other names skip this gate, and their kernel capability checks still apply. Match support for a new provider toolset to the adapter's pinned beta contract, never to a date suffix alone.

The field name server_tools refers to the Chio manifest. Computer, Bash, and text-editor calls are client-executed tools, which Anthropic's tool reference distinguishes from provider-hosted server tools.

Bedrock IAM caller

new_with_signed_iam_principals_config_from_sts resolves STS GetCallerIdentity once per process, loads config/iam_principals.toml, checks the Sigstore bundle beside it (the TOML path with .sigstore-bundle.json appended, per sigstore_bundle_path) through the chio-attest-verify verifier, and maps the caller ARN to Principal::BedrockIam.

crates/protocol/chio-bedrock-converse-adapter/config/iam_principals.tomltoml
# iam_principals.toml
# Default signed-config path for Bedrock IAM principal mapping.
# Replace these sample mappings with operator-owned values and publish a
# matching Sigstore bundle at iam_principals.toml.sigstore-bundle.json.

default_action = "deny"
config_version = 1

[[mapping]]
match = "arn:aws:iam::123456789012:role/ChioAgentRole"
owner = "team-alpha"
notes = "sample exact role mapping"

[[mapping]]
match = "arn:aws:sts::123456789012:assumed-role/ChioAgentRole/*"
owner = "team-alpha"
notes = "sample assumed-role session mapping"

The mapping list is ordered and the first match wins; each match is an exact ARN or a * wildcard pattern. Loading rejects a config_version other than 1, a default_action other than deny, an empty mapping list, and any entry with a blank match or owner (crates/protocol/chio-bedrock-converse-adapter/src/iam_principals.rs:272-300). The shipped file is a sample: replace its mappings with operator-owned values and publish a matching Sigstore bundle beside it.

Resolution fails closed on a missing config file, a missing Sigstore bundle, a rejected signature, invalid TOML, an unsupported config_version, or a caller ARN with no matching entry. The bundle is checked before the TOML is parsed, so a signature problem masks any content problem behind it. For an STS assumed-role caller, the adapter keeps the session ARN in assumed_role_session_arn and the canonical role ARN in caller_arn. principal_owner() and matched_iam_principal_pattern() return the resolved owner and the matched pattern. The mapping is one case of binding an execution environment to a Chio principal, which Workload Identity covers.

Provider errors

Both adapters map upstream failures to the ProviderError taxonomy: seven named classes plus Other. Other wraps an anyhow::Error, and neither adapter crate depends on anyhow, so neither produces it. Anything an adapter cannot place lands in Malformed and fails closed.

crates/protocol/chio-tool-call-fabric/src/error.rs4-21rust
pub enum ProviderError {
    #[error("rate limited by upstream: retry after {retry_after_ms}ms")]
    RateLimited { retry_after_ms: u64 },
    #[error("upstream content policy denied request: {0}")]
    ContentPolicy(String),
    #[error("tool arguments failed schema validation: {0}")]
    BadToolArgs(String),
    #[error("upstream 5xx ({status}): {body}")]
    Upstream5xx { status: u16, body: String },
    #[error("transport timeout after {ms}ms")]
    TransportTimeout { ms: u64 },
    #[error("verdict latency budget exceeded ({observed_ms}ms > {budget_ms}ms); fail-closed")]
    VerdictBudgetExceeded { observed_ms: u64, budget_ms: u64 },
    #[error("malformed upstream payload: {0}")]
    Malformed(String),
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}
  • RateLimited. HTTP 429 on the Anthropic path, ThrottlingException on Bedrock. retry_after_ms is a constant, 0 for Anthropic and 1000 for Bedrock; neither adapter reads a provider retry hint, and the Anthropic transport surfaces no response headers.
  • ContentPolicy. HTTP 403 from the provider, on the Anthropic path only. A refusal inside a 200 response is not parsed into it, and a Bedrock guardrail intervention arrives as another class.
  • BadToolArgs. A tool_use.input or toolUse.input that cannot become canonical JSON object arguments; also any non-403 4xx on the HTTP path, and a malformed tool result on the Bedrock lower path.
  • Upstream5xx. Any status in the 500 range, which is where an overload response lands. Bedrock also routes ModelTimeoutException here, with status 408.
  • TransportTimeout. A local per-call timeout, classified apart from upstream timeout envelopes.
  • VerdictBudgetExceeded. Your evaluator reported missing its own budget. The adapter preserves observed_ms and budget_ms and fails the stream closed.
  • Malformed. Impossible or out-of-order native payloads; the fail-closed default.

Detached provenance

A kernel receipt signs its own fields, and the basic invocation-to-request builder leaves the full stamp out of the kernel request. When an auditor needs the upstream identity (provider, request ID, principal) as asserted by a trusted adapter, sign the stamp separately. sign_provenance serializes it to RFC 8785 canonical JSON, signs those bytes, and returns a SignedProvenance envelope carrying the stamp, the signed bytes, the algorithm, the public key, and the detached signature.

src/attest.rsrust
use chio_tool_call_fabric::{sign_provenance, verify_signed_provenance};

// Sign the stamp with the adapter's signing backend.
let envelope = sign_provenance(&invocation.provenance, &backend)?;

// verify_signed_provenance runs three checks in order:
//   1. envelope.algorithm matches the signature algorithm,
//   2. envelope.signed_bytes re-canonicalize from stamp byte-for-byte,
//   3. the signature verifies against public_key over signed_bytes.
// On success it returns the public key that signed the stamp.
let signer = verify_signed_provenance(&envelope)?;

Compare the returned signer with a configured trusted authority before accepting the claim: the signature check uses the key inside the envelope, which by itself names neither your expected adapter nor a provider-authenticated principal. The envelope is canonical-JSON serializable, so it fits an audit log line, a receipt attachment, or a standalone attestation. Its cryptography is the same used for capability tokens and receipts; the adapter adds no key handling.

Pinned contracts and offline replay

Byte-stable canonical-JSON fixtures pin the lift and lower contract under crates/protocol/chio-tool-call-fabric/fixtures/lift_lower/{openai,anthropic,bedrock}/, three files each, and a test asserts each re-serializes to the same bytes; the two fixture transcripts above read them. chio-provider-conformance keeps its own NDJSON capture corpus under crates/protocol/chio-provider-conformance/fixtures/ and replays it through each adapter behind per-provider cargo features.

chio replay traffic reads a chio-tee-frame.v1 NDJSON stream and deserializes a ToolInvocation from each frame. With no other flags it validates.

Validate a capturetranscript
$ chio replay traffic \
  --from ./capture.ndjson
chio replay traffic: validating <chio-source>/crates/products/chio-cli/tests/replay_traffic/fixtures/clean_match.ndjson (schema=chio-tee-frame.v1)
  line    1: ok (01H7ZZZZZZZZZZZZZZZZZZZZZA)
chio replay traffic: 1/1 frames passed
exit 0

--against <policy-path> re-executes each captured call in an ephemeral kernel and compares the recomputed verdict with the captured one. The policy is the one you name, so a replay answers what that policy would have done. That path refuses a policy carrying post-output guards or concrete-server grants, which a pre-output replay cannot honor.

A frame's upstream.system is one of six: openai, anthropic, aws.bedrock, mcp, a2a, and acp; the five vocabulary-only providers have no frame system. A capture goes wrong in two ways: a frame that does not parse, and a schema name that does not match.

A frame that does not parsetranscript
$ chio replay traffic \
  --from ./malformed.ndjson
chio replay traffic: validating <chio-source>/crates/products/chio-cli/tests/replay_traffic/fixtures/parse_error.ndjson (schema=chio-tee-frame.v1)
  line    1: FAIL exit=30 ndjson parse error on line 1: key must be a string at line 1 column 2
chio replay traffic: 0/1 frames passed
exit 30
A schema name that does not matchtranscript
$ chio replay traffic \
  --from ./capture.ndjson --schema chio-tee-frame.v2
chio replay traffic: validating <chio-source>/crates/products/chio-cli/tests/replay_traffic/fixtures/clean_match.ndjson (schema=chio-tee-frame.v2)
  line    1: FAIL exit=40 schema-version gate failed: expected schema name "chio-tee-frame.v1", got "chio-tee-frame.v2"
chio replay traffic: 0/1 frames passed
exit 40

Symptoms and fixes

  • NonCanonicalArguments on a value you serialized. The bytes are JSON but not RFC 8785 canonical, most often unsorted keys. Re-serialize canonically. Bytes that are not JSON raise InvalidArgumentJson, a different bug.
  • A validation failure names the principal. The Principal variant differs from provenance.provider, or one of its fields is empty, padded with whitespace, or carries a control character.
  • Bedrock construction refuses before any traffic. The region or API version is not the pinned pair. The transport re-checks the region on every call, so a later override fails too.
  • IAM principal resolution fails at startup. One of the six fail-closed conditions under Bedrock IAM caller holds, or the mapping list is empty.
  • A recognized native tool fails closed with the feature compiled. The manifest is the second and authoritative gate. Build with new_with_manifest and name the stable entry in server_tools.
  • A stream fails instead of forwarding an allowed block. The block exceeded the buffer caps, or the allow carried redactions.
  • A Bedrock tool name comes back Malformed. The response carried a toolConfig that does not declare the lifted name: the lift refuses a tool the turn never offered.
  • An approval-gated call reads as a plain deny. The kernel-to-fabric conversion maps PendingApproval to Deny with rule_id kernel.pending_approval. Read that reason or the receipt to tell them apart.

Next: read the receipts your adapters produced