Add Chio Middleware to Your HTTP Framework
Run Larkfield's pricing service so a quote handler runs only after the kernel admits the request, in Rust or behind a framework adapter.
On this page
A quote is a commitment to a customer, so the handler that issues one should run only after the kernel admits the request. In Rust the evaluator lives inside the service; in Python, Node, Go, the JVM, and .NET a framework adapter asks a Chio evaluator first.
Starts from chio new pricing-rust --example pricing-rust. You run the Rust pricing host, refuse a quote, issue one with a capability, verify the signed receipts, and then make reading quotes require authority too. The Python and Node and Bun projects run the same service behind an adapter.
Admission sits between the router and your handler
Framework middleware puts the kernel check after routing and before the handler, so the framework keeps its routes and your code keeps its shape. The adapter sends the request's metadata and body hash, receives a signed decision, and verifies it before the handler runs.
κcapabilityσscopegguardsBbudget
Admission runs before your handler
The middleware sends the method, route, headers, and body hash to the kernel before the handler runs. In Python and Node it calls a separate evaluator and verifies the decision receipt; the Rust tower layer embeds the evaluator. An allow continues to the handler, which validates and saves the note. HTTP Framework Middleware.
Where the evaluator runs decides when the receipt is signed. The Rust layer signs after your handler returns, so its receipt records the final status. An adapter's evaluator signs before the handler runs. A reverse proxy, by contrast, receives the client's request itself and forwards what it admits to an upstream.
1. Run the embedded Rust pricing host
Pull the project, or download it. You need Rust 1.94.1 and jq; Cargo fetches the Chio crates the manifest pins.
chio new pricing-rust --example pricing-rust && cd pricing-rust
cargo run --locked --releaseRust pricing: http://127.0.0.1:8096; five-minute grant: .pricing/capability.json
On start the host creates its private .pricing state and a five-minute capability. It keeps its authority key, quotes, admission state, and receipts across restarts, and each start issues a fresh local grant.
2. Refuse a quote, then issue it
In a second terminal in the same directory, ask for sku-301 at 80 percent without a capability, then with it, then read the quotes back.
curl -sS -i http://127.0.0.1:8096/v1/quote \
-H 'Content-Type: application/json' -d '{"sku":"sku-301","percent":80}'
curl -sS -i http://127.0.0.1:8096/v1/quote \
-H 'Content-Type: application/json' \
-H "X-Chio-Capability: $(jq -c . .pricing/capability.json)" \
-d '{"sku":"sku-301","percent":80}'
curl -sS http://127.0.0.1:8096/v1/quotesHTTP/1.1 403 Forbidden
x-chio-receipt-id: 81d27d879473d8292138983d0a7e9dc9b3cc91e603e8e786e0dfc43a7c84324a
content-length: 0
HTTP/1.1 201 Created
content-type: application/json
x-chio-receipt-id: a553c1bfbb4dcedc412fdfcc8a3d4559722abd9cf738bc57a41438bb3a236996
{"id":1,"sku":"sku-301","percent":80.0,"price":"28.00","currency":"USD"}
{"quotes":[{"id":1,"sku":"sku-301","percent":80.0,"price":"28.00","currency":"USD"}]}You should see 403 with an empty body, then 201 with USD 28.00, then the list. Every response carries x-chio-receipt-id, the refusal included.
3. Read the layer that admits each request
The service is an ordinary axum router with ChioLayer wrapped around it.
let app = Router::new()
.route("/v1/quotes", get(list_quotes))
.route("/v1/quote", post(quote))
.with_state(database.clone())
.layer(axum::extract::DefaultBodyLimit::max(8192))
.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(|error: axum::BoxError| async move {
eprintln!("Chio evaluation failed: {error}");
(
StatusCode::BAD_GATEWAY,
Json(json!({"error":"chio_evaluation_failed"})),
)
}))
.layer(host.layer.clone()),
)
.layer(axum::middleware::from_fn_with_state(
retention.clone(),
receipts::retain,
));Read src/main.rs · complete 128 lines · complete file
mod database;
mod host;
mod receipts;
use axum::{
error_handling::HandleErrorLayer,
extract::State,
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tower::ServiceBuilder;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct QuoteRequest {
sku: String,
percent: f64,
}
#[derive(Serialize)]
struct Quotes {
quotes: Vec<database::Quote>,
}
struct ApiError(database::Error);
impl From<database::Error> for ApiError {
fn from(error: database::Error) -> Self {
Self(error)
}
}
impl axum::response::IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let (status, code) = match self.0 {
database::Error::Busy | database::Error::Stopped => {
(StatusCode::SERVICE_UNAVAILABLE, "database_unavailable")
}
database::Error::Storage(error) => {
eprintln!("Storage failed: {error}");
(StatusCode::INTERNAL_SERVER_ERROR, "database_failed")
}
};
(status, Json(json!({"error":code}))).into_response()
}
}
async fn list_quotes(State(database): State<database::Database>) -> Result<Json<Quotes>, ApiError> {
Ok(Json(Quotes {
quotes: database.list().await?,
}))
}
async fn quote(
State(database): State<database::Database>,
Json(request): Json<QuoteRequest>,
) -> axum::response::Response {
use axum::response::IntoResponse;
let Some(cents) = database::list_price(&request.sku) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":format!("unknown sku {}", request.sku)})),
)
.into_response();
};
if !(0.0..=100.0).contains(&request.percent) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"percent must be between 0 and 100"})),
)
.into_response();
}
let price = database::discounted(cents, request.percent);
match database.insert(request.sku, request.percent, price).await {
Ok(quote) => (StatusCode::CREATED, Json(quote)).into_response(),
Err(error) => ApiError(error).into_response(),
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let host = host::open()?;
chio_example_runtime::files::private_directory(std::path::Path::new(".pricing/http-receipts"))?;
let database = database::Database::open(std::path::Path::new(".pricing/quotes.db"))?;
let retention = receipts::Retention::new();
let app = Router::new()
.route("/v1/quotes", get(list_quotes))
.route("/v1/quote", post(quote))
.with_state(database.clone())
.layer(axum::extract::DefaultBodyLimit::max(8192))
.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(|error: axum::BoxError| async move {
eprintln!("Chio evaluation failed: {error}");
(
StatusCode::BAD_GATEWAY,
Json(json!({"error":"chio_evaluation_failed"})),
)
}))
.layer(host.layer.clone()),
)
.layer(axum::middleware::from_fn_with_state(
retention.clone(),
receipts::retain,
));
let address = std::env::var("CHIO_PRICING_LISTEN")
.unwrap_or_else(|_| "127.0.0.1:8096".into())
.parse::<std::net::SocketAddr>()?;
anyhow::ensure!(
address.ip().is_loopback(),
"the example requires a loopback listener"
);
let listener = tokio::net::TcpListener::bind(address).await?;
println!(
"Rust pricing: http://{}; five-minute grant: .pricing/capability.json",
listener.local_addr()?
);
let signal = chio_example_runtime::shutdown_signal()?;
let draining = retention.clone();
let served = axum::serve(listener, app)
.with_graceful_shutdown(async move {
signal.await;
draining.close();
})
.await;
let retained = retention.shutdown().await;
let stopped = database.shutdown().await;
drop(host);
served?;
retained?;
stopped?;
Ok(())
}HandleErrorLayer adapts Chio's fallible service to axum's router. ChioLayer evaluates before the handler and signs an HTTP receipt after the response, and the outer receipt middleware retains that signed record. The host module builds the evaluator and its stores.
Read src/host.rs · complete 70 lines · complete file
use chio_core::capability::{
scope::ChioScope,
token::{CapabilityToken, CapabilityTokenBody},
};
use chio_http_core::http_authority_tool_grant;
use chio_store_sqlite::{SqliteAuthorityStore, SqliteReceiptStore, SqliteRevocationStore};
use chio_tower::{ChioEvaluator, ChioLayer};
use std::{path::Path, sync::Arc};
pub struct Host {
pub layer: ChioLayer,
_authority: SqliteAuthorityStore,
}
pub fn open() -> anyhow::Result<Host> {
let directory = Path::new(".pricing");
chio_control_plane::ensure_private_directory(directory)?;
let key =
chio_control_plane::load_or_create_authority_keypair(&directory.join("authority.hex"))?;
let locks = directory.join("authority-locks");
chio_control_plane::ensure_private_directory(&locks)?;
let path = directory.join("admission.db");
SqliteAuthorityStore::provision(&path, &locks)?;
let authority = SqliteAuthorityStore::open_serving(&path, &locks)?;
let receipts = Arc::new(SqliteReceiptStore::open(directory.join("receipts.db"))?);
let revocations = Arc::new(SqliteRevocationStore::open(
directory.join("revocations.db"),
)?);
// Fingerprint this host configuration, not an unparsed policy document.
let policy_hash = chio_core::sha256_hex(include_bytes!("host.rs"));
let evaluator = ChioEvaluator::builder(key.clone(), policy_hash)
.receipt_store(receipts)
.revocation_store(revocations)
.durable_admission_stores(
Arc::new(authority.admission_operation_store()),
Arc::new(authority.tool_outcome_store()),
authority.mutation_fence(),
)
.build()?;
// Starting this local application explicitly issues a five-minute HTTP grant.
let now = chrono::Utc::now().timestamp() as u64;
let capability = CapabilityToken::sign(
CapabilityTokenBody {
id: uuid::Uuid::now_v7().to_string(),
issuer: key.public_key(),
subject: key.public_key(),
scope: ChioScope {
grants: vec![http_authority_tool_grant()],
..ChioScope::default()
},
issued_at: now,
expires_at: now + 300,
delegation_chain: Vec::new(),
aggregate_invocation_budget: None,
},
&key,
)?;
std::fs::write(
directory.join("capability.json"),
serde_json::to_vec_pretty(&capability)?,
)?;
std::fs::write(
directory.join("kernel-public-key.txt"),
key.public_key().to_hex(),
)?;
Ok(Host {
layer: ChioLayer::from_evaluator(evaluator).with_max_body_bytes(8192),
_authority: authority,
})
}This evaluator admits safe methods and requires authority for side-effect methods. The local grant covers the synthetic HTTP operation for all routes, with no per-route or per-tool write budget. The policy hash fingerprints this host's configuration; it loads no OpenAPI document.
The HTTP journal writes after the handler returns, so a journal failure can return 500 after the quote is recorded, and the quote stays. The service bounds request bodies, active requests, and database commands; Build Systems in Rust covers its worker and shutdown contracts.
HTTP receipt retention and the signature reader
Read src/receipts.rs · complete 81 lines · complete file
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use chio_example_runtime::tasks::{BlockingPool, TaskGroup};
use chio_http_core::HttpReceipt;
use std::{sync::Arc, time::Duration};
#[derive(Clone)]
pub struct Retention {
tasks: TaskGroup,
io: BlockingPool,
capacity: Arc<tokio::sync::Semaphore>,
}
impl Retention {
pub fn new() -> Self {
Self {
tasks: TaskGroup::default(),
io: BlockingPool::new(8),
capacity: Arc::new(tokio::sync::Semaphore::new(8)),
}
}
pub fn close(&self) {
self.capacity.close();
if let Err(error) = self.tasks.close() {
eprintln!("Could not stop admission: {error}");
}
}
pub async fn shutdown(&self) -> anyhow::Result<()> {
self.close();
let joined = self.tasks.drain(Duration::from_secs(15)).await;
let stored = self.io.drain(Duration::from_secs(15)).await;
joined?;
stored
}
}
/// Own the admitted request through receipt retention, even if its HTTP client
/// disconnects. The concurrency permit is acquired before the handler runs.
pub async fn retain(State(state): State<Retention>, request: Request, next: Next) -> Response {
let Ok(permit) = state.capacity.clone().try_acquire_owned() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({"error":"host_capacity_exhausted"})),
)
.into_response();
};
let (send, receive) = tokio::sync::oneshot::channel();
let io = state.io.clone();
if let Err(error) = state.tasks.spawn(async move {
let _permit=permit;
let response=next.run(request).await;
let receipt=response.extensions().get::<HttpReceipt>().cloned();
let retained = if let Some(receipt)=receipt {
let id=receipt.id.clone();
let saved=io.run(move || {
anyhow::ensure!(receipt.id.len()==64 && receipt.id.bytes().all(|c|c.is_ascii_hexdigit()), "invalid HTTP receipt ID");
let path=std::path::Path::new(".pricing/http-receipts").join(format!("{}.json",receipt.id));
chio_example_runtime::files::create(&path,&serde_json::to_vec_pretty(&receipt)?)?;
Ok(())
}).await;
match saved {
Ok(()) => { let _=send.send(response);Ok(()) }
Err(error) => {
let _=send.send((StatusCode::INTERNAL_SERVER_ERROR,Json(serde_json::json!({"error":"http_receipt_save_failed","receipt_id":id,"effect":"may_already_have_happened"}))).into_response());
Err(error)
}
}
} else { let _=send.send(response);Ok(()) };
retained
}) {
eprintln!("Request ownership failed: {error}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
receive
.await
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}Read src/bin/verify-http.rs · complete 29 lines · complete file
use chio_http_core::HttpReceipt;
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let key_path = args
.next()
.ok_or_else(|| anyhow::anyhow!("usage: verify-http TRUSTED_KEY_FILE RECEIPT.json ..."))?;
let trusted = chio_example_runtime::files::read_text(std::path::Path::new(&key_path), 256)?;
let mut count = 0;
for path in args {
let receipt: HttpReceipt = serde_json::from_slice(
&chio_example_runtime::files::read_bounded(std::path::Path::new(&path), 1_048_576)?,
)?;
anyhow::ensure!(
receipt.kernel_key.to_hex() == trusted.trim(),
"untrusted signing key: {path}"
);
anyhow::ensure!(
receipt.verify_signature()?,
"invalid signed receipt: {path}"
);
println!(
"{} {} {}",
receipt.id, receipt.method, receipt.response_status
);
count += 1;
}
anyhow::ensure!(count > 0, "supply at least one receipt file");
Ok(())
}4. Run the same app behind a framework adapter
The other adapters keep framework hooks in your application and call Chio over HTTP. Your client calls the application port; the adapter sends request metadata and a body hash to /chio/evaluate, verifies the receipt through /chio/verify, and either continues or refuses.
Each project is complete and installs its adapter from its registry. The Python project has FastAPI and Django, the Node project Express, Fastify, and Elysia, the Go and C++ project chi and Drogon, and the JVM and .NET project Spring Boot and ASP.NET Core.
Each project starts chio api protect with a spec and durable stores. That command takes an upstream URL even when the adapter uses only its evaluation endpoint, and it holds the policy: configure policy there, since a middleware configuration file loads none. The placement each framework needs is in the framework hooks below.
5. Require authority for reading quotes
Issued quotes are commercial terms, and an agent with no grant should not browse them. For the adapter projects, the evaluator loads openapi.yaml: GET /v1/quotes starts as a safe read and POST /v1/quote requires a capability. Add x-chio-side-effects to the GET operation and keep the rest of the document.
paths:
/v1/quotes:
get:
operationId: listQuotes
x-chio-side-effects: true
responses:
"200": { description: Up to 100 issued quotes }
# Keep the existing /v1/quote post operation here.Restart the pricing-python launcher with the same .pricing state, then read without and with Basalt's grant.
curl -sS -w ' HTTP %{http_code}\n' http://127.0.0.1:8091/v1/quotes
curl -sS http://127.0.0.1:8091/v1/quotes \
-H "X-Chio-Capability: $(jq -c . .pricing/capability.json)"{"error":"CapabilityGuard","message":"side-effect route requires a capability token","status":403} HTTP 403
{"quotes":[{"id":1,"sku":"sku-301","percent":80.0,"price":"28.00","currency":"USD"}]}You should see 403 for the first read and the recorded quote for the second. The change sets the admission requirement for a matched operation; unmatched safe methods still fall back to the method rule, so enforce a route allowlist at your router if you need one. Express uses application port 8093 and evaluator port 9099. The Go and Drogon and Spring and .NET projects carry the same change at their own addresses. The embedded Rust host reads no OpenAPI extensions.
6. Verify the receipts your responses name
Run the host's verifier over the retained HTTP receipts, with the kernel public key the host wrote.
cargo run --locked --release --bin verify-http -- \
.pricing/kernel-public-key.txt .pricing/http-receipts/*.json1326491c3d9af4756da01411556415368581e016f884fdafd825702b5465811b GET 200 81d27d879473d8292138983d0a7e9dc9b3cc91e603e8e786e0dfc43a7c84324a POST 403 a553c1bfbb4dcedc412fdfcc8a3d4559722abd9cf738bc57a41438bb3a236996 POST 201 dfa30c2b5b1af57e0f9e0098027c9b32da5275047176a1eaf1f99183ff87533f POST 400 f8d4ce936f4381b8422cd9468723ea0ed100c97f0f4b52bbfc2700087566f407 GET 200
You should see each receipt's id, method, and final status, including the 403 and a 400 from step 6 below. The reader takes the public key as a separate input, then checks each id and signature; a receipt whose status was altered fails that check. Pass one filename to check one receipt. The adapter projects ship a read-only receipt reader with a decision filter, and you verify their records through the issuing evaluator as HTTP receipt verification describes.
A receipt's status is what was known when it was signed
An HTTP receipt binds the request identity, method, route, content hash, policy hash, and verdict under the kernel's signature. Ask for sku-999 with a valid capability and the two placements record it differently. The embedded Rust layer signs after the handler, so its allow receipt carries the final 400 and no quote is recorded. An adapter's evaluator signs before the handler, so its allow receipt carries 200 and the service later returns 400 or 422.
{
"id": "dfa30c2b5b1af57e0f9e0098027c9b32da5275047176a1eaf1f99183ff87533f",
"verdict": { "verdict": "allow" },
"response_status": 400,
"metadata": {
"chio_http_status_scope": "final",
"chio_kernel_receipt_id": "1373266834348d9825cb86132e6abb3c2c83f2639047b427df16630b47306956"
}
}The status scope says which: final here, decision for an adapter. The receipt records the kernel's decision and the status it saw; the quote list is the service's evidence of which rows SQLite committed.
Three placements, three operational boundaries
| Placement | Choose it when | What you operate |
|---|---|---|
| Embedded Rust | You own the Rust host and want local evaluation | Authority keys, durable stores, service lifecycle |
| Adapter and evaluator | You own code in a supported framework | A decision service called before protected handlers |
| Reverse proxy | You govern an upstream without changing its code | The only route to the upstream |
Routes an adapter explicitly excludes, such as /healthz, bypass it. Protect an API runs the same quote operation through a reverse proxy; only requests that cross a placement receive its checks and evidence.
Framework hooks, limits, and failures
Try Fastify, with_route_resolver, 413, or sidecar outage.
Where each framework places admission
| Framework | Required placement | Working source |
|---|---|---|
FastAPI | ASGI middleware buffers and replays the request before parsing | |
Django | Chio before protected views | |
Express | Chio middleware before express.json | |
Fastify | Plugin captures bytes in preParsing and evaluates before the handler | |
Elysia | POST uses parse: 'none'; the authorized handler reads request.json() | |
Go / chi | Bound the body, then wrap the router with chio.Protect | |
C++ / Drogon | Name ChioMiddleware on governed route registrations | |
Spring Boot | Bounded body reader before the mapped ChioFilter | |
ASP.NET Core | Use on the /v1 branch |
FastAPI receipt access follows admission timing
The ASGI integration exposes the allowed receipt through its response header and an optional on_receipt callback, and leaves request.state unchanged. The separate chio-fastapi route decorator attaches state after its own admission step, so a Depends(get_receipt) value resolved before that decorator runs has no receipt to read. Establish the workflow with the ASGI app first, then choose a route-level integration.
Go adapter for an existing handler
The Go adapter calls an evaluator. For an existing net/http application, its middleware wraps an http.Handler and takes WithSidecarURL and WithTimeout options; the Go SDK reference covers the contract. The Go pricing service demonstrates a refused quote, an issued quote, validation after admission, an evaluator outage, and persistence across restart.
Rust identity and route resolution
The evaluator builder exposes with_identity_extractor and with_route_resolver for services with authenticated identity context or parameterized routes. A resolver gives equivalent requests one stable route name; the route table itself stays as the router defines it. The downloaded service implements GET /v1/quotes and POST /v1/quote; add this resolver when you add GET /v1/quotes/{quoteId}.
fn route_pattern(_method: &str, path: &str) -> String {
match path.strip_prefix("/v1/quotes/") {
Some(id) if id.parse::<u64>().is_ok() => "/v1/quotes/{quoteId}".into(),
_ => path.into(),
}
}
// In host::open, extend the evaluator builder with:
// .with_route_resolver(route_pattern)An identity extracted from a header carries the trust your application's authentication gives that header. A blocking thread owns SQLite, and owned request tasks keep their receipts through client cancellation. The archive's README describes the journal failure and the separate kernel receipt store, whose authority and converted records carry their own ids and no response_status.
Body limits in the pricing projects
| Integration | Configured limit | Where it applies |
|---|---|---|
Rust / ASGI | 8192 bytes in Chio's middleware | While collecting request bytes |
Django | DATA_ | Django's request.body access |
Express | An ingress limit you add before the middleware | Before the adapter collects the body; a later parser limit comes too late |
Fastify | bodyLimit = 8192 | Parsing, after the adapter's preParsing buffer; add an ingress cap before that buffer |
Elysia / Bun | max | Native HTTP server, before route handling |
Go | MaxBytesReader = 8192 | Before SDK buffering; oversized known-length and chunked bodies return 400 |
Drogon | set | Native server; oversized bodies return 413 before admission |
Spring Boot | 8192-byte application filter | Before ChioFilter caches bytes; oversized bodies return 413 |
ASP.NET Core | Kestrel limit and Max of 8192 | 413 for oversized bodies, chunked included, before admission |
A body hash binds the bytes without showing their contents to a remote evaluator. The pricing services validate the SKU and percent themselves; a hash or an OpenAPI schema is evidence of binding, and a raw-body guard is what inspects the payload.
Outbound effects keep their own boundary
Inbound middleware governs admission to a handler. HTTP calls, filesystem writes, and subprocesses that the handler makes go through their own governed tool or network boundary; an egress annotation on the route leaves them to it. An approval annotation makes an operation deny by default, and the approval itself comes from a person through the approval protocol.
Diagnose the boundary that refused the request
- 403 on a write. Inspect the capability's presence, issuer, scope, and expiry. Issue a fresh local grant if the five-minute example expired.
- 400 or 422 after admission. Correct the application payload, then compare the admission receipt with the final response and the saved list.
- Python 503; Node, Go, Drogon, Spring, or .NET 502 while Chio is stopped. Restore the configured evaluator. A transport failure is a refusal with no signed policy decision behind it.
- Health endpoint still returns 200. Check a governed route too; the projects exclude
/healthzfrom admission. - The Rust host cannot acquire authority state at startup. Check the state directory and whether another host owns its mutation fence. Leave live state in place.
- The HTTP journal save fails after a Rust handler. Check storage, and read the quote list before you retry a quote, because the handler may already have run.
With the evaluator stopped, every framework variant refuses governed traffic, keeps its recorded quotes, and keeps its excluded health endpoint available. Restarting the evaluator restores service.