PlatformProcess Model
Node
Embedded Node
Node obligations discharged inside a host process: a Tower layer over any http service, or an Envoy ext_authz check with its own policy engine.
This page owns the contract, not the wiring
Two embeddings, two different contracts
Chio ships two ways to enforce inside somebody else’s process, and they are not variants of one design. They share no code, no dependency, and no guarantee.
chio-tower is a tower::Layer. It wraps any tower::Service that speaks http::Request / http::Response, extracts caller identity, buffers and hashes the request body, and delegates the authorization decision to chio_http_core::HttpAuthority, which is backed by chio-kernel. A full kernel runs in the host process. Its Cargo.toml names four chio crates among its dependencies: chio-core-types, chio-http-core, chio-kernel, and chio-metrics-spec.
chio-envoy-ext-authz is a tonic service implementing envoy.service.auth.v3.Authorization/Check. It translates each Envoy CheckRequest into a ToolCallRequest, hands it to an EnvoyKernel implementation, and maps the returned Verdict back onto a CheckResponse. Its [dependencies] table lists tonic, prost, prost-types, tokio, async-trait, thiserror, tracing, sha2, and hex. No chio-* crate appears in it, and the crate’s architecture notes state the rule directly: "Internal: none."
The Envoy crate has no kernel dependency, by design
EnvoyKernel is the whole extension point, and chio-envoy-ext-authz ships no implementation of it. The trait is one async method, and every guard, every capability check, and every receipt a deployment wants comes from whatever that method delegates to. Building on this adapter means supplying your own policy engine: chio-kernel, HttpAuthority, or something else entirely. The adapter will happily serve Ok(Verdict::Allow) forever, and nothing in the crate would notice. Nothing in the crate signs a receipt either; a repo-wide read of its six source files finds the word only in a comment and in a header-stripping test.One more scoping fact, because it changes how the Envoy path should be read. No crate in the workspace depends on chio-envoy-ext-authz, and no binary target builds a server around it. The crate is a workspace member and a library. The design document that describes the wider integration, docs/protocols/ENVOY-EXT-AUTHZ-INTEGRATION.md, carries the status line "Tier 0 -- proposed April 2026" and describes a header-injection scheme, an HTTP-mode adapter, and a shadow mode that the shipped crate does not implement. Read the crate for what exists; read the protocol document for intent.
What ChioLayer needs from the host
The layer is a configuration wrapper. ChioLayer::layer builds a ChioService<S> from a cloned ChioEvaluator and the configured body cap; every decision lives in the service and the evaluator. Four constructors exist, and the difference between the first two is the difference between a deployment that works and one that denies every side-effecting call.
| Constructor | Durable receipt sink | Result |
|---|---|---|
ChioLayer::new | None, and allow_ephemeral is false | Fail-closed. The embedded kernel refuses a mediated call for missing durable persistence, prepare returns Err, and the service answers 502. |
ChioLayer::new_ephemeral | None, opt-in taken | In-memory receipt log and revocation store. Receipts live in the response extensions and vanish on restart. |
ChioLayer::builder | receipt_store, revocation_store | The configured shape. build is fallible because attaching a durable receipt store hydrates checkpoint counters. |
ChioLayer::from_evaluator | Whatever the evaluator carries | The route for a custom IdentityExtractor or RouteResolver. |
The trait bounds on Service<http::Request<ReqBody>> are the real compatibility contract, and one of them is unusual. The request body must implement From<Bytes>, because the middleware reconstructs a fresh body from the bytes it buffered and hands that to the inner service.
impl<S, ReqBody, ResBody> Service<http::Request<ReqBody>> for ChioService<S>
where
S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>> + Clone + Send + 'static,
S::Future: Send,
S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
ReqBody: Body + From<Bytes> + Send + 'static,
ReqBody::Data: Send,
ReqBody::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
ResBody: Default + From<Bytes> + Send + 'static,axum::body::Body and http_body_util::Full<Bytes> satisfy it, and both are exercised by the crate’s integration tests. Real tonic::body::Body replay is not. The crate root says so in as many words, and tests/tonic_integration.rs opens by naming itself an approximation: it drives Full<Bytes> bodies with gRPC-shaped headers and a /my.service.MyService/GetItem path, which proves the middleware handles gRPC-looking HTTP, not that it can wrap a live tonic service.
Two hooks are plain function pointers, not closures. IdentityExtractor is fn(&http::HeaderMap) -> CallerIdentity and RouteResolver is fn(&str, &str) -> String. Neither can capture state, so anything an extractor needs has to arrive in the headers or be compiled in. The default resolver returns the raw path unchanged, which means an unconfigured deployment records /pets/secret-id as its route_pattern rather than a template.
The default extractor reads three header families in a fixed order and hashes the secret before it becomes an identity: Authorization: Bearer to bearer:<first 16 hex>, then x-api-key to apikey:<first 16 hex>, then the first Cookie pair to cookie:<first 16 hex>, then anonymous. The bearer and API-key branches run their value through valid_secret_value, which rejects anything blank or anything that differs from its own trimmed form, so Bearer falls through and resolves anonymous. The cookie branch is looser: it trims the first pair’s value itself and falls through only when nothing is left, so a padded cookie value still becomes an identity, hashed over the trimmed bytes. Every one of these sets verified: false; the extractor validates nothing, it only records what was presented.
The body cap is enforced frame by frame
The middleware has to read the request body to compute the content hash the receipt binds to, which makes body size a memory-safety question before it is a policy question. DEFAULT_MAX_BODY_BYTES is 8 MiB, and with_max_body_bytes on either the layer or the service overrides it.
Two guards run, and the second is the one that matters. buffer_request_body first checks size_hint().upper() and rejects an advertised size above the cap without pulling a byte. A body that advertises nothing, or lies, reaches the loop.
loop {
let frame = std::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await;
let frame = match frame {
Some(Ok(frame)) => frame,
Some(Err(error)) => return Err(BufferBodyError::Inner(error.into())),
None => break,
};
let mut data = match frame.into_data() {
Ok(data) => data,
// Trailers and other non-data frames carry no body bytes and are
// dropped: the kernel's content hash and length only cover the
// request payload, not HTTP/2 trailers.
Err(_non_data) => continue,
};
let chunk_len = data.remaining();
if buffer.len().saturating_add(chunk_len) > limit {
return Err(BufferBodyError::TooLarge);
}
while data.has_remaining() {
let slice = data.chunk();
let slice_len = slice.len();
buffer.extend_from_slice(slice);
data.advance(slice_len);
}
}Peak buffer use is bounded by the cap plus one in-flight frame. A collect() would not be: a streaming POST whose size hint hides its true length would be fully materialized before anyone measured it. buffer_request_body_aborts_streaming_body_during_collection pins exactly that, with a hand-written Body that returns four 8-byte frames under a default (unbounded) size hint against a 16-byte cap, and asserts the third frame aborts the read.
Three consequences of the loop are worth stating plainly. Non-data frames are skipped, so HTTP/2 trailers are excluded from both body_hash and body_length: the hash covers the payload and nothing else. An empty body produces body_hash: None rather than the SHA-256 of the empty string. And the buffered bytes are replayed byte-identical through ReqBody::from(collected), which buffer_request_body_hashes_and_replays_raw_bytes asserts against the original payload.
Buffering is not streaming, and the cap is the only bound
max_body_bytes to admit large uploads raises the per-request memory ceiling by the same amount, multiplied by concurrency. There is no chunked or streaming evaluation path in the middleware: an upload larger than the cap is refused, not streamed past. The node-side ceilings that bound a Chio binary’s own listeners are a separate set, covered in Backpressure & Limits.An oversized body gets a signed 413, or nothing at all
A transport-level rejection still produces evidence. The middleware resolves the caller identity before pulling any body bytes, precisely so a request that aborts during buffering can still carry a caller identity hash into a signed deny receipt. build_payload_too_large_response constructs the 413, and the deny it signs carries the guard name chio_tower_request_body_limit_guard and the reason request body exceeds {max_body_bytes}-byte limit for chio_tower.
On the full path the response carries three copies of the same fact: the receipt id in x-chio-receipt-id, the serialized HttpReceipt as a application/json body, and the receipt itself in the response extensions. service_413_response_has_signed_receipt_body asserts all three agree and that both the extension receipt and the deserialized body receipt verify under the embedded kernel key. A signed 413 is what lets an auditor tell a Chio decision from a stray network-layer rejection.
Three branches produce something else, and an operator reading a 413 should know which one they are looking at.
| Condition | Status | Receipt |
|---|---|---|
| Receipts are audited, signing succeeds, append succeeds | 413 | Header, JSON body, extension |
Neither a durable sink nor an ephemeral opt-in (receipts_are_audited() false) | 502 | None. No header, no body, no extension. |
| The configured store rejects the append | 502 | None. The signed 413 is discarded rather than sent. |
Method not in the seven parse_method accepts, the identity hash fails, or sign_transport_deny_receipt fails | 413 | None. Bare 413, no content-type, no header. |
The second row is the one that reads wrong at first and is right on reflection. Refusing to sign a 413 because no store can record it is the same rule the normal request path applies: a denial’s audit record must be as durable as an allow’s, so a misconfigured deployment gets a server error rather than a signed rejection whose evidence was dropped. service_413_fails_closed_without_durable_receipts asserts the 502 and asserts the absence of both the extension and the header.
The fourth row is a real gap rather than a design choice. An unsupported method reaches the body guard before the route resolver runs, so an oversized BREW /pets gets a 413 with no evidence attached at all; service_413_unsupported_method_rejects_before_route_resolver pins that ordering with a resolver that panics if reached. parse_method accepts GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS, case-insensitively, and rejects everything else.
A signing failure lands in that same fourth row, and it behaves differently from the append failure one row above. The signing arm ends in .ok(), so a failed sign_transport_deny_receipt yields None and a bare 413 rather than a 502; the function’s own doc comment states the rule, that the deny path is never blocked by a signing error. A store that rejects the append fails the request closed. A keypair that cannot sign does not.
Duplicate capability query parameters force a deny
A capability may be presented in the x-chio-capability header or in the chio_capability query parameter, header first. The query form has a failure mode the header form does not: a query string is a list of pairs, and RequestMetadata collapses it into a HashMap where a repeated key keeps the last value. Trusting that collapse would let a caller present two capabilities and have the middleware silently pick one.
/// Invalid capability marker used to force fail-closed authority evaluation
/// when the transport presented an ambiguous query capability.
pub(crate) const DUPLICATE_QUERY_CAPABILITY_PRESENTATION: &str =
"duplicate chio_capability query parameters";
pub(crate) fn presented_capability_override(&self) -> Option<&'static str> {
self.has_duplicate_query_capability
.then_some(DUPLICATE_QUERY_CAPABILITY_PRESENTATION)
}The counting happens after percent-decoding, inside the url::form_urlencoded::parse loop, so chio%5Fcapability and chio_capability in one query string count as two occurrences of the same key. That case has its own test.
The override is not a local 403. It replaces the presented capability with a sentinel string that cannot parse as a capability token, so validate_presented_capability records an invalid_reason, and projected_verdict returns that reason as a deny under the CapabilityGuard label before it ever consults the policy mode:
fn projected_verdict(
policy: HttpAuthorityPolicy,
presented_capability: &PresentedCapabilityState,
) -> Verdict {
if let Some(reason) = &presented_capability.invalid_reason {
return Verdict::deny(reason, "CapabilityGuard");
}
match policy {
HttpAuthorityPolicy::SessionAllow => Verdict::Allow,
HttpAuthorityPolicy::DenyByDefault => match &presented_capability.capability_id {
Some(_) => Verdict::Allow,
None => Verdict::deny(
"side-effect route requires a capability token",
"CapabilityGuard",
),
},
}
}The ordering is what makes this bite. Safe methods (GET, HEAD, OPTIONS) run under HttpAuthorityPolicy::SessionAllow and would otherwise allow; everything else runs under DenyByDefault. Because the invalid-reason branch precedes the match, a GET carrying duplicate capability parameters is denied 403 even though a GET carrying none would have been allowed. service_denies_duplicate_query_capability_even_when_one_value_is_valid drives exactly that: a GET whose two chio_capability values are one garbage string and one properly signed token, asserting 403, a receipt id header, and receipt.is_denied(). Presenting a valid capability alongside an invalid one is worse than presenting nothing.
Only the capability key is counted
chio_capability. Every other repeated query key still collapses last-write-wins with no signal: parse_duplicate_non_capability_query_does_not_set_override asserts that tag=a&tag=b yields b and no override. The collapsed map is then fed to the content hash, so two requests differing only in a dropped duplicate value hash identically. If a policy reads a query parameter that a caller can legally repeat, that parameter is not safely bound.A configured store that cannot record the receipt fails the request
Durable-by-default is enforced at four call sites, all of them the same two lines: sign, then persist_http_receipt, and propagate the error rather than swallowing it. The embedded kernel persists its own receipts to the configured store; these four are the outer HTTP receipts that ChioService signs at the edge and that would otherwise live only in the response extensions.
crates/protocol/chio-tower/src/service.rs:82-211at fe56570| Site | When it runs | Has the effect already happened? |
|---|---|---|
| Transport deny | Oversized body, before evaluation | No. On append failure the 413 is replaced by a 502. |
| Final receipt on a deny verdict | After evaluation, before any response is built | No. The inner service is never called on a deny. |
| Decision receipt | Allow verdict, only when has_durable_receipt_sink() | No. This is the gate: an allowed request cannot reach the inner service unless its decision record landed. |
| Final receipt on the response status | After the inner service returns | Yes. The effect ran; the request then fails. |
The decision receipt is skipped entirely when no durable sink is attached. That is deliberate: with nothing to persist to, there is no durable audit trail to guarantee, so the ephemeral path avoids the extra signing rather than fail a request closed on a store that does not exist. service_fails_closed_when_durable_http_receipt_append_fails drives the configured case with a store that accepts the kernel’s own first append and rejects every one after, standing in for a volume that fills up mid-request. Its inner service panics if called, and the test asserts that any response returned is a server error.
One append failure is silent by design, and it is not the one you expect
persist_http_receipt first converts the HTTP receipt into a core receipt under the sink keypair. If that conversion fails, the receipt is dropped with a tracing::warn and the function returns Ok(()). The request succeeds and nothing is recorded. The documented rationale is that a durable log must only ever receive records the store can re-verify, and a receipt signed under a foreign kernel key can never be made verifiable, so failing every request closed over it would be worse. skips_an_http_receipt_that_cannot_convert_to_a_verifiable_record asserts the append count stays at zero and no error propagates. An append that the store rejects still fails the request closed; only an unconvertible receipt is dropped. Watch the warn log, because no counter marks it.The fourth site deserves its own reading. Finalizing on the response status happens after the inner service has already produced a response, so an append failure there returns an error for a request whose effect completed. The audit record is missing and the caller sees a failure; the two disagree. Nothing in the middleware compensates or retries. That is the residual gap in durable-by-default for the embedded path, and sizing the receipt volume is the only mitigation the code offers.
The one path that lets a request through unenforced
Fail-open is opt-in, off by default, and narrower than its name suggests. It applies to exactly one branch: a prepare error. A deny verdict is never bypassed by it.
let prepared = match prepared {
Ok(r) => r,
Err(e) => {
if evaluator.is_fail_open() {
crate::metrics::record_fail_open_suspected("tower");
tracing::warn!(
error = %e,
"Chio evaluation failed; fail-open enabled, forwarding request WITHOUT enforcement"
);
return inner.call(req).await.map_err(Into::into);
}
tracing::error!("Chio evaluation failed: {e}");
let mut response = http::Response::new(ResBody::default());
*response.status_mut() = http::StatusCode::BAD_GATEWAY;
return Ok(response);
}
};What reaches that branch is worth enumerating, because it is a wider set than "the kernel crashed". A caller identity that cannot be hashed, a content-hash failure, a kernel error, and a request that requires human approval all arrive as ChioTowerError converted from HttpAuthorityError. So does the durability refusal: a store-less, non-ephemeral evaluator produces a kernel error here, which means turning fail-open on converts a misconfigured deployment from "502 on every call" to "every call forwarded with no enforcement at all". An unsupported HTTP method reaches the same branch by a shorter route: parse_method fails inside the evaluator before HttpAuthority is consulted at all, returning ChioTowerError::Evaluation directly.
Receipt signing is not in the set. prepare never signs: sign_decision_receipt, finalize_receipt, and sign_transport_deny_receipt all run after it returns. On the normal request path the service propagates each with ?, so a signing failure there fails the request closed whatever fail_open is set to.
Every bypass increments chio_fail_open_suspected_total{surface="tower"}, and ChioService::new seeds the series at zero through a std::sync::Once so an absence-based alert fires on a scrape gap rather than on a deployment that has never failed open. The counter family lives in chio-metrics-spec as CHIO_FAIL_OPEN_SUSPECTED_TOTAL so a serving process can render it without depending on chio-tower. The tower layer is currently the only producer.
The second stack: dispatch with no HTTP shape
chio-tower also ships a stack that has nothing to do with HTTP. KernelService takes a KernelRequest (a chio_kernel::ToolCallRequest plus a TenantId) and dispatches straight to ChioKernel::evaluate_tool_call. Capability validation and receipt signing happen inside the kernel, not in this stack. build_layered(kernel, per_tenant_limit, request_timeout) composes, outermost first, KernelTraceLayer, a timeout-error normalizer, TimeoutLayer, TenantConcurrencyLimitLayer, and then KernelService.
The tenant limiter never waits. Each TenantId gets its own LoadShed<ConcurrencyLimit<S>> bucket, and a saturated bucket returns KernelServiceError::Overloaded immediately rather than queueing. The bucket table is bounded by DEFAULT_MAX_TENANT_CONCURRENCY_BUCKETS, 1024. A bucket becomes reapable only once it has sat idle past tenant_idle_reap_secs, 3600 by default, with no in-flight call, so a table of 1024 tenants all touched inside the last hour returns the distinct TenantTableFull variant even with nothing in flight. The in-flight condition is not a tie-breaker: a bucket with a live call is never reaped, because recreating its semaphore would let that tenant exceed its own limit. map_kernel_error routes a kernel RSS shed to the same Overloaded variant tower-side shedding produces, so both kinds of backpressure reach a caller as the same retryable signal.
The ext_authz check, and what it does not carry
ChioExtAuthzService::check is three steps with no branches worth hiding: translate, evaluate, respond. Translation failure and kernel failure both take the same exit.
let tool_call = match check_request_to_tool_call(&check) {
Ok(call) => call,
Err(err) => {
warn!(error = %err, "ext_authz translation failed");
return Ok(Response::new(fail_closed_response()));
}
};
match self.kernel.evaluate(tool_call).await {
Ok(verdict) => Ok(Response::new(verdict_to_response(&verdict))),
Err(err) => {
warn!(error = %err, "ext_authz kernel evaluation failed");
Ok(Response::new(fail_closed_response()))
}
}check_request_to_tool_call derives a tool identity of the form http.<method>.<path segments>, lowercasing the method and joining non-empty path segments with dots. Any byte in a segment that is not ASCII alphanumeric or one of -, _, ~ is percent-escaped, which is what keeps /admin.list (http.get.admin%2Elist) distinct from /admin/list (http.get.admin.list). A root path yields http.post with no segment. Every call reports server_id as envoy, the value of ENVOY_SERVER_ID, so a policy can restrict itself to Envoy-originated traffic.
Secrets are removed before anything leaves translation. collect_policy_headers keeps a seven-entry allowlist (content-type, content-length, host, user-agent, x-request-id, x-chio-session-id, x-chio-source) and drops everything else, with authorization and x-chio-capability-token on an explicit strip list ahead of the allowlist check. A bearer token survives only as a SHA-256 hex digest and a bearer: subject prefix. Response headers a caller might spoof inbound (x-chio-receipt-id, x-chio-verdict, x-chio-denial-reason, x-chio-denial-guard) are not on the allowlist, and translate_strips_chio_internal_response_headers asserts each one is absent from the forwarded map.
Caller identity resolves in three steps and stops at the first hit: x-chio-capability-token, then Authorization: Bearer, then the mTLS peer principal Envoy reported. Only the mTLS branch sets verified: true, and it does so because Envoy asserted it, not because the adapter checked anything. The capability branch is the one to read carefully: it copies the header value verbatim into both subject and AuthMethod::Capability, with verified: false. The adapter never parses, verifies, or checks the revocation of a capability token. Validating it is the implementing kernel’s job.
Note also that the header name differs across the two embeddings. The Tower path reads x-chio-capability (and the chio_capability query parameter); the ext_authz path reads x-chio-capability-token. A client written against one will present nothing to the other.
Verdict to CheckResponse
Verdict::Allow becomes Code::Ok plus an OkHttpResponse with every mutation list empty: no headers added, none removed, no query parameters set. The allow path injects nothing into the upstream request. Verdict::Deny becomes Code::PermissionDenied plus a DeniedHttpResponse carrying x-chio-denial-reason and x-chio-denial-guard and a hand-built JSON body.
Two details in the deny mapping are load-bearing. Header values pass through sanitize_header_value, which replaces every control character with a space and substitutes unspecified for an empty result, so a guard name or denial reason cannot inject control bytes into a response header. And envoy_status_code maps an arbitrary status onto Envoy’s enum, falling back to Forbidden when it cannot represent the input faithfully; 418 and 451 are explicitly mapped to 403 for that reason. The mapped value, not the requested one, is what the chio.http_status metadata field reports, so the status the client sees and the status the access log records never disagree. deny_verdict_metadata_reports_admitted_envoy_status pins that with a requested status of 999 and asserts both read 403.
Every response carries dynamic_metadata under the chio. namespace for Envoy access logs: chio.verdict always, chio.denial_reason, chio.denial_guard, and chio.http_status on a deny, and chio.fail_closed = true on the fail-closed path.
The fail-closed response is a fixed 500 under Code::Internal with the reason ext_authz request denied fail closed and the guard fail_closed. It is the same object for a malformed CheckRequest and for a kernel that returned an error, and it never carries the underlying fault: the real error reaches only the tracing::warn. kernel_error_redacts_internal_message_from_denied_response drives that with the error text db password=secret-token unavailable and asserts it appears in neither the body, the header, nor any metadata field. And missing_attributes_fails_closed_without_kernel_call asserts a CheckRequest with no attributes short-circuits with the kernel call count still at zero.
A missing body reports a non-zero length with no hash
derive_body_binding hashes raw_body if populated, else body, else nothing, but it always reads body_length from the size field Envoy declares. An ext_authz filter configured without with_request_body, or one whose max_request_bytes truncated the payload, therefore delivers a ToolCallRequest with body_hash: None and a non-zero body_length. A kernel that reads the length as evidence a body was seen will be wrong. The in-tree comment names the intent, "so the call is recorded as body-bearing"; treat body_hash as the only signal that bytes were actually bound.Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | The body cap is enforced during collection, not only from the size hint. Peak buffer use is bounded by max_body_bytes plus one in-flight frame, whatever the body advertises. | The frame loop in buffer_request_body; buffer_request_body_aborts_streaming_body_during_collection |
| Proved by test | Buffered bytes replay byte-identical to the inner service, and the hash covers the payload only. Trailers and other non-data frames are excluded from both hash and length. | buffer_request_body_hashes_and_replays_raw_bytes; the frame.into_data() continue arm |
| Proved by test | Duplicate chio_capability query parameters deny 403 with a signed receipt even on a safe method and even when one of the two values is a valid signed token. | service_denies_duplicate_query_capability_even_when_one_value_is_valid; the invalid_reason branch preceding the policy match in projected_verdict |
| Proved by test | A 413 carries a Chio-signed deny receipt in three places, all agreeing, and the signature verifies under the embedded kernel key. | service_413_response_has_signed_receipt_body |
| Proved by test | A configured receipt store that rejects an append fails the request closed; the inner service does not run and no 2xx is returned. | service_fails_closed_when_durable_http_receipt_append_fails, whose inner service panics if reached |
| Proved by test | A transport deny without durable receipts refuses to sign at all: 502, no receipt extension, no x-chio-receipt-id, empty body. | service_413_fails_closed_without_durable_receipts |
| Proved by test | The ext_authz adapter fails closed on a malformed CheckRequest without calling the kernel, and never returns internal fault text to a caller. | missing_attributes_fails_closed_without_kernel_call; kernel_error_redacts_internal_message_from_denied_response |
| Shipped | Bearer tokens, API keys, and cookie values never reach a policy engine in plain text through either embedding. The Tower extractor hashes them before they become a subject; the ext_authz translator strips authorization and x-chio-capability-token from the forwarded header map. Capability tokens are the deliberate exception: both embeddings pass the presented token through verbatim, because validating it is the policy engine’s job. | extract_identity in identity.rs; collect_policy_headers and extract_caller_identity in translate.rs; extract_presented_capability in evaluator.rs |
| Limit | The final receipt is persisted after the inner service has already run. An append failure at that point returns an error for a request whose effect completed, and nothing compensates. | The finalize_receipt / persist_http_receipt pair after inner.call(req).await |
| Limit | An HTTP receipt that cannot convert into a verifiable core receipt is dropped with a warn log and no error. The request succeeds with nothing recorded, and no counter marks it. | The Err arm of to_chio_receipt_with_keypair in persist_http_receipt; skips_an_http_receipt_that_cannot_convert_to_a_verifiable_record |
| Limit | An oversized body under an unsupported HTTP method, one whose caller identity cannot be hashed, or one whose deny receipt fails to sign, gets a bare 413 with no receipt, no content-type, and no receipt-id header. The signing arm ends in .ok(), so unlike an append the store rejects, a signing failure does not fail the request closed. | The (Ok(http_method), Some(caller_hash)) match and the trailing .ok() in build_payload_too_large_response; service_413_without_receipt_body_omits_json_content_type |
| Limit | Fail-open covers a wider set of conditions than transient kernel faults. A missing durable store, an unsupported method, and a pending approval all reach the same branch, so enabling it on a misconfigured deployment forwards every request unenforced. | impl From<HttpAuthorityError> for ChioTowerError; parse_method in evaluator.rs; the is_fail_open() branch in ChioService::call |
| Limit | Duplicate detection covers chio_capability and nothing else. Every other repeated query key collapses last-write-wins into the map that feeds the content hash, with no signal. | RequestMetadata::parse; parse_duplicate_non_capability_query_does_not_set_override |
| Limit | The ext_authz adapter treats a capability token as an opaque string. It copies the header into the subject with verified: false and never parses, verifies, or revocation-checks it. | The AuthMethod::Capability branch of extract_caller_identity |
| Not implemented | A policy engine inside chio-envoy-ext-authz. The crate depends on no chio-* crate, ships no EnvoyKernel implementation, and signs no receipt. A deployment built on it supplies its own. | The crate’s [dependencies] table; "Internal: none" in its architecture notes; the EnvoyKernel trait in service.rs |
| Not wired | A shipped ext_authz server. No crate in the workspace depends on chio-envoy-ext-authz and no binary target builds one. The Istio example points at a placeholder image an operator must build and publish themselves. | Repo-wide search for the crate name outside its own directory; examples/istio-ext-authz/README.md |
| Not claimed | Wrapping a live tonic gRPC service in ChioLayer. Real tonic::body::Body replay is not covered by the middleware contract; the gRPC test drives bytes-backed bodies with gRPC-shaped headers and says so. | The lib.rs crate doc; the header comment on tests/tonic_integration.rs |
| Not claimed | Header injection into the upstream request on the ext_authz allow path. OkHttpResponse is constructed with every mutation vector empty, so an allow forwards the request unchanged. The header table in the protocol design document specifies a shape the response does not carry. | verdict_to_response in response.rs; docs/protocols/ENVOY-EXT-AUTHZ-INTEGRATION.md section 3.3 |
Next Steps
- HTTP Framework Middleware · the wiring this page assumes: adding the layer to an axum router, custom identity, custom route patterns
- In-Process Library · the kernel the Tower layer delegates to, its embedded signing key, and what a host process owns at startup
- Envoy ext_authz · filter configuration, mesh registration, and the deployment topologies for the adapter
- Fail-Closed Semantics · the rule the durability gates implement, stated once for every enforcement path
- Sidecar HTTP Service · the alternative when the host process is not Rust, and what changes when the decision crosses a socket