Expand description
The server envelope layer (ADR 0006 §§2, 10, 12; cratestack#1006): opens signed requests and seals responses for the generated REST and RPC routers, before rate limiting and idempotency run.
Feature envelope (the layer and its traits, no crypto crate), or cose
(adds cratestack_cose::CoseEnvelope as the ServerEnvelope,
decision D7); the cratestack-pg and cratestack-api facades forward
both, and each schema gets a generated
cratestack_schema::axum::envelope_layer(envelope, policy, audience)
that picks its transport and accepted op-contract digests.
§Placement (D12)
The layer must be the last .layer(..) on the generated router, and
applied before the router is merged or nested:
let router = cratestack_schema::axum::router(db, procedures, codec, auth, limit)
.layer(IdempotencyLayer::new(store, ttl))
.layer(RateLimitLayer::new(buckets, config))
.layer(envelope_layer); // runs first
let app = axum::Router::new().nest("/api", router); // mount_prefix("/api") on the layer- Last, so it runs first: the rate limiter and the idempotency layer key
on the
VerifiedPrincipalit inserts (§12), and the idempotency layer hashes the opened payload, so a client that re-seals a retry (a newctiunder the sameIdempotency-Key) gets the stored response, sealed afresh for the new request. - Through
Router::layer, soMatchedPathand the path parameters are already in the request; aServiceBuilderaround the whole app loses them, andnest_servicenever setsMatchedPathfor the outer mount. - Verification, including the key resolver and nonce store lookups,
runs before rate limiting, and under
Optionalso does signing every response to a signed or nonce-bound request. Put an IP-level limiter outside this layer to bound what an unauthenticated flood can cost.
§What the layer decides, per request
- A bodiless
OPTIONS(a CORS preflight) passes through untouched. - The
BindingResolvernames the generated op (route and path parameters), once. Not an op: seeResolution(D6; under aRequiredpolicy a matched route nobody resolved fails closed, S2). - The
EnvelopePolicypicksEnvelopeMode::Required,OptionalorOfffor the op, seeing aPolicyRequest(no headers). There is no default (D9). A/rpc/batchcall runs under the strictest mode ofbatchand every frame’s op (B1). The op’s row in the accepted-contract table is found from it, and theCratestack-Contractheader picks which accepted digest the request is opened under; one that names none is an unsigned426(seecontract.rsfor the rules). - A request whose
Content-Typeis an envelope type is opened. UnderRequireda request that is not is refused. UnderOptionalit runs unsigned, and its response is sealed only if it carries a validCratestack-Nonceand theResponseSealPolicyagrees (D10). - An opened request reaches the router as the plain request it wraps,
with the negotiated
Content-Type/Accept, and gains thecratestack_core::VerifiedSigner(recorded on the handler’s context, D2, never an authentication) and thePrincipalMapper’sVerifiedPrincipalextensions. - The response is sealed with a binding built from the same values the
request was opened against, plus the request digest and the status.
Every response to a signed request is sealed, under
Optionaltoo (S3), errors included, except the layer’s own refusals (D4).
§Payload media types (cratestack#1168)
A payload is CBOR unless the client names another type (unbound Cratestack-Payload-Type
/ -Accept headers, bound in the AAD): EnvelopeLayerBuilder::payload_media_types.
§Security invariants, enforced here and not by the plug-ins
- A request whose
Content-Typehas the base typeapplication/cose(any case, any parameters), or oneServerEnvelope::is_envelope_content_typeclaims, is opened and verified before anything else sees it, whatever the policy says, or refused with the unsigned415: never forwarded. - Under
Required, no unsigned request reaches the inner service, and no response to a signed request goes out plain: one the layer cannot seal (a stream, a body of an un-negotiated type) is replaced by a sealed error. - A verification failure is always the same coarse, unsigned
401(D4): a replayed request must not earn a signed “401” for a request that already executed. Any other envelope error is a500whose detail is only logged. - The
PrincipalMapperonly ever sees aVerifiedRequest, which only this layer can construct, after the envelope verified. - The resolver and the policy are consulted once per request, and the response binding reuses the values the request was opened against, so no plug-in can make the two bindings disagree. The audience, the op-contract digest, the method, the canonical query, the bound headers, the payload media type and the request digest are the layer’s own.
§What is bound, and what is not
The AAD binds the audience, method, route, path parameters, canonical
query (distinct keys in any order bind alike; one key’s repeated values
keep their order), op-contract digest, the message’s payload type, and the
Idempotency-Key and If-Match headers exactly as sent (S1; a request
sending either twice is refused with a 400). For a response: the
request digest and the status. Response headers (ETag,
Retry-After, …) are not authenticated. An Optional response
sealed for an unsigned request binds its nonce and payload, not the
caller: it proves this server answered, not who asked.
§Known limits (P0)
- Streamed responses (
@streamoverapplication/cbor-seq, SSE subscriptions) cannot be sealed untilchainmode (ADR 0006 P1). A signed request gets anAcceptof sealable types only, so a@streamop answers with one buffered, sealed array; a signed subscription is refused with a sealed406before its handler runs. Only an unsigned request underOptionalcan stream (plain). - A response is re-buffered to be sealed, up to
cratestack_core::MAX_RESPONSE_REBUFFER_BYTES; a longer one becomes a sealed500. A request body is buffered up to the layer’smax_body_bytes(413beyond it). - A fallback handler (
Router::fallback) sets noMatchedPath, so the layer treats its traffic as unmatched and lets plain requests through. - The bound digest is the called op’s contract digest
(
cratestack_core::op_contract_digest, binding version 2, cratestack#1123), so it moves only when that op’s wire shape does: a policy, an index, SQL, a validator, another op or a new procedure leaves it alone, and a wire-shape edit makes the old client’s op answer the unsigned426 contract_unsupported. Semantic changes that leave every shape alone (anIntswitching units) are not caught, as before. A server built withcontracts = "..."also accepts the older per-op digests its lock holds while they stay wire-compatible with the current one. A signed/rpc/batchbinds the whole-contract digest until batch frames carry their own, so any client-facing change refuses it. Binding version 1 messages are refused. - One envelope per layer: a router accepting Sign1 devices and Mac0
services at once needs the composite of cratestack#1078, which the
per-request
SealContextandSealedmedia type make possible. - The response payload is copied once into the sealed message (D1; the zero-copy head/tail API is cratestack#1076).
Structs§
- Accept
Names Envelope - Seal when the request’s
Acceptnamesapplication/cose(D10). - Envelope
Layer - Opens signed requests and seals responses for a generated router; see
the module docs for placement and guarantees.
Build it with
EnvelopeLayer::builder, or with the generatedcratestack_schema::axum::envelope_layer. Cheap to clone. - Envelope
Layer Builder - Configuration for an
super::EnvelopeLayer. Nothing is checked untilbuild, and the calls may come in any order. - Envelope
Service - The service
super::EnvelopeLayerproduces. - Opened
Request - A request an envelope verified: the payload to hand the router, the signer to record, and the context its response is sealed with.
- Policy
Request - The op a request addresses, as an
super::EnvelopePolicysees it. - Resolved
Route - A resolved op: the binding’s
routeandpath_params. - Rest
Binding Resolver - REST: the
RouteTransportDescriptormatching the method and the matched template (mount prefix stripped), bound by its declared path. - Route
Request - What a resolver sees of a request: the method, the raw path, the route
template axum matched (
Nonewhen no route matched, or undernest_service), and the matched path parameters, percent-decoded, in the order axum reports them (a parameterised mount prefix’s first). - RpcBinding
Resolver - RPC: the op id axum decoded from
/rpc/{op_id},batchfor/rpc/batch(decision D11), andsubscribe/<op id>for a subscription. The op id and the body digest bind the call, so path parameters are empty (ADR 0006 §4), except under a parameterised mount (nest("/t/{tenant}", ..)), whose values are bound for the same reason assuper::RestBindingResolver’s. - Seal
Context - Opaque per-request state an envelope passes from
open_requestto theseal_responseof the same request (API-review decision, 2026-09-26). - Sealed
- A sealed response body and the media type the layer stamps on it as its
Content-Type(API-review decision, 2026-09-26: a composite answers each request in its own framing, so the type is per response, not per envelope). - Thumbprint
Principal - The prefix,
cose:by default, followed by the lowercase hex of the verifying key’s full RFC 9679 thumbprint, never its 8-bytekid(two keys can share one). - Unsigned
Request - An unsigned request under
Optionalthat carries a validCratestack-Nonce, as aResponseSealPolicysees it. - Verified
Request - A request the envelope verified, as the
PrincipalMappersees it.
Enums§
- Envelope
Mode - How the layer treats one op’s traffic (ADR 0006 §2).
- Resolution
- A
BindingResolver’s answer.
Constants§
- DEFAULT_
MAX_ BODY_ BYTES - The default cap on a request body the layer buffers: the generated routers’ default body limit plus 16 KiB of envelope overhead (a header, a signature, and room for a post-quantum one later, ADR 0006 Q5).
- DEFAULT_
MAX_ CONTRACT_ TRIALS - The default cap on the accepted digests tried for a request that names
none (
EnvelopeLayerBuilder::max_contract_trials). - PAYLOAD_
MEDIA_ TYPE - The payload type of a sealed message that names none (ADR 0006 §4): the
default, no longer the only one (
EnvelopeLayerBuilder::payload_media_types).
Traits§
- Binding
Resolver - Resolves the op a request addresses.
- Envelope
Policy - Picks the
EnvelopeModefor a request, from the op it addresses. - Principal
Mapper - Names the principal a verified request is charged to: the value of the
VerifiedPrincipalextension the layer inserts, which the rate limiter’s default key (princ:bucket, no cardinality budget) and the idempotency layer’s default fingerprint (princ:namespace) use. - Response
Seal Policy - Decides whether the response to an unsigned request is sealed, under
Optional. The default isAcceptNamesEnvelope(D10). A closure is a policy (second-review nit): - Server
Envelope - What the layer needs from an envelope on the server side: open a request against its binding, seal a response against its binding.