BuildConnect
Add Chio Middleware to Your HTTP Framework
Add Chio policy enforcement and signed receipts to an existing axum, Express, FastAPI, or net/http service with in-process middleware.
Prerequisites
http.Handler wrappers).Middleware vs Reverse Proxy
Both shapes enforce the same policy with the same kernel and produce the same signed HttpReceipt records. The choice is deployment topology. The middleware path runs the evaluator inside your service process; the proxy path runs it in a separate binary in front of your service.
| Dimension | Middleware (this guide) | Reverse proxy (chio api protect) |
|---|---|---|
| Processes | One. Evaluator inside your service. | Two. Chio in a separate binary in front of the upstream. |
| Network hop | None in Rust; localhost sidecar elsewhere. | One extra hop (client to Chio, Chio to upstream). |
| Code changes | Add a dependency and wire middleware. | None. Upstream is unmodified. |
| Identity context | Rich. Framework-parsed route params, auth state, session handles. | Header-only. Chio sees what is on the wire. |
| Best for | Services you own and can ship with a Chio dependency. | Services you cannot modify, polyglot fleets, multi-tenant edges. |
The rest of this guide walks the middleware path. If the table leans you the other way, head to Protect an API instead.
Rust: axum, warp, tonic via chio-tower
chio-tower ships a tower::Layer that wraps any inner service with chio evaluation. Because axum, tonic, and most modern Rust HTTP stacks build on tower::Service, wiring is identical across them. The crate exports:
ChioLayer: the towerLayeryou wrap your router with.ChioServiceis the innerServiceit produces.ChioEvaluator: holds the kernel keypair, policy hash, identity extractor, route resolver, and fail-open flag. Exposed so you can evaluate directly and return anEvaluationResult(verdict, signedHttpReceipt, guard evidence) outside the middleware.extract_identity/IdentityExtractor: the default header-based extractor and the function type for plugging in your own.ChioTowerError: surfaced when evaluation itself fails; distinct from aDenyverdict, which is a normal 403 response.
ChioLayer::new attaches no store, so it denies
ChioLayer::new is fail-closed with no durable sink: the first mediated call denies until a store is wired through ChioLayer::builder (crates/protocol/chio-tower/src/layer.rs:27-36, with evaluator.rs:83-98). For a local scaffold use ChioLayer::new_ephemeral, which says in its name that the receipts do not survive the process. Reach for new only when you are about to hand it a store.A minimal axum example. The layer sits in front of the router so every route inherits the evaluation:
use chio_core_types::crypto::Keypair;
use chio_tower::ChioLayer;
use axum::{routing::get, Router, Json};
use serde_json::json;
#[tokio::main]
async fn main() {
// Stable kernel keypair. In production this comes from a sealed seed
// file or HSM; generate() is fine for local dev.
let keypair = Keypair::generate();
// policy_hash binds this process's receipts to the exact policy
// document that was loaded. Compute it once at startup as the bare
// lowercase sha256 hex of the policy bytes, with no "sha256:" prefix:
// that is the form chio api protect writes and verifiers compare against.
let policy_hash = chio_http_core::sha256_hex(std::fs::read("chio.yaml").unwrap().as_slice());
// new_ephemeral, not new: new attaches no receipt store and denies the
// first mediated call. Use ChioLayer::builder to wire a durable one.
let chio = ChioLayer::new_ephemeral(keypair, policy_hash);
let app = Router::new()
.route("/pets", get(|| async { Json(json!({ "pets": [] })) })
.post(|Json(b): Json<serde_json::Value>| async move { Json(json!({ "created": b })) }))
.route("/pets/:id", get(|| async { Json(json!({ "pet": {} })) })
.delete(|| async { Json(json!({ "deleted": true })) }))
.layer(chio);
let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What the layer does for each request:
- Buffers the body so its SHA-256 hash can be computed and the bytes replayed to your handler. The body type must implement
http_body::BodyplusFrom<Bytes>;axum::body::BodyandFull<Bytes>qualify. - Extracts caller identity via
extract_identity, which checksAuthorization: Bearer,X-Api-Key,Cookie, then falls through to anonymous. Raw values never leave memory; only SHA-256 hashes reach the receipt. - Calls the
HttpAuthorityevaluator with method, path, query, caller, body hash, body length, and any capability token fromX-Chio-Capabilityor thechio_capabilityquery param. - On
Deny, returns the verdict's HTTP status (default 403), setsx-chio-receipt-id, stashes the receipt inresponse.extensions(), and never calls the inner handler. - On
Allow, forwards to the inner service and finalizes the receipt with the response status once the handler returns.
Custom identity and route resolution
The default extractor is header-only. If your service validates JWTs or looks up session cookies against a store, project that richer identity into the receipt by building an evaluator with your own extractor and passing it to ChioLayer::from_evaluator. The with_route_resolver hook on the same builder lets you collapse instance paths to OpenAPI-style templates, which is what route_pattern records in the receipt:
use chio_core_types::crypto::Keypair;
use chio_http_core::CallerIdentity;
use chio_tower::{ChioEvaluator, ChioLayer};
fn tenant_aware_identity(headers: &http::HeaderMap) -> CallerIdentity {
let mut caller = chio_tower::extract_identity(headers);
if let Some(tenant) = headers.get("x-tenant-id").and_then(|v| v.to_str().ok()) {
caller.tenant = Some(tenant.to_string());
}
caller
}
fn route_pattern(_method: &str, path: &str) -> String {
// Match against your router's compiled patterns and return the template.
if let Some(rest) = path.strip_prefix("/pets/") {
if !rest.is_empty() && !rest.contains('/') {
return "/pets/{id}".to_string();
}
}
path.to_string()
}
fn chio_layer(policy_hash: String) -> ChioLayer {
// new_ephemeral for a scaffold; ChioEvaluator::builder for a durable store.
let evaluator = ChioEvaluator::new_ephemeral(Keypair::generate(), policy_hash)
.with_identity_extractor(tenant_aware_identity)
.with_route_resolver(route_pattern)
.with_fail_open(false); // fail-closed is the default; make it explicit.
ChioLayer::from_evaluator(evaluator)
}A few constraints worth stating plainly, grounded in the current crate:
- The body is fully buffered before evaluation. That makes body-hash binding and body-aware guards work, but it also means streaming uploads are held in memory up to the collected size. Size caps belong upstream of the layer.
- The layer is generic over bodies that implement
http_body::BodyplusFrom<Bytes>, which coversaxum::body::Bodyand bytes-backed HTTP bodies.tonic::body::Bodydoes not satisfy that bound, so a live tonic gRPC service does not wrap inChioLayer. Govern gRPC at a different seam: an interceptor that callsChioEvaluatordirectly, or the sidecar. - Identity extractors and route resolvers are plain
fnpointers, not closures. State they need must be passed through headers or compiled into the extractor at build time.
Everything Else: via the Sidecar
Outside Rust, the shape is a thin framework middleware that talks to a local Chio sidecar over HTTP. The sidecar is the same process you would run for chio api protect, but in evaluation-only mode: it exposes an internal endpoint the middleware posts normalized requests to and receives a verdict plus signed receipt. The kernel keypair stays in the sidecar; your application process never sees it.
The localhost round trip keeps the kernel signing key in a small, separately auditable binary. The sidecar loads the same OpenAPI spec and x-chio-* policy as the reverse proxy, and each evaluator writes to the same receipt store.
Sidecar client, not in-process kernel
chio-tower path above or host your service inside a tower-compatible runtime.Add the dependency
One package per ecosystem. Python, TypeScript, and Go install from their registries; the JVM and .NET packages come in as a build coordinate and a project reference, exactly as the reference apps in the Chio source tree declare them.
| Ecosystem | Dependency line |
|---|---|
| Python | pip install chio-asgi, or chio-django / chio-fastapi for the framework-native forms |
| TypeScript | npm install @chio-protocol/express, or @chio-protocol/fastify / @chio-protocol/elysia |
| Go | replace github.com/backbay-labs/chio/sdks/go/chio-go-http => ../../sdks/go/chio-go-http in go.mod |
| JVM | includeBuild("../../sdks/jvm") in settings.gradle.kts, then implementation("world.chio:chio-spring-boot:0.1.0") in build.gradle.kts |
| .NET | <ProjectReference Include="../../sdks/dotnet/ChioMiddleware/src/ChioMiddleware.csproj" /> |
The package id that csproj declares is Backbay.Chio.Middleware and the namespace is Backbay.Chio; ChioMiddleware is the project and assembly name, so it is what you reference on disk and never what you write in a using directive.
The wiring, one framework at a time
Five ecosystems ship a first-party middleware. Each needs one piece of information, the sidecar URL, and each mounts ahead of your handlers so a denied request never reaches them. Routes and policy are the sidecar's business, loaded there from --spec. All five default the sidecar URL to http://127.0.0.1:9090, but where they look first differs and the difference bites:
| Package | Where the sidecar URL comes from |
|---|---|
@chio-protocol/* | Config value, then CHIO_SIDECAR_URL, then the default (sdks/typescript/packages/node-http/src/interceptor.ts:163). |
chio-go-http | Same three, in the same order (sdks/go/chio-go-http/config.go:42). |
chio-spring-boot | Same three (sdks/jvm/chio-spring-boot/src/main/kotlin/world/chio/ChioFilter.kt:54). |
Backbay.Chio.Middleware | Same three (sdks/dotnet/ChioMiddleware/src/ChioSidecarClient.cs:40). |
chio-asgi | Reads no environment. ChioASGIConfig.sidecar_url is a plain field defaulting to the literal URL (sdks/python/chio-asgi/src/chio_asgi/config.py:31). Setting CHIO_SIDECAR_URL does nothing unless your own code reads it, as the reference app does. |
chio-django | Reads a Django setting, not the environment. settings.CHIO_SIDECAR_URL (sdks/python/chio-django/src/chio_django/middleware.py:101-102). |
from chio_asgi import ChioASGIMiddleware, ChioASGIConfig
# Starlette / FastAPI
app.add_middleware(
ChioASGIMiddleware,
config=ChioASGIConfig(sidecar_url="http://127.0.0.1:9090"),
)import express from "express";
import { chio, chioErrorHandler } from "@chio-protocol/express";
const app = express();
app.use(chio({ config: "chio.yaml" }));
app.use(chioErrorHandler);package main
import (
"fmt"
"net/http"
chio "github.com/backbay-labs/chio/sdks/go/chio-go-http"
)
func handlePets(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `{"pets":[]}`)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/pets", handlePets)
protected := chio.Protect(mux, chio.ConfigFile("chio.yaml"))
http.ListenAndServe(":8080", protected)
}@SpringBootApplication
class DemoApplication
fun main(args: Array<String>) {
runApplication<DemoApplication>(*args)
}
@RestController
class PetsController {
@GetMapping("/pets")
fun pets(): Map<String, Any> = mapOf("pets" to emptyList<Any>())
}using Backbay.Chio;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddChioProtection();
var app = builder.Build();
app.UseChioProtection();
app.MapGet("/pets", () => new { pets = Array.Empty<object>() });
app.Run();The chio.yaml option in those two tabs is reserved
config is declared in sdks/typescript/packages/node-http/src/types.ts:206-210 and has no reader in that package's source; ConfigFile is set by sdks/go/chio-go-http/config.go:59-64 and read nowhere else in the module. Routes and policy come from the sidecar's own --spec. Point chio api protect at the spec and treat the option as a no-op.The Kotlin tab has no Chio call in it, and that is the point: the Spring Boot starter auto-configures the servlet filter as soon as it is on the classpath, so a minimal application needs no extra wiring. Configure it in application.yaml under the chio prefix, or supply a ChioFilterConfig bean when you want a custom identity extractor or route resolver:
chio:
sidecar-url: http://127.0.0.1:9090
timeout-seconds: 5
on-sidecar-error: deny # or "allow" for fail-open
enabled: true
url-patterns:
- "/*"
filter-order: 1That block is the README verbatim, and its comment on on-sidecar-error is stale. The filter's own doc comment reads “Reserved no-op option. The filter always denies sidecar errors” (sdks/jvm/chio-spring-boot/src/main/kotlin/world/chio/ChioFilter.kt:49). Setting it to allow changes nothing.
The other four take their options in code. The names line up across languages because they name the same five things:
| What it sets | Python | TypeScript | Go | C# |
|---|---|---|---|---|
| Sidecar base URL | sidecar_url | sidecarUrl | WithSidecarURL | SidecarUrl |
| Route and policy file | loaded by the sidecar | config | ConfigFile | loaded by the sidecar |
| Request timeout | timeout | timeoutMs | WithTimeout | TimeoutSeconds |
| Custom caller extraction | extractor | identityExtractor | WithIdentityExtractor | IdentityExtractor |
| Path to route pattern | sidecar spec | routePatternResolver | WithRouteResolver | RouteResolver |
Denied requests get a structured JSON error body in all five. Allowed requests reach your handler with the receipt id on the response, and, where the framework has a request object to hang it on, on the request too: req.chioResult in Express and request.chio_receipt in Django. chioResult is an EvaluateResponse, which is { verdict, receipt?, evidence } (sdks/typescript/packages/node-http/src/types.ts:184-188). There is no caller field on it: caller identity rides on the receipt as caller_identity_hash, and receipt is optional, so narrow before you read it.
More than one option in this table is reserved rather than live. .NET's OnSidecarError is a no-op, the TypeScript packages' onSidecarError: "allow" is asserted in their own tests to still fail closed, and chio-asgi's fail_open is documented as a legacy field kept for source compatibility (sdks/python/chio-asgi/src/chio_asgi/config.py:24-26). A sidecar error fails closed everywhere except Rust's chio-tower, whose with_fail_open(true) really does forward the request unenforced (crates/protocol/chio-tower/src/service.rs:136-142).
Per-route enforcement in FastAPI
chio-fastapi leans into FastAPI-native patterns: the chio_requires decorator declares the capability a route needs, and the package also exports the dependencies get_caller_identity and get_chio_receipt (sdks/python/chio-fastapi/src/chio_fastapi/__init__.py:3-10). There is no ASGI middleware class; enforcement is per-route through the decorator.
Two constraints come with the decorator. The handler has to take a Request: the wrapper looks for one in the call arguments and answers 500 when it finds none. And the receipt has to be read off request.state rather than injected, because the wrapper attaches it after the sidecar allows the call, which is after FastAPI has already resolved the route's dependencies (sdks/python/chio-fastapi/src/chio_fastapi/decorators.py:71-84, 171-172). get_caller_identity reads the request directly, so that one does inject.
from fastapi import Depends, FastAPI, Request
from chio_fastapi import chio_approval, chio_requires, get_caller_identity
app = FastAPI()
@app.get("/pets/{pet_id}")
@chio_requires("pets-api", "get_pet", operations=["Invoke"])
async def get_pet(
pet_id: str,
request: Request,
caller = Depends(get_caller_identity),
):
# Handlers under @chio_requires must be async def: the decorator wraps
# them and awaits the inner handler. caller.subject is the SHA-256 hex
# digest of the bearer token, API key, or session cookie value, with no
# method prefix, or the literal "anonymous"; caller.auth_method records
# which signal produced it. The verified receipt is on request.state.
receipt = request.state.chio_receipt
return {"pet_id": pet_id, "caller": caller.subject, "receipt_id": receipt.id}
# Stack @chio_approval on top of @chio_requires to gate a write on an
# operator-issued approval token.
@app.delete("/pets/{pet_id}")
@chio_approval(threshold_cents=0)
@chio_requires("pets-api", "delete_pet", operations=["Invoke"])
async def delete_pet(pet_id: str, request: Request):
# chio_approval looks for an X-Chio-Approval header and answers 403
# CHIO_APPROVAL_REQUIRED when it is absent. It does that on every request:
# threshold_cents and currency shape the error message and the metadata
# left on request.state.chio_approval, and are not compared against a cost.
return {"deleted": pet_id}Go: one wrapper covers every router
chio.Protect takes an http.Handler and returns one, so it composes with anything that speaks the standard interface. Gin, Echo, chi, and gorilla/mux all expose an http.Handler adapter, and the same wrapper covers all four. The reference app wraps a chi router exactly this way:
func protectedHandler(sidecarURL string) http.Handler {
return chio.Protect(
newRouter(),
chio.WithSidecarURL(sidecarURL),
)
}
func newRouter() http.Handler {
router := chi.NewRouter()
router.Get("/healthz", healthz)
router.Get("/hello", hello)
router.Post("/echo", echo)
return router
}On an allowed request the wrapper sets X-Chio-Receipt-Id on the response, so downstream logging correlates the call to its receipt without parsing a body:
HTTP/1.1 200 OK
Content-Type: application/json
X-Chio-Receipt-Id: bfc45d50f4c8c105c6720ad2c6a466f205c1382421e7e9bbd21f8b8cb5be9e92
Content-Length: 29
{"message":"hello from chi"}Keep the kernel out-of-process
What Gets Signed
Every evaluated request produces an HttpReceipt (from chio-http-core). On allowed requests you get two: a decision receipt signed before the handler runs and a final receipt once the response status is known, linked by metadata.chio_decision_receipt_id. Denied requests produce a single final-scope receipt because there is no upstream call to wait on.
A real one, read out of a sidecar's store after an allowed write. Its route_selection metadata block is dropped here for length; nothing else is edited.
$ python3 -c 'import json, sqlite3, sys
$ rows = sqlite3.connect("receipts.sqlite").execute(
$ "SELECT receipt_json FROM http_receipts").fetchall()
$ allow = [json.loads(r[0]) for r in rows
$ if json.loads(r[0])["verdict"]["verdict"] == "allow"
$ and json.loads(r[0])["method"] == "POST"][0]
$ allow["metadata"].pop("route_selection", None)
$ json.dump(allow, sys.stdout, indent=2)'{
"id": "a8fcfc7de60a1d06f66f6b9ab7fd3b788d94cd90ec1ff5b7e5239e126c355859",
"request_id": "01a0703f-620f-7de2-8d28-8d98a738da95",
"route_pattern": "/orders/{orderId}/refund",
"method": "POST",
"caller_identity_hash": "5a45e80d1ab1a73e8c2644f3f6c6e53974d0a986c2fc86692372d3f130248c9c",
"verdict": {
"verdict": "allow"
},
"receipt_kind": "mediated_decision",
"boundary_class": "prevent",
"tool_origin": "caller_executed",
"redaction_mode": "none",
"evidence": [
{
"guard_name": "CapabilityGuard",
"verdict": true,
"details": "valid capability token presented"
}
],
"response_status": 200,
"timestamp": 1788589597,
"content_hash": "37458db593fb679bf8629b8f345d39c9f32c264e9b045340dc4d11248ccf8433",
"policy_hash": "215a1eecb13caf8340c4905407b0c03f2a6309866ff2ff0a5a5f9735ea3b3566",
"trust_level": "mediated",
"capability_id": "sidecar-f80ee84531c5d4ad1b7f068f3187c2fcf7eb4f0d0ac75a5e83ba01397b9525f0",
"metadata": {
"chio_decision_receipt_id": "6285a7e5c796aeb7fb872c5b3f439fbe6ac3d4fd94edd05c7f8399020b036270",
"chio_http_status_scope": "final",
"chio_kernel_receipt_id": "1b409feb6b244d7758d988fc4d6c2fcb2df201782ac725be67b2cfc97806a03c"
},
"kernel_key": "04c8e7aadf96a0af7d148b8b25e0cc0e2509bbdce3e1266661168c262c822b7e",
"signature": "ba06c6b3928002bd611408fa0e8475a73c07a75e363ec5e72d617e47408032e24acea7a8f5484dbd0723ff002e0f8c3ac2690fedeaa4308e154d82bbedf3ee05"
}idand every hash on the record are bare lowercase hex, 64 characters, with noed25519:orsha256:prefix. Theidis the SHA-256 of the canonical body withidremoved, so it is content-addressed rather than allocated. Therequest_idis a dashed UUIDv7, which is the one identifier on the record that is not a hash.session_idis absent, not null. The field carriesskip_serializing_if = "Option::is_none"(crates/platform/chio-http-core/src/receipt.rs:42-44), so a receipt with no session omits the key entirely.receipt_kind,boundary_class,tool_origin,redaction_modeandtrust_levelare not optional. Any receipt shape missing one of them is not anHttpReceipt.route_patternis the template, not the instance URL. Inchio-tower, supply this viawith_route_resolver; in the sidecar path, the pattern comes from the loaded OpenAPI spec, and an unmatched path falls back to the literal path.caller_identity_hashis a SHA-256 over the canonical JSON of the caller identity record, not over the raw credential. Bearer tokens and API keys are never stored raw.content_hashcovers the canonicalized method, route pattern, path, query, and body hash (crates/platform/chio-http-core/src/request.rs:168-188). Two requests that differ only in body bytes produce different hashes, so the receipt is bound to the exact request that was evaluated.policy_hashfingerprints the policy document that was in effect. Rotating policy changes the hash, which downstream verifiers can detect.
The full schema, including the canonical JSON layout used for signature verification, lives in Receipt Format.
Policy Patterns
Middleware and the proxy derive route and method policy from the OpenAPI spec plus x-chio-* extensions, as described in the Protect an API guide. Policy does not come from a chio.yaml route block. A few patterns recur:
Route and method allowlist
Only operations present in the spec become tools; anything not enumerated is unknown and denies side effects by default. Narrow a route with x-chio-* so a purely-read POST stays session-scoped and a sensitive route is guarded:
paths:
/pets:
get:
operationId: listPets
responses:
"200": { description: OK }
post:
operationId: createPet
x-chio-side-effects: true # requires a capability token
responses:
"201": { description: Created }
/pets/{id}:
delete:
operationId: deletePet
x-chio-approval-required: true # deny-by-default, operator approval
responses:
"204": { description: Deleted }Body-size caps
The evaluator receives the request body length, so oversized uploads can be rejected before the handler runs. This is a code-level bound, not a spec field: in Rust set it with ChioService::with_max_body_bytes; the non-Rust sidecar exposes an equivalent server-side max_body_bytes ceiling.
Egress per route
When a route itself makes outbound calls (webhook dispatcher, third-party integration), scope the capability's egress grant to a narrow URL pattern. Any unapproved destination denies at the egress guard and leaves a receipt.
Full authoring reference: Write a Policy.
Body-aware guards require buffering
ChioService::with_max_body_bytes in Rust, or the sidecar's max_body_bytes) so a single oversized upload cannot eat your process memory.When to Use Middleware vs the Sidecar Proxy
| Situation | Middleware | chio api protect |
|---|---|---|
| You own the service and ship its binary. | Yes. | Optional. |
| Service is a closed-source third party. | Not available. | Yes. |
| Polyglot fleet, one governance surface. | Per-language wiring. | Preferred. One binary per service. |
| Receipts should reflect post-auth identity. | Strong. Sees framework auth state. | Header-only unless your auth is header-based. |
| Need governance without a deploy. | Requires a deploy. | Rollable in front of a running service. |
| Already behind Envoy or a service mesh. | Works, but overlaps the mesh. | Or use Envoy ext_authz. |
A common production shape is both: middleware in services you own,chio api protect in front of the ones you do not, and a single receipt store collecting evidence from both surfaces.
Verify the Result
The middleware is wired correctly when three things are true at once, and all three are visible from outside the process.
The sidecar is reachable and durable. Ask it before you ask your app:
$ curl -s http://127.0.0.1:9090/chio/health{"status":"healthy","version":"0.1.0","receipt_backend":"durable","revocation_backend":"durable"}receipt_backend reading ephemeral rather than durable is the single most common reason a middleware that looks wired up produces receipts nobody can find afterwards.
An allowed request carries a receipt id back. Every middleware sets a receipt header on the response. The Python packages name it X-Chio-Receipt by default and the others X-Chio-Receipt-Id. A 200 with no receipt header means the request bypassed the middleware, usually through an exclude list:
$ cat hello.headers hello.jsonHTTP/1.1 200 OK
date: Sat, 05 Sep 2026 11:49:07 GMT
server: uvicorn
content-length: 32
content-type: application/json
x-chio-receipt: 1dba0f38326d8eaba47d2b8e50a54fdeb0da056639cbaa20fca0100e0dc67f64
{"message":"hello from fastapi"}A refused request never reaches your handler. The refusal is the important half of the check, because a middleware that only ever allows is indistinguishable from one that is not mounted. Send a side-effect request with no capability and read the status:
$ cat deny.headers deny.jsonHTTP/1.1 403 Forbidden
date: Sat, 05 Sep 2026 11:49:08 GMT
server: uvicorn
content-type: application/json
content-length: 103
x-chio-receipt: 5d31d1463fe3819b3a5ffae0fb89e01c92339c3e4da455e6701511509690aaa3
{"error": "CapabilityGuard", "message": "side-effect route requires a capability token", "status": 403}The denial is receipted too, so both lanes end up in the same store and an audit can count them.
Failures and Recovery
| Symptom | Cause and fix |
|---|---|
| Every request denies, including safe reads, in a Rust service | The layer was built with ChioLayer::new, which attaches no receipt store and fails the first mediated call closed. Use new_ephemeral for a scaffold or builder for a real store. |
| Every request denies, in a sidecar-backed service | The sidecar is unreachable and the middleware is fail-closed. Check /chio/live on the URL the middleware resolved, remembering that chio-asgi ignores CHIO_SIDECAR_URL and chio-django reads a Django setting. |
| Setting a fail-open option changes nothing | Expected. Only chio-tower's with_fail_open(true) is live; the rest are reserved no-ops. |
Receipts exist but chio receipt list returns nothing for them | You are querying the wrong table. HttpReceipt rows live in http_receipts; the CLI lists the kernel receipts, whose tool server is chio_http_authority. See Reading receipts back. |
| A large upload hangs or the process grows | The body is fully buffered before evaluation so it can be hashed and replayed. Cap request size upstream of the middleware rather than inside it. |
| A tonic gRPC service will not compile with the layer | tonic::body::Body does not satisfy the From<Bytes> bound the replay path needs. Call ChioEvaluator from an interceptor instead. |
| A route is governed that should not be, or the reverse | Route policy comes from the sidecar's spec, not from a middleware config file. Fix --spec, or the framework's own exclude list for paths like /healthz that should sit outside Chio entirely. |
Next Steps
- Architecture · how the kernel, guard pipeline, and receipt store fit together regardless of the request entry point.
- Protect an API · the reverse-proxy counterpart to this guide, for services you cannot modify.
- Write a Policy · HushSpec authoring for capability scopes, approval rules, and guard configuration.
- Envoy ext_authz · run chio as an external authorization service at the mesh layer when middleware is too coupled and a sidecar is too coarse.
- Trust Control Plane · swap local policy, dev keys, and SQLite for hosted equivalents without touching application code.