Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Arcature Guide

Arcature is a full-stack web framework for Rust. One crate, batteries included: HTTP routing, the Inertia protocol, a database layer, authentication, validation, background jobs, events, cache, storage, mail, and the arc command-line tool. One dependency line is the whole install – see Getting started, and the Status section below for what “one crate” currently buys you.

It is opinionated. It integrates proven components — Axum, Tower, Tokio, SeaORM, SQLx, OpenDAL, lettre, tracing — and owns what sits between them: the application lifecycle, the request pipeline, the conventions, and the vocabulary. Where the opinions run out, the underlying crates are re-exported and reachable.

What this guide assumes

That you know Rust, and that you have written a web application before in something. It does not re-teach async, and it does not explain what a migration is. It does explain what Arcature does differently from what you would expect, and why.

How to read the examples

Every code sample in this guide was written against the code as it exists, not against the API as it is planned. Where a feature is documented but not built, the chapter says so under a Not yet implemented heading and shows what works today instead. A missing example costs you a search; a wrong one costs you an afternoon.

Samples elide use statements when the prelude covers them:

use arcature::prelude::*;

Status

arcature 0.1.2 is on crates.io. 0.1.0 was the first release of this codebase, there is no upgrade path from anything earlier, and the API is still moving. Neither patch since removes anything or changes a signature, so arcature = "0.1" carries you to both: 0.1.1 added fourteen subsystems, all behind feature flags that are off by default, and 0.1.2 fixes two controls that were not doing what their configuration said. Read the changelog and the upgrade note before relying on anything here.

The 2026.x versions under the arcature* names on crates.io come from an abandoned predecessor repository. They are yanked and share nothing with this codebase but the name.

Versioning is semantic, starting at 0.1.0. Under 0.x the fields shift one place left: a minor bump is the breaking one, a patch bump is compatible, and Cargo reads them that way too, so arcature = "0.1" will not silently carry you to 0.2.

Where the reasoning lives

Doc comments explain what a type does. This guide explains how the pieces fit. Decisions explains why some of them are shaped in ways that will surprise you: no npm package, one TCP port in development, a fixed layer order, no hidden registry.

Getting started

Requirements

  • Rust 1.97.1 or newer (edition 2024). rust-toolchain.toml pins stable.
  • PostgreSQL 17 for anything using the database or the job queue.
  • Node.js, only if you are building a frontend with Vite. Arcature itself publishes no npm package.

Installing

cargo add arcature

That is the whole install. To follow main ahead of a release instead, depend on the repository and pin a revision – a branch reference will move under you.

[dependencies]
arcature = { git = "https://github.com/ArcatureLabs/Arcature", rev = "..." }

The smallest application

use arcature::application::EngineResult;
use arcature::prelude::*;

#[arcature::main]
async fn main() -> EngineResult<()> {
    Application::new()
        .routes(Routes::new([Route::get("/", index).name("home")]))
        .build()
        .run()
        .await
}

async fn index() -> Result<Response> {
    Ok(text(StatusCode::OK, "hello"))
}

Three things to notice.

.build() is required. Application::new() returns an ApplicationBuilder; .run() lives on Application. Forgetting .build() is a type error, not a runtime surprise.

run() returns EngineResult<()>, not the framework’s Result<()>, and EngineResult is not in the prelude — it lives at arcature::application::EngineResult. Engine failures (a port already bound, a database that will not connect) are a different kind of failure from a handler’s, and they deliberately do not share an error type.

Handlers return Result<Response>, where Result is Arcature’s. text, json, redirect and no_content build the common shapes.

The generated application

arc new writes a Laravel-shaped project rather than a single file:

app/
  controllers/   models/     services/
  requests/      policies/   resources/
bootstrap/
  app.rs         state.rs
config/
database/migrations/
routes/mod.rs
resources/js/    resources/css/
public/
storage/
src/main.rs      src/lib.rs
tests/smoke.rs
.env

bootstrap/app.rs is the composition root. It loads .env, reads typed configuration, and wires the subsystems:

pub fn app() -> Result<Application<crate::bootstrap::AppState>> {
    dotenvy::dotenv().ok();
    let config = crate::config::load()?;
    Ok(Application::new()
        .routes(crate::routes::routes())
        .bind(&config.bind_addr)
        .port(config.port)
        .database(config.database)
        .cache(config.cache)
        .storage(config.storage)
        .mail(config.mail)
        .jobs(jobs_registry())
        .build())
}

bootstrap/state.rs defines AppState, the cloneable bundle every handler reaches through State<AppState>. Each field is an Option, because a subsystem that was never configured contributes None rather than a panic:

#[derive(Clone)]
pub struct AppState {
    pub db: Option<Db>,
    pub jobs: Option<Jobs>,
    pub cache: Option<Cache>,
    pub storage: Option<Storage>,
    pub mail: Option<Mailer>,
}

The state is produced after startup, from the started Resources, which is why it is a closure rather than a value:

pub fn state_fn() -> Arc<dyn Fn(&Resources, &Lifecycle) -> AppState + Send + Sync> {
    Arc::new(|res, _lc| AppState {
        db: res.db().cloned(),
        jobs: res.jobs().cloned(),
        cache: res.cache().cloned(),
        storage: res.storage().cloned(),
        mail: res.mail().cloned(),
    })
}

src/lib.rs puts the two together with run_with_state.

Features

Arcature’s features reduce the compile surface; they are not a self-assembly kit. default is a working full-stack application. Turn features off to compile less, not on to reach a usable state.

# The whole framework.
arcature = { git = "...", features = ["fullstack"] }

# An API server: no Inertia, no static assets pipeline.
arcature = { git = "...", default-features = false, features = ["api", "database", "auth", "validation"] }

The database driver is split three ways — db-postgres, db-sqlite, db-mysql — so a SQLite application does not compile the PostgreSQL protocol. The job queue requires PostgreSQL.

Next

Your first module to add a feature to the application you just generated, Routing for how requests reach handlers, or Inertia if you are building a page-driven frontend.

Your first module

A fresh application is laid out by kind: every controller in app/controllers/, every service in app/services/, every page in app/pages/. That is the right shape while there is one feature. It stops being the right shape at ten, when “show me everything billing does” means opening seven directories and knowing which files in each are billing’s.

A module is the other shape. One directory owns one feature’s controller, service and routes, and a module! block at its root is the index of what is inside. The two layouts coexist deliberately — the scaffold’s own Web module keeps the by-kind directories, and an application is free to use one, the other, or both.

This page goes from arc new to a route that answers, without editing a file by hand.

Generate it

$ arc new acme --stack react --db sqlite
$ cd acme
$ arc make:module billing
created app/modules/billing/mod.rs
updated app/modules/mod.rs
created app/modules/billing/controller.rs
created app/modules/billing/service.rs
created app/modules/billing/routes.rs

Four files written, one updated. The module is registered: nothing else has to be touched before it serves.

$ arc serve --port 3000
$ curl localhost:3000/billing
BillingController::index

What landed

app/modules/billing/mod.rs

The index. module! records what this feature contains, so the application graph — and through it arc routes, arc typegen and arc build — can see it.

pub mod controller;
pub mod routes;
pub mod service;

use arcature::prelude::*;

use controller::BillingController;

module! {
    pub Billing {
        controllers: [BillingController],
        services: [BillingService],
        routes: routes::BILLING_ROUTES,
    }
}

BillingService is named but not imported, and that is not an oversight. controllers: and routes: are resolved at this site — the first reads the controller’s ControllerMetadata::METHODS, the second is a path to a const — while services: and policies: are recorded as names only. Importing a type the macro never resolves would be an unused import.

app/modules/billing/controller.rs

use arcature::prelude::*;

pub struct BillingController;

#[controller]
impl BillingController {
    pub async fn index() -> Result<Response> {
        Ok(text(StatusCode::OK, "BillingController::index"))
    }
}

Result, Response, StatusCode and text all come from the prelude, which is the point: a controller should not need a use list before it does any work.

app/modules/billing/service.rs

#[service]
pub struct BillingService {
    db: Db,
}

#[service] generates Resolve<S>, which composes the struct from the application’s resources per request: each field type is resolved in turn, so a service may hold another service as a field. Keep the methods framework-agnostic — take domain values, return domain values, and let the controller map the result to HTTP.

app/modules/billing/routes.rs

use super::controller::BillingController;
use crate::bootstrap::AppState;

routes! {
    pub billing {
        state: AppState;

        get "/billing" => BillingController::index { name: billing.index }
    }
}

The path is absolute. Living in a module adds no prefix — a module is a unit of source organisation here, not a mount point. Reach for a route group when you want a shared prefix, inside the module or outside it.

The route name carries the module’s own, and that matters more than it looks. app/modules/mod.rs merges every module’s routes into one table. Two modules claiming the same path is a panic at boot, from Axum, so it cannot reach production unnoticed. Two modules claiming the same name is not: the later one silently wins, and url_for("index", ..) starts resolving somewhere else. Namespacing the name is what makes ten modules safe to merge.

How it is wired

arc make:module adds three lines to app/modules/mod.rs: the pub mod declaration, one entry in the descriptor list, and one in the route list.

pub fn modules() -> Vec<ModuleDescriptor> {
    vec![
        // arc:modules descriptors
        billing::billing_module().clone(),
        // arc:end
    ]
}

pub fn routes() -> Routes<AppState> {
    let collections: Vec<Routes<AppState>> = vec![
        // arc:modules routes
        billing::routes::billing_routes(),
        // arc:end
    ];
    collections.into_iter().fold(Routes::empty(), Routes::merge)
}

app/mod.rs appends modules() to the scaffold’s Web module before handing the list to ApplicationGraph::new; bootstrap/app.rs merges routes() into the application’s table. Both were already written by arc new — the generator only ever inserts into the two marked regions.

Editing the file by hand is fine. Reorder the entries, reformat them, add one yourself. The generator matches the arc:modules / arc:end markers and nothing else about the surrounding text, and it skips an entry that is already there, so re-running it after deleting a directory does not leave a duplicate behind.

Deleting a marker is the one thing that breaks it. When that happens it says so and writes the module’s files anyway, leaving one line for you to paste:

$ arc make:module billing
created app/modules/billing/mod.rs
updated app/modules/mod.rs
note: app/modules/mod.rs has no `// arc:modules routes` marker -- add `billing::routes::billing_routes(),` to its routes list by hand
created app/modules/billing/controller.rs
...

Nested names

A name with slashes nests, and the intermediate mod.rs files are created as needed:

$ arc make:module admin/reports
created app/modules/admin/reports/mod.rs
updated app/modules/admin/mod.rs
updated app/modules/mod.rs

The registration follows the nesting — admin::reports::reports_module() — and so does the route name, which becomes admin.reports.index. The URL does not: routes.rs still declares an absolute path, so the module serves /admin/reports because that is what is written there, not because of where it sits on disk.

What the graph checks

ApplicationGraph::new runs at boot and rejects three wiring mistakes:

GraphErrorMeans
DuplicateModuletwo modules declared the same name
UnknownImportan imports: entry names a module the graph does not hold
CircularDependencymodules import each other in a loop; the error lists the cycle in order

app/mod.rs calls .expect(..) on the result, so all three are a panic at boot in the scaffold. That is the honest answer: each one is the same on every run and has nothing to do with the request, so surfacing it on one unlucky request later would only make it harder to place.

A type missing from a module! block still compiles and still serves. It is simply invisible to the graph, and therefore to arc routes, arc typegen and arc build. That is the trap module! exists to close, one level up.

What is deliberately not scaffolded

A module gets four files, not five. arc make:policy is one command away, but a generated policy cannot compile until it is pointed at a model and a user type — Policy<M> bounds its associated User by AuthUser, so there is no placeholder that type-checks. Shipping one inside a module would mean arc make:module billing produces a project that does not build.

Add one once the feature has a model and a user type to point it at:

$ arc make:policy invoice
created app/policies/invoice.rs
updated app/policies/mod.rs
note: invoice names `Invoice` and `User`; point them at the model this policy guards and the application's user type

That lands in app/policies/, not in the module — nearly every make kind writes to the by-kind directory it belongs to. The two exceptions are make:module, because a module is a directory, and make:auth, which writes a feature’s worth of files into app/auth/. Move the file under app/modules/billing/policy.rs if you want the feature to own it, add the pub mod policy; line, and name the type in the module’s policies: list. Nothing in the generator or the graph depends on where the file sits; the list is what the graph reads.

The module! sections

SectionHoldsResolved at the call site?
importsmodule names this one depends onno — names
exportsmodule names this one providesno — names
controllerscontroller typesyes — reads ControllerMetadata::METHODS
servicesservice type namesno — names
policiespolicy type namesno — names
routesa path to a &'static [RouteDescriptor] constyes
listenersevent → listener name pairsno — names
jobsjob kind, version and handler nameno — names
commandscommand name → function name pairsno — names
schedulesjob kind, version and cadenceno — names
pagespaths to #[page] typesyes — reads PAGE_CONTRACT_ENTRY

Order between sections is free, every section is optional, and the trailing comma is optional. The right-hand column is the whole rule for what has to be in scope: a name is a string the graph compares, a resolved entry is a type or const the macro reads through. Which way a section falls decides what a typo costs. A misspelled entry in controllers: is an ordinary “cannot find type” at the module! line. A misspelled entry in services: compiles, because the macro only ever records the string — the graph reports the name you wrote, and nothing anywhere disagrees with it.

Next

Routing for groups, middleware and the route table; Controllers for what #[controller] emits; Testing for driving a module’s routes without a server.

The dev loop

One number decides whether a framework is pleasant to work in: the time between saving a Rust file and seeing the result in the browser. arc dev exists to make that number small, and this page says what it currently is, how it was measured, and where the time goes.

Numbers without a method are folklore, so the method is here in full and the machine is named. Reproduce it before believing it.

What one save costs

arc dev holds the TCP port itself and runs the application as a child process, so a rebuild replaces only the child. The supervisor prints what each part of the trip took:

cargo 7.85s (check 2.73s, codegen+link 5.06s)  swap 0.21s  spawn 1.44s  boot 0.31s  total 9.81s

Those stages are:

StageWhat it is
cargocargo build --features dev, spawn to exit.
checkStart of the build to the last non-executable artifact.
codegen+linkThat boundary to the linked executable.
swapStopping the old process and staging the new binary.
spawnAsking the operating system to start it.
bootThe new process starting to its first accepted IPC connection.
ViteNothing. A .tsx, .vue or .css edit never reaches this loop.

cargo dominates, so it is what the measurement below isolates.

How this was measured

  1. arc new demo --stack react --db postgres, with arcature patched to the working tree so the framework under test is the one in the repository.
  2. cargo build --features dev once, cold, to fill target/.
  3. Change one line in app/controllers/home_controller.rs – the string the welcome page renders – and time cargo build --features dev. Three times, each with a different string, so no run can be answered from a previous run’s cache.
  4. A fourth run with --timings, for the per-unit breakdown and the fresh/dirty split.

--features dev is what arc dev itself runs, so this is the same build the loop performs, not an approximation of it.

The baseline

Measured 2026-08-21 on the machine described below.

MeasurementResult
Cold build, empty target/52m 38s
cargo build with nothing to do4.1s
One-line handler change33.2s / 34.3s / 37.6s
demo.exe18.8 MB
demo.pdb71.2 MB

The --timings run breaks the rebuild into exactly two units of work out of 489 in the graph:

UnitTimeOf which
demo lib50.6sfrontend 6.9s, codegen 43.7s
demo bin39.4scodegen of a nine-line main.rs, then the link

Two dirty units, 487 fresh. That is the first thing the numbers settle: on a Rust-only change nothing is rebuilt that need not be. Not arcature, not arcature-macros, not the embedded scaffold templates, not a dependency. The loop is not slow because it recompiles too much; it is slow because the two units it does compile are expensive.

The second thing they settle is where inside those two units the time is. Type-checking the application crate – the part a developer thinks of as “compiling” – is 6.9 seconds of a 90-second trip. Everything else is code generation and linking, and the 71 MB of debug information is why: every frame of it has to be written by rustc, read by the linker, and merged into a program database on each save.

The machine

This is a small, busy machine, and the absolute numbers are worse than a developer laptop would show:

  • Windows 11, x86_64-pc-windows-msvc, 4 logical CPUs.
  • rustc 1.98.0, cargo 1.98.0.
  • Microsoft Defender watching target/.
  • Other Cargo builds running concurrently throughout. Cargo reported Max concurrency: 1 (jobs=4 ncpu=4) for the timed run, and that run took 96.8s against 33-38s for the same work untimed – a two-to-three times spread from contention alone.

Treat the absolute figures as an upper bound and the shape – 5% frontend, 95% codegen and link, nothing spurious rebuilt – as the finding. The shape is what any change has to move.

Because the load varied, only measurements taken under --timings are compared against each other below: those report per-unit compile time rather than wall clock, and both the before and the after run reported the same Max concurrency: 1 (jobs=4 ncpu=4). Plain wall-clock series taken minutes apart on this machine differ by more than any change being measured, and are not used as evidence for anything.

What was cut

The baseline points at one thing: debug information. Not the application’s own – the scaffold has always built it with line-tables-only – but its dependencies’.

The instinct is that a dependency compiles once and then sits in target/, so its profile is a one-time cost. That is wrong for generic code. Every Vec<MyThing>, every tokio combinator, every sea-orm query builder used with the application’s own types is monomorphised into the application’s crate, and its debug information is emitted by rustc and merged by the linker there – on every save, for as long as the project exists. Nobody steps through tokio while debugging a controller, so the scaffold now sets:

[profile.dev.package."*"]
opt-level = 2
debug = false

[profile.dev.build-override]
opt-level = 2
debug = false

Same machine, same application, same one-line change, both runs under --timings:

BeforeAfter
demo lib50.6s (frontend 6.9s, codegen 43.7s)25.5s (frontend 4.9s, codegen 20.6s)
demo bin39.4s19.8s
Both dirty units90.0s45.3s
demo.pdb71.2 MB29.5 MB
demo.exe18.8 MB18.8 MB

Half, and the executable is byte-for-byte the same size, because none of this was ever in it. Backtraces still carry file and line: the application’s own crates were never touched. A developer who wants a step debugger through a dependency can have it for one run with CARGO_PROFILE_DEV_PACKAGE_tokio_DEBUG=2.

Three levers that look obvious are not taken, and the manifests say why:

  • opt-level = 0 and a high codegen-units are Cargo’s dev defaults. Writing them down changes nothing.
  • split-debuginfo is target-specific. rustc --print split-debuginfo reports packed as the only stable value on *-pc-windows-msvc, which is what MSVC already does by writing a .pdb. A fixed value in the manifest would be a no-op for some developers and a hard error for others.
  • A fast linker is already configured. .cargo/config.toml puts Windows on the toolchain’s own rust-lld.exe, and leaves Linux and macOS on the system linker with mold and wild as commented opt-ins – a config that fails on a machine without the tool is worse than a slow link.

What is left

The 2.5 second target is not met on this machine, and halving the cost was not enough to meet it. What remains, in order:

  1. Linking the executable. Even with a third of the debug information, the demo bin unit is 19.8s for a main.rs of nine lines. Almost all of that is rust-lld pulling every rlib in the graph together. It is proportional to the size of the program, not to the size of the change, so it does not shrink as the diff shrinks.
  2. Code generation for the application crate, 20.6s. This is monomorphisation: the application instantiates a large amount of generic machinery from axum, tokio and sea-orm, and each instantiation is compiled into this crate.

Type-checking – 4.9s, and the only part proportional to what was actually edited – is already inside the budget. The loop is not slow because the compiler is slow at understanding the change; it is slow because the whole program is rebuilt around it.

The distance to the target, measured rather than scaled

The seconds above are per-unit compile time on a saturated four-core machine. They are the right numbers for comparing the before and after of this change, because both runs were taken the same way, and they are the wrong numbers for deciding how far 2.5s is. An earlier version of this page scaled them against issue #8’s quiet reading and put a post-change Cargo invocation near 3.8s, while saying plainly that the estimate was not settled and that somebody should re-measure on an idle machine.

Somebody has. Six one-line handler edits on an otherwise idle machine, warm target, cargo build --features dev each time:

RunWall clock
no-op build (nothing changed)1.2s
rebuild 15.8s
rebuild 24.3s
rebuild 34.5s
rebuild 44.3s
rebuild 54.4s
rebuild 65.5s
warm cargo check (type-check only)1.6s

So the Cargo half of the loop is about 4.4s, and the estimate was optimistic by roughly fifteen per cent – close enough to have been worth making, wrong enough to have been worth checking. Against a 2.5s target that is over by about 1.8x, not the fourfold the saturated per-unit figures suggest.

The split holds up and is the useful part. Type-checking a one-line change is 1.6s, comfortably inside the budget; everything above that is code generation and linking for the whole program, which does not shrink when the diff does. The loop is not slow because the compiler is slow at understanding the edit.

One trap for whoever measures next. A cargo check taken straight after a cargo build reads about 90s on this project, and it is not the type-check cost – check keeps its own fingerprints and artifacts, so the first one after a build is cold. Run it twice and take the second; the 1.6s above is a second run. A measurement script that interleaves build and check will report the cold number every time and make type-checking look like the bottleneck it is not.

The second thing is larger, and is missing from the list above because --timings cannot see it. Issue #8’s own breakdown has spawn at 5.55s of an 11.20s loop – bigger than the entire Cargo invocation – and identifies it as Microsoft Defender scanning the 18.9 MB executable that was just linked, reproducibly, at roughly 80x the cost of running a file it has already seen. Nothing in this change touches it, and no profile setting can: the scan happens after Cargo has exited. arc doctor already reports it with the remediation. Anyone reading this page as the state of the dev loop should read that stage as still the single largest one.

Getting to 2.5s therefore needs a structural change rather than another profile flag, and the candidates all have real costs:

  • Fewer generics crossing the boundary. -Zshare-generics is nightly. Doing it by hand means erasing types at the framework’s public edges, which trades compile time against the type safety the framework exists to provide.
  • A different codegen backend. rustc_codegen_cranelift is dramatically faster at -O0 and is nightly-only, x86-64 Linux first.
  • Not relinking at all. Hot-patching the running process, as subsecond does, skips both remaining costs. It is a large piece of machinery and it does not survive every kind of change.

None of these is a patch-release change, so none of them is here. Issue #8 stays open with a measured number against it instead of a quoted one.

Routing

A route is a method, a path, a handler, and optionally a name. Routes is a collection of them, and it is what Application::routes takes.

use arcature::prelude::*;

pub fn routes() -> Routes {
    Routes::new([
        Route::get("/", home).name("home"),
        Route::get("/links", index).name("links.index"),
        Route::post("/links", store).name("links.store"),
    ])
}

Constructors exist for get, post, put, patch, delete, head and options. Anything else falls through to any.

Paths and parameters

Paths are Axum paths, because the router is Axum’s. /links/{id} captures a segment; the handler takes it with Path:

use arcature::axum::extract::Path;

async fn show(Path(id): Path<i64>) -> Result<Response> {
    Ok(text(StatusCode::OK, format!("link {id}")))
}

Names and URL generation

A named route can be turned back into a URL. Parameters are filled in declaration order:

let url = routes.url_for("links.show", &["42"])?;   // "/links/42"

url_for returns Err(Error::NotFound(..)) for a name that was never declared, so a typo is a runtime error rather than a silently broken link. Routes::named() iterates every name and its path template.

Groups

RouteGroup shares a path prefix and, optionally, middleware:

Routes::new([
    RouteGroup::new("/admin", [
        Route::get("/panel", panel).name("admin.panel"),
        Route::get("/users", users).name("admin.users"),
    ])
    .middleware(RequireAuth),
])

The prefix is joined onto each route’s path when the group is flattened.

Where middleware can attach

Middleware attaches at three scopes, and the difference between them is not cosmetic:

ScopeCallReaches
One routeRoute::middlewarethat route only
One groupRouteGroup::middlewareeach route in the group, individually
A collectionRoutes::middlewareevery route present at the time of the call

Route owns a MethodRouter, not a folded Router. That is deliberate: a Router::layer fold applies to everything in the router, so per-route middleware written that way leaks onto sibling routes. Holding a MethodRouter per route makes the leak impossible to express.

Routes::middleware is the one that folds a whole router, and its scope rule is stated in the API: routes merged in afterwards are not covered. That is what lets a guarded collection and a public collection be merged without the guard spreading:

let guarded = Routes::new([...]).middleware(RequireAuth);
let public  = Routes::new([...]);
let all = guarded.merge(public);   // `public` is still public

Writing middleware

Middleware is a Clone type whose handle returns a boxed future. #[middleware] writes that plumbing for an ordinary async function:

use arcature::routing::{Request, Response};
use arcature::{Next, Result, middleware};

#[middleware]
pub async fn require_auth(request: Request, next: Next) -> Result<Response> {
    Ok(next.run(request).await)
}

The function is emitted unchanged, so it stays callable directly from a test. Alongside it the macro generates a unit struct named after the function in PascalCase — RequireAuth here — implementing Middleware. Override the name with #[middleware(RequireAdmin)].

Returning Err maps the framework error to a response instead of continuing. Not calling next.run short-circuits.

For middleware that is not a Middleware — a tower_http layer, say — Route::layer, RouteGroup::layer and Routes::layer take a raw tower::Layer.

The routes! macro

The builder API above is the whole runtime. routes! is a declarative front end over it that additionally emits &'static metadata and typed URL helpers. This block compiles today:

routes! {
    pub app {
        get "/" => home { name: home, page: "Home" }

        group "/auth" {
            get  "/login" => login { name: auth.login }
            post "/login" => store { name: auth.store }
        }

        group "/admin" {
            middleware: [RequireAuth];
            get "/panel" => panel { name: admin.panel }
        }
    }
}

It generates three things from pub app:

  • app_routes() -> Routes — the collection, built with the same builder API.
  • APP_ROUTES: &[RouteDescriptor] — a &'static const describing every route: method, path, handler name, declared page names. Nothing registers itself at startup; the array is the whole registry.
  • app_route — a module of URL functions mirroring the dotted names: app_route::home(), app_route::auth::login(), app_route::admin::panel(). A path parameter becomes a function argument, so app_route::links::show(42) is "/links/42" and a missing parameter is a compile error rather than a broken URL.

Declare state with a state: line:

routes! {
    pub api {
        state: AppState;
        get "/health" => health { name: api.health }
    }
}

And expand a controller into its conventional actions with resource:

routes! {
    pub web {
        resource "/links" => LinksController {
            name: links,
            only: [index, show, destroy]
        }
    }
}

That produces links.index (GET /links), links.show (GET /links/{id}) and links.destroy (DELETE /links/{id}), each with its helper.

Falling through to Axum

Routes::into_router hands back the axum::Router, and Routes::router borrows it. Route::layer takes a tower::Layer. The escape hatch is not hidden and using it is not a defeat; the framework’s opinions are meant to run out somewhere visible.

Where routing sits in the pipeline

The router is stage 19 of 20. Everything a request passes through before it — security headers, CORS, request id, panic catching, error mapping, body limit, timeout, sessions, CSRF, Inertia, page contracts — is fixed and documented in Deployment and in src/application/pipeline.rs. User .layer() calls go in at stage 18, just outside the router; static files are stage 20, reached only when the router does not match.

Controllers

A handler is an ordinary async fn. A controller is a struct with an impl block full of them, which is a convention rather than a requirement — Route::get("/", index) takes a free function just as happily.

pub struct HomeController;

#[arcature::controller]
impl HomeController {
    pub async fn index() -> String {
        "hello".to_string()
    }

    pub async fn show(id: u64) -> String {
        format!("show {id}")
    }
}

The impl block is emitted unchanged. HomeController::index() is still a plain async function a test can call directly, and still a genuine Axum handler.

What the macro adds

#[controller] additionally emits impl ControllerMetadata, whose METHODS const carries one entry per handler: the method name, its parameter names, and the page it renders.

use arcature::ControllerMetadata;

let methods = <HomeController as ControllerMetadata>::METHODS;
assert_eq!(methods[0].name, "index");
assert_eq!(methods[1].params, ["id"]);

METHODS is a &'static const. Nothing registers itself at startup and nothing is looked up by TypeId; see Decisions.

The contract the macro enforces

Every method in the block must be pub, async, have a return type, and take no self receiver — an Axum handler is a free function. Breaking any of those produces error[ARC-M004] at the method, not a page of trait-bound noise.

The page edge

A handler that returns Page<T> has its page identity read off the return type:

#[arcature::page("Dashboard")]
pub struct DashboardPage {
    pub title: String,
}

#[arcature::controller]
impl DashboardController {
    pub async fn index() -> arcature::Page<DashboardPage> {
        arcature::dx::page(DashboardPage {
            title: "Dashboard".to_string(),
        })
    }

    #[page("Reports")]
    pub async fn reports() -> String {
        "reports".to_string()
    }
}

methods[0].page is Some("Dashboard"), derived from the signature and never from the body. The derivation compiles to <T>::PAGE_CONTRACT.name(), a const that exists only for #[page] types — so a handler that tries to return a non-page type as a page fails to compile. That is the Client Exposure Firewall applied to the return type; Inertia covers the rest of it.

Any other return shape (Response, Json<T>, String, impl IntoResponse) yields page: None. A handler that renders a page without returning Page<T> declares the identity with an explicit #[page("Name")] helper attribute, as reports does above.

Extractors

Handler arguments are Axum extractors, unchanged, because the router is Axum’s:

use arcature::axum::extract::{Path, Query, State};

pub async fn show(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> Result<Response> {
    Ok(json(&id))
}

Arcature adds its own: Auth and Current for the signed-in user, Session, Flash, CsrfToken, Validated<T> and its typed variants, and RequestCache. Each is documented in the chapter that owns it.

Responses

Four builders cover the common shapes.

CallProduces
text(StatusCode::OK, "hello")a text/plain response
json(&value)an application/json response, status 200
no_content()204
redirect().to("/dashboard")303, or 308 after .permanent()

json takes one argument. It does not take a status; build the response directly if you need a different one.

redirect() takes no arguments — it returns a builder. .to(path), .back(), .permanent(). Unlike the other three it returns a RedirectResponse, not a Response, so a handler declared -> Result<Response> finishes with .into_response():

Ok(redirect().to("/dashboard").into_response())

Redirect targets are validated against open redirects: an absolute URL to another host is rejected rather than followed.

redirect().route("links.show", 42) resolves the name against the application’s route table, and redirect().with("status", "saved") writes flash data through the session. Neither can be finished by into_response, which sees no request and so has neither the table nor the session: the builder rides along in the response extensions and RedirectMapper – stage 20 of the pipeline, installed by default – takes it out and completes it. .back() works the same way, reading Referer through the same open-redirect validation.

The one thing to know is what happens without that layer. An application that assembles its own pipeline instead of using the builder, and does not install RedirectMapper, gets the fallback response unchanged: a literal path still redirects, a named route answers 400, and flash data is dropped.

Errors

Handlers return Result<Response> — Arcature’s Result, whose error type converts into an HTTP response. bad_request, forbidden and not_found build the common ones; Problem builds an RFC 9457 body. The pipeline’s error-mapping stage gives a body to errors that were returned bodiless, and in release builds it redacts 5xx detail rather than leaking it.

Grouping controllers into a module

module! names the controllers, services, jobs and listeners that belong together and aggregates their metadata into one ModuleDescriptor:

arcature::module! {
    pub Dashboard {
        controllers: [DashboardController],
    }
}

let descriptor = dashboard_module();
assert_eq!(descriptor.controllers, ["DashboardController"]);

The descriptor is built from the same &'static consts the macros emit, so the module is a description of wiring rather than a container that resolves things at runtime.

arc make:module dashboard writes this block, a controller, a service and a routes table into one directory and registers the lot. See Your first module.

Validation

Validation is the trust boundary. At the point a handler receives a validated value, it has passed validator::Validate::validate, and the handler does not re-check it.

Validation does not imply authorization. A validated request is a well-formed one, not a permitted one; authorization is a separate explicit step, covered in Authentication.

Declaring a request

use arcature::{Deserialize, Serialize};

#[derive(Debug, Clone, Deserialize, Serialize)]
#[arcature::request]
pub struct StoreLinkRequest {
    #[validate(url)]
    pub url: String,
    #[validate(length(min = 1, max = 120))]
    pub title: String,
}

Two details that are easy to get wrong.

The rule attribute is #[validate(...)], not #[rule(...)]. #[request] prepends #[derive(::arcature::validator::Validate)], and #[validate] is that derive’s helper attribute, so the rule vocabulary is the validator crate’s: required, email, url, length, range, regex, contains, custom, nested.

You derive Deserialize yourself. The macro deliberately does not add it, to avoid a duplicate derive when you also want Serialize or Debug. The #[arcature::request] attribute goes after the derives.

Because the macro re-exports validator through Arcature, an application does not need validator as a direct dependency.

What the macro emits

Three things beside the struct:

  • #[derive(Validate)] and #[validate(crate = "::arcature::validator")].
  • impl arcature::Request, the marker that makes the type first-class to tooling.
  • impl arcature::RequestMetadata, a &'static [FieldShape] describing the fields, which routes! resolves when a route declares action: T so the typed input shape lands in the RouteDescriptor.

Using it in a handler

use arcature::Validated;

pub async fn store(input: Validated<StoreLinkRequest>) -> Result<Response> {
    let data = input.into_inner();
    Ok(redirect().to("/links").into_response())
}

Validated<T> extracts a JSON body, deserializes it, and validates it before the handler body runs. A failure never reaches the handler; it becomes a response.

Four narrower extractors exist for the other sources:

ExtractorSource
ValidatedJson<T>JSON body
ValidatedForm<T>form body
ValidatedQuery<T>query string
ValidatedPath<T>path parameters

Validated<T> delegates to ValidatedJson<T>.

For a value you extracted yourself, validate_or_problem(&value) validates it and returns Err(Problem) on failure.

What a failure looks like

A validation failure is an RFC 9457 problem document, 422, with the field errors under an errors extension:

{
  "type": "urn:arcature:problem:validation",
  "status": 422,
  "detail": "Request validation failed",
  "errors": {
    "url": [{ "code": "url" }],
    "title": [{ "code": "length" }]
  }
}

Extractor rejections — malformed JSON, a missing query parameter, a path segment that will not parse — are mapped to problem documents too, by from_json_rejection and friends, so a client sees one error shape rather than two.

validation_problem(errors) builds the document from a validator::ValidationErrors directly if you need to raise one by hand.

Inertia

Arcature implements the server side of the Inertia v3 protocol natively. A stock official @inertiajs/react or @inertiajs/vue3 client talks to it without knowing Arcature exists.

There is no @arcature/client package, and there will not be one. The reasoning is in ADR 0001: everything Rust hands JavaScript goes as generated .ts files on disk under resources/js/generated/, not through a bundler plugin the framework has to keep alive.

The mental model

The browser’s Inertia client makes ordinary HTTP requests. On a first visit Arcature renders the initial HTML document with the page object embedded in it. On subsequent visits — requests carrying X-Inertia — it returns the page object as JSON. Same route, same handler, two representations.

Configuring it

InertiaConfig::new takes an asset version and a root-document renderer:

use arcature::assets::{Assets, AssetsConfig};
use arcature::inertia::{InertiaConfig, vite_root_document};

let assets = Assets::detect(&AssetsConfig::new())?;
let config = InertiaConfig::new(
    env!("CARGO_PKG_VERSION"),
    vite_root_document("Acme", &assets, "resources/js/app.tsx"),
)?;

Application::new()
    .routes(routes())
    .inertia(config)
    .build()

default_root_document(title) is the minimal renderer if you are not using Vite. with_shared(shared_props) registers props every page receives.

A root document is any Fn(ScriptBody) -> String. ScriptBody displays as the <script data-page> payload plus the mount <div>, and it also carries this request’s CSP nonce when SecurityHeaders::with_csp_nonce is installed. Both built-in renderers stamp it onto every tag they emit; a hand-written one has to stamp its own, and body.nonce_attribute() is the attribute (with its leading space, or empty when there is no nonce) to interpolate:

let config = InertiaConfig::new(env!("CARGO_PKG_VERSION"), |body: ScriptBody| {
    let nonce = body.nonce_attribute();
    format!(
        "<!doctype html><html><body>{body}\
         <script{nonce} type=\"module\" src=\"/js/app.js\"></script>\
         </body></html>"
    )
})?;

.inertia(config) is what installs InertiaLayer. Without it the Inertia extractor fails: a handler taking inertia: Inertia in an application that never called .inertia(..) returns 500 inertia adapter error. That is documented on the builder method and is worth remembering, because the failure looks like a handler bug rather than a wiring one.

Rendering

The untyped path takes any Serialize:

pub async fn index(inertia: Inertia) -> Result<Response> {
    let response = inertia
        .render("users/index", serde_json::json!({ "users": [] }))
        .await?;
    Ok(response)
}

The inertia! macro is sugar over it. It requires an in-scope binding literally named inertia, because it expands to a call on that name:

pub async fn index(inertia: Inertia, State(state): State<AppState>) -> Result<Response> {
    let db = state.db.as_ref().ok_or_else(|| not_found("no database"))?;
    let users = user::Entity::query(db).all().await?;
    inertia!("users/index", { users })
}

render_with_options adds page-level options (history flags, flash data); render_advanced takes a Props value for per-prop behaviour.

The first argument to InertiaConfig::new is the asset version — any string that changes when the built assets change. The Inertia client compares it and does a full page reload when it moves. A release tag or a manifest hash both work; a constant means the client never reloads on deploy.

The Client Exposure Firewall

Serialize does not mean “safe to send to a browser”. A domain model derives Serialize for a hundred reasons, and any one of them makes it one field reference away from the wire. Arcature makes browser exposure a separate, explicit opt-in.

Two macros grant it.

#[page("name")] declares a page’s prop struct:

#[arcature::page("users/show")]
pub struct ShowUserPage {
    pub user: UserResource,
    pub can_edit: bool,
}

#[resource] declares a value that nests inside page props:

#[arcature::resource]
pub struct UserResource {
    pub id: String,
    pub name: String,
    pub avatar: Option<AvatarResource>,
}

Both emit impl ClientData, whose exposure_schema() is built from the named fields. A non-primitive field type maps to PropsSchema::nested::<FieldType>, which requires FieldType: ClientData. So nesting an internal model inside a page does not compile — the failure is a trait bound at build time, not a leak in production.

#[page] additionally emits a PAGE_CONTRACT const (the typed handle) and a PAGE_CONTRACT_ENTRY const (the non-generic one module! aggregates). Both are &'static. Nothing registers itself; application! builds the PageContracts registry from the graph.

#[resource] emits no PAGE_CONTRACT: resources are values inside pages, not pages.

A database model is not a resource. A SeaORM entity stays an entity, and application code converts explicitly with impl From<User> for UserResource. The conversion is the place where you decide what the browser sees, which is the point of writing it out.

Rendering through the firewall

pub async fn show(inertia: Inertia) -> Result<Response> {
    let page = ShowUserPage {
        user: UserResource { id: "1".into(), name: "Ada".into(), avatar: None },
        can_edit: true,
    };
    Ok(inertia.render_page(ShowUserPage::PAGE_CONTRACT, page).await?)
}

render_page is render with a ClientData bound. The component name comes from the contract rather than a string literal, so a renamed page cannot drift from its route.

A controller method may instead return Page<T> and let #[controller] read the page identity off the return type — see Controllers. page!(ShowUserPage { .. }) constructs one with a compile-time ClientData assertion at the call site.

Prop behaviours

Props carries per-prop evaluation strategy, matching the Inertia protocol:

ConstructorBehaviour
eager(value)always serialized
always(value)included even in partial reloads
lazy(f)resolved only when requested
optional(f)omitted unless the client asks for it
deferred(f)sent in a follow-up request
deferred_group(name, f)deferred, batched under a group

merge(prop), prepend(prop) and deep_merge(prop) set the client-side merge strategy for a prop that accumulates across visits.

Contracts as an artifact

.page_contracts(artifact) publishes the collected page contracts as a request extension. It changes no response; it is data for the dev-only UAG endpoint and for arc typegen to read when generating TypeScript.

arc typegen reads that artifact and writes the TypeScript, which is the generated-types pipeline ADR 0001 describes and the reason the contracts are collected at all.

Redirects

inertia.redirect(location) builds an Inertia-aware redirect. external(url) produces a 409 with X-Inertia-Location, which is how the protocol tells the client to leave the SPA. fragment(..) targets a fragment.

One port

In development, Vite runs in middlewareMode with no TCP port of its own and the Rust process forwards to it over an IPC endpoint. There is one port in development and one in production, and no localhost:5173 fallback. See ADR 0003 and Deployment.

When a page does not need JavaScript

Inertia is for the application. For pages that are just HTML – a marketing page, a confirmation screen, an email body – the views feature renders Askama templates instead, and costs the client nothing.

Askama compiles a template into Rust at build time, so there is no expression evaluator anywhere in the request path and server-side template injection is structurally absent rather than defended against. The trade is that editing a template means rebuilding, and that a Dockerfile has to COPY templates before cargo build. arc make:view <name> writes a view struct and its template together.

The two mix freely: an application can serve Inertia pages behind sign-in and compiled views in front of it.

More than one language

The i18n feature adds Fluent translation catalogs and puts the negotiated locale where both renderers can reach it – Inertia props and view context alike, so a page does not have to know which one it is.

Fluent rather than a HashMap<String, String>, because the map is wrong the moment a language has more than two plural forms. The negotiation reads Accept-Language and any override you allow, and matches it against the locales you registered: a locale string never becomes a path, so a hostile Accept-Language selects nothing rather than reaching the filesystem.

Views

Server-rendered HTML from templates that are Rust code by the time the binary exists.

#[derive(Template)] reads the .html file when the crate compiles and emits the write! calls that produce the page. What ships is a function. There is no template text in the process, no loader, and no evaluator.

Turning it on

The feature is views, and it is off in the framework’s default set:

arcature = { version = "0.1", features = ["views"] }

views = ["dep:askama"]. It pulls nothing else.

A generated application already has it on — arc new writes app/views/, templates/layout.html and templates/welcome.html, and lists "views" in the app’s Cargo.toml. If every screen in the application is an Inertia page, remove the feature and those two directories together.

WhereState
framework defaultoff
framework fullstackon
generated applicationon

Askama is pinned at 0.16.0 with default-features = false and only two of its features enabled: derive and std. The omissions have consequences you can see from application code.

Askama featureConsequence of it being off
configTOML parsing is off, so an askama.toml is a compile error rather than a config file. Arcature ships none, so the template directory, the syntax and the escaper table are the defaults.
urlencodeThere is no urlencode or urlencode_strict filter.
serde_jsonThere is no json or json_pretty filter.
code-in-doc#[template(in_doc = true)] is unavailable.

config and urlencode are two of the four askama defaults, so this is a narrowing, not an unchanged baseline. config would add basic-toml, glob, serde and serde_derive to the build-time graph to read a file the framework does not ship; urlencode would add percent-encoding for a filter an HTML template does not need, since escaping is the autoescaper’s job.

askama_axum is deliberately absent as well. It was folded into askama and then dropped; the IntoResponse impl lives in src/view/response.rs, where it can answer a render failure the way the rest of the framework answers one.

views does not imply observe. That default matters and it is covered under Render failures.

Why the templates are compiled

This is the decision the feature exists to make, so it is worth arguing rather than asserting.

A runtime template engine — minijinja, tera, handlebars — is two programs shipped as a library: a parser that turns template text into a tree, and an evaluator that walks the tree against a context and produces output. Both of them run inside the request path, because that is when the template is rendered.

That arrangement is what server-side template injection is. SSTI is not a parsing bug; it is the engine doing exactly its job on input that reached it from the wrong direction. If any string a request controls is handed to the parser — a template chosen by name from a query parameter, a page fragment stored in a database and rendered as a template, a subject line assembled with format! and then passed through the engine — then the evaluator will evaluate it. An expression language with attribute access and method calls is one hop from the host process, which is why SSTI is the shortest route there is from a form field to remote code execution.

The usual answer is discipline: never render user input as a template, audit the places templates are loaded from, keep the sandbox on. That is a defence, and a defence is a thing that can be forgotten in one commit.

Askama makes the class of bug unreachable instead. The parser runs in the proc-macro at build time. The output is Rust. At runtime there is no parser to reach, no evaluator to abuse, and no template text in the binary to be substituted — only the statements the compiler emitted. There is nothing to forget, because there is nothing there.

The same property has a second effect, which is smaller but is felt daily. A runtime engine binds names when it renders, so a name the template uses and the data does not supply is discovered when a page is served. A compiled template resolves names against the struct’s fields when the crate compiles: a {{ subtitle }} with no subtitle field is a build failure, not a blank space on a page nobody looked at.

Runtime engineCompiled templates
Parser in the request pathyesno
Expression evaluator in the request pathyesno
SSTIdefended againststructurally absent
Unknown name in a templaterender timecompile time
Template must be on disk at runtimeyesno
Editing a templatereloadrebuild

The last row is the price, and it is a real one. It is paid in full under Costs.

Localization is the one place the framework accepted a runtime parser anyway, and src/i18n/mod.rs states the boundary: a Fluent catalog is a file a developer wrote and a request never names, supplies or selects. That is a different input from a template rendering attacker-supplied values.

Writing a view

A view is a struct whose fields are the values its template names.

use arcature::view::Template;

/// `templates/welcome.html`.
#[derive(Template)]
#[template(path = "welcome.html", askama = arcature::askama)]
pub struct WelcomeView {
    pub title: String,
    pub message: String,
}

path resolves against templates/ in the crate root — askama’s default directory, and with the config feature off there is no askama.toml that could move it.

askama = arcature::askama is not decoration. #[derive(Template)] writes code that says askama::, which does not resolve in a crate that depends only on Arcature. Pointing the derive at the re-export means the application compiles against the askama the framework pins and cannot drift to a second version of it. An application that would rather write a bare #[derive(Template)] can add askama to its own Cargo.toml; the price is a version number to keep in step by hand.

For a template short enough to read in place, source and ext replace path:

use arcature::view::{Template, view};

#[derive(Template)]
#[template(
    source = "<h1>{{ title }}</h1>",
    ext = "html",
    askama = arcature::askama
)]
struct Welcome {
    title: String,
}

let html = view(Welcome { title: "Hello".into() }).render().unwrap();
assert_eq!(html, "<h1>Hello</h1>");

The #[template(..)] keys this chapter relies on:

KeyMeaning
pathtemplate file, resolved under templates/
sourcetemplate text written inline; requires ext
extthe extension source should be treated as having
escapeoverride the escaper the extension would select
askamathe path to the askama crate the derive should name

The template

The scaffold ships a base and one page that extends it. templates/layout.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{% block title %}Acme{% endblock %}</title>
  </head>
  <body>
    {% block content %}{% endblock %}
  </body>
</html>

templates/welcome.html:

{% extends "layout.html" %}

{% block title %}{{ title }}{% endblock %}

{% block content %}
<main>
  <h1>{{ title }}</h1>
  <p>{{ message }}</p>
</main>
{% endblock %}

Template syntax, inheritance and filters are askama’s, and arcature::view puts nothing in front of them. It is a seam, not a wrapper. The askama crate is re-exported as arcature::askama, and Template — both the trait and the derive macro, which share one name — comes from arcature::view::Template or from the prelude.

arc make:view

arc make:view admin/receipt

writes two files:

PathContentsRegistered in a mod.rs
app/views/admin/receipt_view.rspub struct ReceiptView with #[template(path = "admin/receipt.html", askama = arcature::askama)]yes — pub mod receipt_view;
templates/admin/receipt.htmla template extending layout.htmlno

The struct is the file stem plus View; the template keeps the base name, because path names a template and nothing in askama makes it a type’s name. The generated struct has two fields, title: String and message: String, and the generated template uses both.

Only the Rust half is declared to rustc. There is no templates/admin/mod.rs, because templates/ is read by the askama derive rather than walked by the compiler, and a mod.rs there would be a Rust file in a directory that has no Rust in it.

Two files rather than one is the point of this generator. Askama reads the template when the crate compiles, so a view struct whose path names a file that is not there is not a scaffold with a gap in it — it is a compile error, and the project stops building until somebody writes the half the generator declined to. The pair is the artifact.

Rendering a view as a response

view(template) wraps a template value; View::new(template) is the same thing with a name that is easier to use in generic code.

use arcature::prelude::*;

use crate::app::views::WelcomeView;
use crate::bootstrap::AppState;

pub struct HomeController;

#[controller]
impl HomeController {
    /// `GET /welcome`
    pub async fn welcome(State(state): State<AppState>) -> Result<Response> {
        Ok(view(WelcomeView {
            title: state.app_name.clone(),
            message: "Rendered on the server.".to_string(),
        })
        .into_response())
    }
}

The return type is Response, not Page<..>: there is no client component behind a view, and the HTML is finished when it leaves the server.

A fresh View carries three things a compiled template does not know:

PropertyDefaultSet with
status200 OK.status(StatusCode)
content typetext/html; charset=utf-8.content_type(HeaderValue)
Content-Languageabsent.in_locale(&Locale) (feature i18n)

HTML is the default rather than a guess from the template’s extension because askama 0.16 does not keep the extension on the compiled type — Template has no MIME_TYPE to read. A view over a .txt or .xml template has to say so:

use arcature::axum::http::HeaderValue;
use arcature::prelude::*;
use arcature::view::{Template, view};

#[derive(Template)]
#[template(
    source = "User-agent: *\nDisallow: {{ path }}\n",
    ext = "txt",
    askama = arcature::askama
)]
struct Robots {
    path: String,
}

let response = view(Robots { path: "/admin".into() })
    .content_type(HeaderValue::from_static("text/plain; charset=utf-8"))
    .into_response();

assert_eq!(response.headers()["content-type"], "text/plain; charset=utf-8");

The rest of the surface is small: .render() produces a String, .template() borrows the wrapped value, and .into_template() gives it back. View<T> is Debug + Clone and #[non_exhaustive].

arcature::view exports View, ViewError, view and Template; the crate root re-exports View, ViewError and view; the prelude carries Template, View and view under the views feature, which is why the controller above imports nothing else.

Render failures

ViewError has one variant, Render { source: askama::Error }. That is the whole runtime failure surface: askama resolved the parse and the names at build time, so what is left is a value whose Display impl returned Err, or a writer that refused the bytes.

When IntoResponse hits one, the response is a plain 500 with the framework’s ordinary internal error body. It says nothing, and it is worth being precise about what “nothing” excludes, because the obvious implementation leaks all three:

  • the template’s own text, which is application source, and on an error page is often the half somebody was mid-edit;
  • the template’s path, which is a map of the source tree and of the filesystem the process runs on;
  • the value that would not format — whatever the failing Display had already written before it gave up, plausibly a session token or a database row.

The conversion From<ViewError> for Error produces Error::Other("view rendering failed"), which answers status 500 with code internal_error. There is no development-mode variant that shows more, because there is no build in which a template’s contents are a reasonable thing to send to a browser. A Content-Language a handler declared does not survive onto the failure response either: the body is the framework’s error document, not the page that failed.

The askama message goes to tracing::error! — and this is where the feature graph bites. tracing arrives with observe, and views does not imply it. In a build with views and without observe, the askama message is discarded with nothing recorded anywhere. The client still gets its uninformative 500; the operator gets silence. If you enable views, enable observe.

Declaring a language

in_locale exists only under the i18n feature and sends Content-Language:

let response = view(Greeting { locale: locale.clone() })
    .in_locale(&locale)
    .into_response();

The framework does not infer this header, in either direction. A compiled template carries no language — askama resolved it to write! calls — and the locale LocaleLayer negotiated is what the request asked for, which is not the same claim as what the bytes in the response are actually in. A handler that renders a French template says so; one that renders a template it did not translate says nothing, which is better than an untrue header.

Translation itself stays in the template: give the struct a Locale field and call it. There is no filter and no {{ t("key") }} syntax, because adding one would mean a lookup the compiler cannot check — the opposite of the reason this module exists.

Mail bodies from the same templates

With views on, the Mail builder grows two terminators that take compiled templates instead of strings.

use arcature::mail::Email;
use arcature::view::Template;

#[derive(Template)]
#[template(
    source = "Hello {{ name }}, your invoice is ready.",
    ext = "txt",
    askama = arcature::askama
)]
struct InvoiceText {
    name: String,
}

#[derive(Template)]
#[template(
    source = "<p>Hello {{ name }}, your invoice is ready.</p>",
    ext = "html",
    askama = arcature::askama
)]
struct InvoiceHtml {
    name: String,
}

let message = Email::builder()
    .from("Billing <billing@example.com>".parse().unwrap())
    .to("ada@example.com".parse().unwrap())
    .subject("Your invoice")
    .templated(
        &InvoiceText { name: "Ada".into() },
        &InvoiceHtml { name: "Ada".into() },
    )
    .unwrap();

let raw = String::from_utf8(message.formatted()).unwrap();
assert!(raw.contains("multipart/alternative"));
TerminatorProduces
templated(plain, html)multipart/alternative from both templates
templated_with_attachments(plain, html, attachments)the same, inside multipart/mixed

Plain first, matching Email::alternative.

Both halves are taken in one call on purpose. A multipart/alternative mail carries the same message twice, and the two copies drifting apart is the ordinary way mail templating goes wrong: the HTML half gets the new wording, the plain half keeps the old, and only the readers on the text client ever see it. Taking the pair together makes a change to a message a change to a pair.

They are two templates rather than one because escaping is chosen by extension. The .html template escapes its values; the .txt one does not. Rendering a text body through an HTML template would send &#38; to somebody reading plain text.

Both halves render before the message is assembled, so a template that cannot render stops before a Message exists. The error is MailViewError:

VariantCauseBecomes
Render { source: ViewError }either template failed to renderthe same generic 500, through From<ViewError> for Error
Build { source: EmailError }lettre could not assemble the messageError::Mail(..)

The render path deliberately goes through the view conversion, so a template’s text cannot reach a response body by way of the mail subsystem either.

These terminators do not fit Mailable

Mailable::build is declared fn build(&self, email: Email) -> Result<Message, EmailError>. templated returns Result<Message, MailViewError>, and there is no From<MailViewError> for EmailErrorEmailError is not #[non_exhaustive], so it cannot grow a variant without breaking every downstream match.

The consequence is concrete: a Mailable implementation cannot call templated and use ?. This does not compile, and no import fixes it.

arc make:mail writes a plain-text Mailable, and its comment gives a related but different reason — that a format! into an HTML body escapes nothing, so the HTML half should come from a template. The templated incompatibility above is not mentioned there. To send a templated mail, build the Message outside the trait and hand it to the mailer:

let message = Email::builder()
    .from(from_mailbox)
    .to(to_mailbox)
    .subject("Your invoice")
    .templated(&InvoiceText { name }, &InvoiceHtml { name })?;

mail.mailer().send(&message).await?;

Mail::mailer() borrows the transport and Mailer::send(&Message) takes a finished message, so this path keeps the configured transport and the capture mailers used in tests. What it gives up is Mail::to(..).send(..) setting From and To for you.

How escaping works, and on what basis

Escaping is selected by the template’s extension, at compile time, from a fixed table. It is not a runtime decision and not a per-value one.

ExtensionEscaperEffect
askama, html, htm, j2, jinja, jinja2, rinja, svg, xmlHtmlfive characters replaced with entities
md, none, txt, yml, and no extensionTextnothing is escaped
anything elsenone existscompile error

That last row is worth reading twice. An extension in neither list is not a silent fall-through to “no escaping” — the derive fails with no escaper defined for extension '...'. A .rss or .csv template does not build until you say what it is, with escape = "html" or escape = "none".

The HTML escaper replaces exactly five characters:

CharacterOutput
"&#34;
&&#38;
'&#39;
<&#60;
>&#62;

So {{ value }} in an .html template with <script>alert(1)</script> in it produces text on the page and not markup. There is a test in src/view/mod.rs asserting exactly that, and a matching one asserting that a .txt template leaves a < b alone, so neither half is taken on trust.

{{ value|safe }} opts out. Write it for markup you produced yourself, never for a value that arrived on a request.

Escaping is not context-aware

This is the part that a five-character replacement table cannot do, and it is the same limitation every non-contextual autoescaper has.

The escaper does not know where in the document a value lands. It escapes the same five characters whether the value is body text, an attribute value, a URL, or the inside of a <script> block. Three consequences follow, and all three are the template author’s to handle:

  • URLs. <a href="{{ url }}"> with url set to javascript:alert(1) contains none of the five characters. It is emitted unchanged and it runs. Check the scheme in Rust before the value reaches the template.
  • Script blocks. Inside <script>, HTML entities are not decoded the way they are in markup, and the five-character table is not a JavaScript string escape. Do not interpolate request data into a <script> body. Askama’s json filter would be the tool for handing data to JavaScript, and this build does not have it — serde_json is one of the askama features Arcature leaves off. Encode the value in Rust, or use an Inertia page, which is what the prop channel is for.
  • Unquoted attributes. <div class={{ value }}> is injectable through a space, since space, /, = and backtick are all left alone. Quote every attribute.

Nothing here is specific to Arcature and nothing here is a defect in askama. It is the boundary of what “the template escapes its values” means, and a chapter that did not say so would be lying by omission.

Costs

Three, and none of them is hypothetical.

Editing a template means rebuilding

There is no reload. The template is in the binary as emitted code, so a change to templates/welcome.html reaches a running process only after the crate is compiled again.

cargo build does notice. Askama emits a const _: &[u8] = include_bytes!("<template path>") for every template file it reads, which makes rustc track the file as a dependency of the crate: touching the .html alone is enough to make the next cargo build recompile.

arc dev does not notice. The supervisor’s watcher — see The dev loop — classifies a change, and only three kinds of file mean anything to it:

ChangeAction
any *.rsrebuild, then restart
Cargo.toml, Cargo.lockrebuild, then restart
.env, .env.*restart, no compile
everything else, templates includednothing

That filter is deliberate for the frontend — a .tsx or .css edit is Vite’s business and costs no Rust rebuild — and an askama template falls on the same side of it. Saving a template during arc dev produces no rebuild and no visible change on refresh. Touch any .rs file, or run cargo build, to pick it up. (The comment in the generated app/views/mod.rs says arc dev already rebuilds on save. It does not.)

A Dockerfile has to COPY templates before cargo build

templates/ is a source directory, in the same sense src/ and app/ are. It is read by the compiler, not by the process.

This is the failure mode worth knowing before you meet it: the local build succeeds, because the templates are on the developer’s disk, and the image build fails on a template it cannot find. The error names a path that plainly exists, which is what makes it confusing rather than obvious.

The generated Dockerfile — described in full under Deployment — copies it alongside the rest of the sources:

COPY src ./src
COPY app ./app
COPY bootstrap ./bootstrap
COPY config ./config
COPY database ./database
COPY routes ./routes
# Askama reads the templates at *build* time and compiles them into the
# binary, so this is a source directory like the ones above, not runtime data.
COPY templates ./templates
RUN cargo build --release --locked

The runtime stage copies the binary, public/ and storage/, and no templates. Nothing at runtime reads them back, and a template shipped into a production image is dead weight at best.

Views and Inertia are both on, and that is fine

A generated application serves both. Inertia renders the application — the screens behind sign-in, where the client is already loaded and a JSON page object is the cheap answer. Views render the pages that have to work with no JavaScript at all: an unsubscribe confirmation, an emailed receipt, an RSS feed, a marketing page, a fallback error page.

They do not interact. A view is a plain Response and never carries a page object; an Inertia page never goes through View. The scaffold demonstrates the split in one controller: GET / returns Page<HomePage> and GET /welcome returns Response from view(WelcomeView { .. }).

The one shared cost is the two directory trees, app/pages/ plus resources/js/pages/ for one and app/views/ plus templates/ for the other. If an application never serves HTML from the server, delete app/views/ and templates/ and drop "views" from its feature list; if it never serves an SPA, the same applies to inertia. Keeping both is a choice, not a default you are stuck with.

What views do not do

Collected, because a chapter that lists only what works is useless to somebody deciding whether to depend on this.

  • No runtime template loading. There is no render("name", context) taking a template chosen at runtime, and there cannot be one. That is the feature.
  • No hot reload, and no rebuild from arc dev on a template edit.
  • No askama.toml. The config feature is off, so the template directory, the syntax and the escaper table are the defaults.
  • No urlencode filter, and no json filter. Those askama features are off too.
  • No content-type inference. A .txt or .xml view answers text/html; charset=utf-8 until you call .content_type(..).
  • No Content-Language unless you declare one, and no inference from the negotiated locale.
  • No translation filter. No {{ t("key") }}; put a Locale on the struct.
  • No context-aware escaping. Five characters, everywhere, regardless of where the value lands.
  • No render-failure detail anywhere without observe. The message is dropped, not logged.
  • No Mailable integration. templated returns an error type Mailable::build cannot return.

Localization

Translation catalogs in Mozilla Fluent, a locale negotiated per request from Accept-Language and from overrides the application opts into, and an active locale a handler, a view or an Inertia page can read.

A locale tag is matched against the catalogs the application registered. It never becomes a filesystem path, because there is no filesystem access in the subsystem at all.

Turning it on

The feature is i18n, and it is off everywhere:

arcature = { version = "0.1", features = ["i18n"] }
WhereState
framework defaultoff
framework fullstackoff
generated applicationoff, and arc new scaffolds nothing for it

i18n = ["dep:fluent-bundle", "dep:unic-langid"], both with default-features = false. fluent-bundle is the reference Rust implementation of Fluent — the .ftl parser, the formatter, and the CLDR plural-rule selection. unic-langid is the BCP-47 language-identifier type that API is written in; it is a direct dependency because fluent-bundle does not re-export it and a locale has to be parsed before a bundle can be built.

The features left off are all-benchmarks on fluent-bundle, and macros, likelysubtags and serde on unic-langid: a benchmark harness, a proc-macro, a data table and a derive, none of which this crate uses.

Nothing enables i18n for you, and nothing in the framework installs the locale layer on your behalf. There is no Application::i18n(..) builder method. Wiring is described under Installing the layer.

Where the names live:

ImportNames
arcature:: (crate root)Catalog, Catalogs, I18nError, Locale, LocaleId, LocaleLayer
arcature::i18n::those, plus ArgValue, LocaleMiddleware, LocaleNegotiator, LocaleRejection, LocaleSource, TranslationArgs
arcature::prelude::*nothing — the prelude carries no i18n name

A handler that takes a Locale writes use arcature::i18n::Locale; even with the prelude imported.

Why Fluent and not a map

The obvious implementation of translation is HashMap<String, String> keyed by locale. It is wrong for every language whose grammar is not English’s, in three separate ways, and this feature exists to pick the engine that gets all three right.

Plurals. English has two categories, so a map with a _one key and an _other key looks complete. It is not a property of the message; it is a property of the language, and CLDR gives Polish four categories, Arabic six and Japanese one. The selection cannot be an if n == 1 in the calling code, because the calling code does not know which language it is rendering, and the rule for that language is a table rather than an arithmetic expression a developer can guess.

Agreement. “the file was deleted” has a gendered participle in French and in Russian. The correct string depends on a property of an argument, not only on the key, so no amount of keys fixes it.

Numbers and dates. 1,234.5 is 1 234,5 in French and 1.234,5 in German. A value formatted before it reaches the map is formatted in the server’s locale rather than the reader’s.

Fluent puts the decision inside the catalog, where the translator can see it and change it, instead of inside the calling code, where they cannot. Adding a language with four plural categories is an edit to one .ftl file and to nothing else:

use arcature::i18n::{Catalog, LocaleId, TranslationArgs};

let catalog = Catalog::parse(
    LocaleId::parse("pl").unwrap(),
    r"files = { $count ->
    [one] plik
    [few] pliki
    [many] plikow
   *[other] pliku
}",
)
.unwrap()
.isolating(false);

let of = |n: i64| {
    catalog
        .translate("files", &TranslationArgs::new().with("count", n))
        .unwrap()
};

assert_eq!(of(1), "plik");
assert_eq!(of(2), "pliki");
assert_eq!(of(5), "plikow");
assert_eq!(of(22), "pliki");

The calling code passed an i64 three times and knew nothing about Polish.

The price is a runtime parser, which the Views feature rejected on purpose. That is a real tension and it gets its own answer under Security.

Writing a catalog

Catalog::parse(locale, ftl) turns .ftl source into a catalog. In an application the source is include_str!, so the bytes are in the binary and the parse is a startup cost:

use arcature::i18n::{Catalog, LocaleId};

let english = Catalog::parse(
    LocaleId::parse("en")?,
    include_str!("../locales/en.ftl"),
)?;

locales/en.ftl is an ordinary file in the repository. Nothing at runtime reads it; nothing at runtime can be told to read a different one.

MethodBehaviour
Catalog::parse(locale, ftl)parse one source into a new catalog
catalog.with_source(ftl)fold another source into the same catalog
catalog.isolating(bool)Unicode bidi isolation of placeables; on by default
catalog.locale()the &LocaleId this catalog is for
catalog.has(key)whether the catalog defines a message under key
catalog.message(key)format a message that takes no arguments
catalog.translate(key, &args)format a message with arguments
catalog.attribute(key, attr, &args)format one attribute of a message

with_source is how a large application splits its messages by area without splitting the locale. It refuses to redefine a message or a term the catalog already has — a silent overwrite would make a key’s meaning depend on the order the files happened to be added in:

use arcature::i18n::{Catalog, LocaleId};

let catalog = Catalog::parse(LocaleId::parse("en").unwrap(), "a = A").unwrap();
assert!(catalog.with_source("a = B").is_err());

Attributes keep the strings of one UI element together so a translator sees them as a unit:

use arcature::i18n::{Catalog, LocaleId, TranslationArgs};

let catalog = Catalog::parse(
    LocaleId::parse("en").unwrap(),
    "search = Search\n    .placeholder = Search the archive",
)
.unwrap();

assert_eq!(catalog.message("search").unwrap(), "Search");
assert_eq!(
    catalog
        .attribute("search", "placeholder", &TranslationArgs::new())
        .unwrap(),
    "Search the archive"
);

Isolation marks

isolating is on by default, and it should stay on in anything a person reads. Fluent wraps every placeable in U+2068 and U+2069, the Unicode isolation marks, so an Arabic name interpolated into an English sentence does not drag the punctuation around it to the other side of the line. They are invisible in a browser and visible in a byte-for-byte assertion, which is the only reason isolating(false) exists: tests, and non-display sinks such as a log line. Most runnable examples in this chapter that compare a formatted string call it. Three do not, because the strings they assert on carry no placeable and so no isolating marks appear in them.

Arguments

TranslationArgs is a builder of named values:

use arcature::i18n::{Catalog, LocaleId, TranslationArgs};

let catalog = Catalog::parse(
    LocaleId::parse("en").unwrap(),
    "invoice = { $name } owes { $amount }",
)
.unwrap()
.isolating(false);

let args = TranslationArgs::new().with("name", "Ada").with("amount", 12.5);

assert_eq!(catalog.translate("invoice", &args).unwrap(), "Ada owes 12.5");

with takes anything that converts into an ArgValue, which has three variants and these From impls and no others:

ArgValueBuilt fromNotes
Text(String)String, &strinterpolated as-is; escaping is the view layer’s job
Integer(i64)i64, i32, u32, usizeusize saturates at i64::MAX rather than wrapping
Number(f64)f64

There is no u64, no f32, no u8/u16/i8/i16 and no bool. Convert at the call site.

Integer and float are separate cases because CLDR treats them separately: in several languages 1 and 1.0 fall into different plural categories, and collapsing both into an f64 would quietly pick the wrong one.

Setting the same name twice keeps the later value. Arguments keep insertion order, and TranslationArgs is a Vec behind the scenes rather than a map, because a message has a handful of placeables and a linear scan over three entries beats hashing three keys.

TranslationArgs is deliberately not fluent_bundle::FluentArgs. FluentArgs in a public signature would make fluent-bundle’s version part of Arcature’s public API, and a 0.16 to 0.17 bump upstream — routine for a crate at 0.x — would become a breaking change here.

Registering locales

Catalogs is the registry, and it is also the whitelist: it is the only answer in the framework to “is this a locale this application has?”.

use arcature::i18n::{Catalog, Catalogs, LocaleId};

let en = LocaleId::parse("en").unwrap();
let fr = LocaleId::parse("fr").unwrap();

let catalogs = Catalogs::new(
    Catalog::parse(en.clone(), "greeting = Hello\nfarewell = Goodbye").unwrap(),
)
.with(Catalog::parse(fr.clone(), "greeting = Bonjour").unwrap());

assert_eq!(catalogs.default_locale(), &en);
assert_eq!(catalogs.message(&fr, "greeting").unwrap(), "Bonjour");
assert!(!catalogs.contains(&LocaleId::parse("de").unwrap()));
MethodBehaviour
Catalogs::new(catalog)start the registry from the catalog that is also its default
catalogs.with(catalog)register another locale; the same locale twice keeps the later catalog
catalogs.default_locale()the &LocaleId used when nothing better is registered
catalogs.default_catalog()the default locale’s catalog, which always exists
catalogs.contains(&locale)the whitelist test
catalogs.catalog(&locale)Option<&Catalog>
catalogs.locales()every registered locale, in canonical-tag order
catalogs.message(&locale, key)format a no-argument message, with fallback
catalogs.translate(&locale, key, &args)format a message, with fallback

new takes a Catalog and not a LocaleId on purpose. A registry whose default locale has no catalog is a configuration that fails at the first request in the worst language, and this signature makes it unspellable.

Nothing removes a catalog. The set is fixed once the registry is built, from values the application’s own code supplied.

Catalogs is Clone and the catalogs sit behind an Arc, so a copy per request costs a refcount bump. Catalog itself is not Clone; build it once and hand it to the registry.

Message fallback

There are two levels of fallback and they are separate on purpose.

Locale fallback happens before a catalog is chosen and belongs to negotiation: an unregistered locale becomes the default one.

Message fallback happens inside Catalogs::translate: a key the chosen catalog does not have is looked up in the default catalog before the call fails.

use arcature::i18n::{Catalog, Catalogs, LocaleId};

let fr = LocaleId::parse("fr").unwrap();
let catalogs = Catalogs::new(
    Catalog::parse(
        LocaleId::parse("en").unwrap(),
        "greeting = Hello\nfarewell = Goodbye",
    )
    .unwrap(),
)
.with(Catalog::parse(fr.clone(), "greeting = Bonjour").unwrap());

// `farewell` was never translated. The page renders in English rather than
// failing.
assert_eq!(catalogs.message(&fr, "farewell").unwrap(), "Goodbye");

That is what makes a partially translated locale usable: a new string ships in en, and a fr page shows the English sentence rather than a 500 or an empty span. It is per key, so it cannot hide a missing catalog — a locale with no catalog at all was never selectable.

Two things the fallback deliberately does not do:

  • A formatting failure is not retried against the default catalog. The message was found and the caller’s arguments did not fit it; the same arguments will not fit the English one either.
  • Catalogs has no attribute method. Attributes are reachable only through Catalog::attribute, which is one catalog, so an attribute a locale has not translated is I18nError::Missing rather than the default catalog’s text. See What this does not do.

A key that is missing everywhere reports the locale that was asked for, not the default one, so the error names the language the reader was in.

Locale identifiers

LocaleId is a validated, canonical BCP-47 language identifier, and it is the only locale type the API accepts.

use arcature::i18n::LocaleId;

// Canonical casing is applied on the way in.
let locale = LocaleId::parse("en-us").unwrap();
assert_eq!(locale.as_str(), "en-US");
assert_eq!(locale, LocaleId::parse("EN-US").unwrap());
assert_eq!(locale.language(), "en");

assert!(LocaleId::parse("../../etc/passwd").is_err());

parse is the only constructor and it is fallible. Validation runs in two passes: a cheap allocation-free shape check, then the real parser on a string already known to be short and alphanumeric.

RuleValue
maximum length35 bytes, checked before parsing
subtag length1–8 bytes
byte setASCII alphanumerics, - as the separator only
subtag orderlanguage, script, region, variants
casingcanonicalized, so zh-hant-hk and ZH-HANT-HK are one locale

Everything else is refused: .., /, \, a NUL byte, a newline, a space, an underscore, an empty subtag, a percent-encoded sequence, a bidi override character, a 4 KB string. LocaleId::parse is the one place that check has to be written, which is the point of the newtype — a String carries no history, so the check would otherwise have to be repeated at every use and the one call site that forgot would be the bug.

LocaleId implements Display, AsRef<str>, FromStr, Serialize, Clone, Ord and Hash. It does not implement Deserialize: a struct with a LocaleId field cannot #[derive(Deserialize)]. Take a String and call parse on it.

Negotiating a request locale

LocaleNegotiator turns what a request proposed into a registered locale. The whole design is one sentence: a request proposes locales, Catalogs decides which of them exist, and anything unproposed, unparseable or unregistered becomes the default.

use arcature::i18n::{Catalog, Catalogs, LocaleId, LocaleNegotiator, LocaleSource};

let catalogs = Catalogs::new(
    Catalog::parse(LocaleId::parse("en").unwrap(), "hi = Hello").unwrap(),
)
.with(Catalog::parse(LocaleId::parse("fr").unwrap(), "hi = Bonjour").unwrap());

let negotiator = LocaleNegotiator::new(catalogs)
    .query_parameter("lang")
    .session_key("locale");

// A browser configured for French, with no explicit choice on record.
let locale = negotiator.resolve(None, None, Some("fr-CA,fr;q=0.9,en;q=0.4"));
assert_eq!(locale.id().as_str(), "fr");
assert_eq!(locale.source(), LocaleSource::Header);

// An explicit `?lang=` beats both the session and the header.
let locale = negotiator.resolve(Some("en"), Some("fr"), Some("fr"));
assert_eq!(locale.id().as_str(), "en");
assert_eq!(locale.source(), LocaleSource::Url);

// A hostile tag is not a candidate.
let locale = negotiator.resolve(Some("../../etc/passwd"), None, None);
assert_eq!(locale.source(), LocaleSource::Default);

Precedence

LocaleSource, most specific first:

SourceOriginDefault state
LocaleSource::Urlthe query parameter named by .query_parameter(..)off
LocaleSource::Sessionthe session key named by .session_key(..)off
LocaleSource::Headerthe request’s Accept-Languagealways read
LocaleSource::Defaultnothing the request offered was registered

Both overrides start off, and an override that was never configured is not an override: an application that has not opted into ?lang= cannot have a user’s locale changed by a link somebody emailed them. A URL parameter puts the locale into every link a page emits and into every log line; a session key is a write to storage the application owns. Neither should appear because a framework assumed a name for it.

LocaleSource is #[non_exhaustive]. Match with a catch-all arm.

Two properties of the overrides worth knowing before you turn them on:

  • The query value is taken verbatim, without percent-decoding. A well-formed locale tag is [A-Za-z0-9-] and needs none, so a percent-encoded tag fails validation and falls through to the next source. That is one decoder fewer between a request and a lookup. A repeated ?lang= resolves to the last occurrence, matching what serde_urlencoded does, so a parameter smuggled in ahead of the real one does not take precedence over it.
  • The session is read and never written. Persisting a choice is a decision with a cookie and a lifetime attached, and it belongs to the handler that offers the language switcher. Reading it needs the auth feature, which is what brings tower-sessions; without auth the key is accepted and never consulted, so enabling auth later does not change a call site. The value goes through LocaleId::parse like everything else — “we wrote it, so it is fine” is how a validated field stops being validated.

Matching a proposed tag

Every proposed tag, from any source, goes through exactly two steps:

  1. LocaleId::parse, which refuses anything that is not a canonical BCP-47 identifier of at most 35 bytes;
  2. a lookup in Catalogs, which is an in-memory BTreeMap whose keys came from the application’s own source at startup.

A tag that fails either is discarded and the next candidate is tried. On an exact miss there is one further step: the first registered locale whose language subtag equals the candidate’s language subtag. That matching runs on an already-validated identifier, never on a prefix of the raw string — a prefix match on raw bytes would make en/../../etc match en.

The consequence is a fallback in both directions:

RegisteredRequestedSelected
en, fr, pt-BRfr-CAfr — a region falls back to its language
en, fr, pt-BRptpt-BR — a language falls back to a registered region
en, fr, pt-BRde-DEen — the default
en, fr, pt-BRfr-, frx, fr..en — not a prefix match

Candidates are tried in order, and each candidate is resolved exact-first then by language. There is no pass that prefers an exact match somewhere later in the list over a language match earlier in it: with fr and pt-BR registered, Accept-Language: pt-PT, fr selects pt-BR, because pt-PT resolves by language before fr is ever reached. When several registered locales share a language subtag the first in canonical-tag order wins, so pt with both pt-BR and pt-PT registered selects pt-BR.

Accept-Language

The header is parsed into weighted candidates, best first.

RuleBehaviour
absent q1.0, per RFC 9110
q=0“not this one” — the entry is dropped, not ranked last
malformed qtreated as absent rather than as zero
q outside 0.0..=1.0treated as absent
equal weightsstable, so the client’s order is kept
*dropped; “anything” is what falling through to the default already does
parameters after ; that are not qdiscarded
whitespace and casingtolerated
bytes readthe first 512; the rest of the header is ignored
entries readthe first 16; the rest are ignored

The two bounds are why Accept-Language: en;q=0.1, repeated ten thousand times followed by fr selects en: the fr at the far end is never seen. A real browser sends well under 100 bytes and at most a dozen entries, so the bound costs nothing real and removes the reason to walk a megabyte of padding.

Weights are compared as thousandths (q=0.8 is 800), so the sort is over integers rather than over a partial order on f32.

A hostile entry does not take the rest of the header down with it: ../../etc/passwd;q=1.0,\0;q=0.95,fr;q=0.9 selects fr.

Installing the layer

LocaleLayer negotiates once per request, puts the Locale in the request’s extensions, and annotates the response.

use arcature::i18n::{LocaleLayer, LocaleNegotiator};

Application::new()
    .routes(routes())
    .layer(LocaleLayer::new(
        LocaleNegotiator::new(catalogs).query_parameter("lang"),
    ))
    .build()

On the way out it sets two headers:

HeaderBehaviour
Content-Languagethe selected tag — only if the handler did not already set one
VaryAccept-Language appended to whatever was there, never duplicated; a Vary: * is left alone

The Vary is not decoration. Without it a shared cache stores one representation per URL and serves the French page to the next English reader, which is a correctness bug at best and a privacy one as soon as a page contains anything about the person who requested it. An existing Vary: accept-language in any casing is recognised and left as it is.

An Accept-Language header whose bytes are not visible ASCII is treated as absent rather than as an error: HeaderValue::to_str refuses it, and the request is served in the default locale with a 200.

Where you install the layer decides what sees the locale. Layers added with Application::layer(..) are stage 21 of the request pipeline — the innermost stage, inside the session (stage 16) and inside Inertia (stage 18); src/application/pipeline.rs has the full table. That is the right side of the session, so the session override works. It is the wrong side of Inertia for one path only:

PathSees the locale from a user .layer()
the Locale extractoryes — an extractor runs after every layer on the route
the Inertia extractor and inertia.render(..)yes, for the same reason
a handler returning Page<T>, rendered by InertiaLayer after the factno

InertiaLayer reads the locale out of the extensions when it runs, which is before a user layer. A deferred Page<T> render therefore loses its locale prop and nothing else. To fix it, install LocaleLayer on a Router outside InertiaLayer — with Router::layer the last layer applied is the outermost, so LocaleLayer is the later call:

let app: Router = Router::new()
    .route("/users", get(index))
    .layer(InertiaLayer::new(config))
    .layer(LocaleLayer::new(negotiator));

Reaching the locale from a handler

Locale is an extractor. It reads what the layer put in the extensions; it does not negotiate:

use arcature::i18n::Locale;
use arcature::prelude::*;

async fn greet(locale: Locale) -> Result<Response> {
    Ok(text(StatusCode::OK, locale.message("greeting")?))
}
MethodReturns
locale.id()&LocaleId, the active tag
locale.source()LocaleSource — worth reading in a language switcher
locale.is_default()whether the locale was fallen back to rather than asked for
locale.catalogs()&Catalogs, every locale the application registered
locale.catalog()&Catalog for this locale; always present
locale.message(key)Result<String, I18nError>
locale.translate(key, &args)Result<String, I18nError>

Cloning is cheap: the tag is an Arc<str> and the catalogs are behind an Arc.

The extractor deliberately does not negotiate a locale of its own. Doing that would need a Catalogs, which only the layer has, and it would make a route that forgot the layer answer in a language nobody negotiated instead of saying the wiring is missing. On a route with no LocaleLayer the extractor produces I18nError::NotNegotiated, which becomes a 500. What the client sees is covered under Error handling; the short version is that it does not name the layer.

There is no OptionalFromRequestParts impl, so Option<Locale> is not an extractor. A route either has the layer or does not.

From a view

With views on, translation happens in the template: give the template struct a Locale field and call it.

#[derive(Template)]
#[template(
    source = "<h1>{{ locale.message(\"hi\").unwrap_or_default() }}</h1>",
    ext = "html",
    askama = arcature::askama
)]
struct Greeting {
    locale: arcature::i18n::Locale,
}

let response = view(Greeting { locale: locale.clone() })
    .in_locale(&locale)
    .into_response();

Two things to notice.

There is no {{ t("key") }} filter and there will not be one. Adding one would mean a lookup the compiler cannot check, which is the opposite of the reason the view layer compiles its templates at all. The cost is visible in the example: a template cannot use ?, so a failed lookup needs unwrap_or_default() or a field the handler resolved before rendering.

in_locale is a separate call, and Content-Language is absent without it. The framework does not infer the header in either direction. A compiled template carries no language — askama resolved it to write! calls — and the locale LocaleLayer negotiated is what the request asked for, which is not the same claim as what the bytes in this response are actually in. A handler that renders a French template says so; one that renders a template it did not translate says nothing, which is better than an untrue header.

LocaleLayer will not fill the gap either: it sets Content-Language only when the response does not already carry one, and a View that never called in_locale carries none, so the layer’s value is used. The two agree exactly when the template really was rendered in the negotiated locale, which is the claim in_locale exists to let a handler make explicitly.

From Inertia props

With inertia on, the renderer publishes the negotiated locale as a locale prop:

{
  "id": "fr",
  "source": "header",
  "available": ["en", "fr"]
}

source is "url", "session", "header" or "default". available is every registered locale in canonical-tag order, which is the list a language switcher needs. It is an object rather than a bare string so it can grow without changing a shape a client already destructures.

The Inertia extractor exposes the same value to a handler that wants to translate something itself: inertia.locale() returns Option<&arcature::i18n::Locale>.

Three rules, and each is a rule about not acting:

SituationBehaviour
the application already shares a locale propits prop wins, untouched
a partial reload that did not name locale in X-Inertia-Partial-Datano locale prop
a partial reload that did name itthe prop is sent
no LocaleLayer, or the layer ran too lateno locale prop, and the page renders as it did before

Overwriting an application’s own locale prop would break a working page on a feature flag; adding an unrequested prop to a partial response is exactly the payload growth partial reloads exist to avoid.

The prop is inserted after prop resolution and is not a field of any #[page] struct, so it is outside the Client Exposure Firewall’s schema and arc typegen does not know about it. A generated page type will not have a locale field. Declare it in the TypeScript by hand, or add your own locale prop to the page struct, which then wins under the first rule above.

Security

Every claim here is a property of src/i18n/, checked against the source rather than assumed from a type’s name.

There is no filesystem access

src/i18n/ performs no filesystem access at all. A search of the module for std::fs, std::io, tokio::fs, File, Path, PathBuf, read_to_string and Command returns one line, and it is a comment discussing the absence. The whole module is args.rs, catalog.rs, error.rs, locale.rs, negotiate.rs and mod.rs, and none of them opens anything.

Catalogs are values the application constructs and hands over. The registry is an in-memory BTreeMap built at startup. Lookup is a map lookup against Catalogs::contains, a whitelist whose entries came from the application’s own source.

This matters because the classic way to lose is to turn a locale tag into locales/{tag}.ftl and open it, at which point ../../etc/passwd and ..\..\..\windows\win.ini are one request away. There is no filesystem path for a hostile tag to traverse because there is no filesystem access to traverse it with. The property is structural, not defended.

A hostile or unregistered locale selects the default

That is the belt; LocaleId is the braces. It is the only locale type the API accepts, its only constructor validates, and a request’s raw string cannot be passed where a locale is expected without going through it.

Each of these is something a request can carry, and none of them parses, so none of them is ever a candidate — from ?lang=, from the session, or from Accept-Language:

../../etc/passwd          ..\..\..\windows\win.ini    /etc/passwd
C:\Windows\System32       en/../../etc/passwd         %2e%2e%2f%2e%2e%2f
....//....//etc/passwd    en\0                        \0/etc/passwd
fr\nSet-Cookie: stolen    fr\r\nX-Injected: 1         $(cat /etc/passwd)
`id`                      {{7*7}}                     <script>alert(1)</script>
en\u{202e}                \u{feff}en                  "e" x 64000

In every case the outcome is the application’s default locale with LocaleSource::Default — a value that was never derived from the input at all. A well-formed but unregistered tag such as de-DE takes the same path. src/i18n/negotiate.rs pins this in a test that walks the list above through all three sources.

One entry deserves a note, because it looks like an exception and is not. Accept-Language: en; rm -rf / selects en, with LocaleSource::Header. That is not a hostile tag being accepted: ; starts a parameter list, so the language range is en and the rest is a parameter that is not a q and is discarded, which is what RFC 9110 says the field means. The en still goes through LocaleId::parse and the whitelist like any other candidate, and nothing after the ; survives into the result. The same bytes offered as a whole tag through ?lang= are refused.

The Fluent parser runs over developer-authored catalogs

The view layer chose askama specifically so that no template parser runs inside the request path. This module adds a runtime parser. The tension is real and the answer is that the two parsers eat different food.

A catalog is developer-authored and lives in the repository. The .ftl text passed to Catalog::parse is a file a translator wrote and a reviewer merged. It does not arrive over the network and it is not selected by anything a request controls. In the intended use it is include_str!, so the bytes are in the binary and the parse is a startup cost.

A request supplies arguments, not messages. Values from a request reach Fluent as ArgValues — a string, an integer, a float — and Fluent interpolates them. It does not evaluate them: there is no path by which a $name of { $other } becomes a placeable, because the message’s pattern was fixed when the catalog was parsed and an argument is substituted into that pattern rather than re-parsed with it. Fluent has no property lookup on host objects, no filesystem access and no eval, and a catalog here can invoke no functions at all: NUMBER and DATETIME exist in Fluent but require FluentBundle::add_builtins, which Catalog::parse never calls. The machinery a template-injection payload needs is not there to reach.

That leaves one rule this module holds itself to, and it is the thing to check in review: never call Catalog::parse on bytes that came from a request. A feature that let an administrator upload a .ftl file, or that read a catalog out of a database row, would put attacker-influenced text into the parser and would need its own analysis. Nothing here does that, and nothing here offers a way to.

Errors quote the catalog, never the request

Two rules shape what I18nError is allowed to carry.

A rejected locale tag is never quoted back. LocaleRejection says what was wrong and never what was sent, in three coarse variants — Empty, TooLong, NotWellFormed. The string that failed validation is the one place in this subsystem where a request’s bytes arrive unexamined, and writing it into an error’s Display puts it one tracing::warn! away from a log line. A log line is a text format with no escaping: a tag containing \n writes a second entry, and a tag containing a terminal escape sequence is read by whoever cats the file. The three variants are enough to debug a developer’s own typo.

A message key is not a secret, and a translated string may be. A key is a constant in the application’s source, so naming one in an error is safe and useful. The formatted value is not: it can carry whatever the caller interpolated. I18nError::Format therefore reports Fluent’s diagnostics and never the partially formatted output — Fluent’s recovery for an unresolved placeable is to emit the placeable’s own source text, so what it hands back on the error path is a string with { $count } in it, and a caller holding a String will put it on a page.

Error handling

I18nError has five variants:

VariantCause
InvalidLocale(LocaleRejection)a string was refused as a locale tag; the string is not carried
Parse { locale, errors }an .ftl source did not parse, or its messages collided with ones already in the catalog
Missing { locale, key }neither the locale’s catalog nor the default one has the key
Format { locale, key, errors }the message exists but could not be formatted
NotNegotiateda handler asked for Locale on a route without LocaleLayer

Parse is a developer error by definition: the source is a file in the repository, so a catalog that fails here fails on the first request after a deploy, for everyone, identically. NotNegotiated is wiring, not input: it is the same for every request that reaches that route and it is fixed in one line of the router.

From<I18nError> for arcature::Error produces Error::Other("translation failed"), which answers status 500, code internal_error, for all five variants. The key, the locale and Fluent’s diagnostics are dropped, because Error’s IntoResponse writes its Display text into the detail field of the problem document outside production — so anything left in it is one APP_ENV away from the wire, and a message key is a fragment of the application’s source tree.

The detail goes to tracing::error! instead. tracing arrives with observe, and i18n does not imply it. In a build with i18n and without observe the diagnostics are discarded with nothing recorded anywhere: the client gets its uninformative 500 and the operator gets silence. This is the same trap Views documents, and the same advice applies — if you enable i18n, enable observe.

Concretely, a missing LocaleLayer on a route produces a 500 whose body says translation failed and mentions neither the layer nor negotiation. There is a test asserting exactly that. Without observe, nothing anywhere says what went wrong.

unsafe in the dependency tree

arcature is #![forbid(unsafe_code)]. Its dependencies are not, and two facts about this feature belong in the open.

fluent-bundle pulls in self_cell, which contains unsafe. self_cell is how FluentResource holds a String of .ftl source together with an AST that borrows from it — a self-referential struct, which safe Rust cannot express, so the crate builds one with a small amount of unsafe and a well-known soundness argument. It is not incidental: it is the reason parsing a catalog does not copy every string out of the source. Enabling i18n accepts that.

The rest of the subtree is pure Rust with no C, no network and no filesystem access:

CrateRole
fluent-syntaxthe .ftl parser
fluent-langneglanguage negotiation primitives
intl-memoizercaches per-locale formatters
intl_pluralrulesthe CLDR plural-category tables
unic-langid, unic-langid-implthe BCP-47 identifier type
type-map, rustc-hash, smallveccontainers
self_cellcontains unsafe

The cargo geiger baseline does not change, and that is not a claim that nothing was added. baselines/unsafe-baseline.<host-target>.txt is recorded by just geiger, which runs cargo geiger --all-targets over the default feature set. i18n is not in default, so self_cell is outside the graph the baseline measures and the file is byte-identical. A reader who expected the number to move should know why it did not, rather than conclude the dependency is free: an application that turns i18n on takes on self_cell’s unsafe, and that is not visible in the recorded numbers.

What this does not do

Collected, because a chapter that lists only what works is useless to somebody deciding whether to depend on this.

  • No catalog loading of any kind. No directory scan, no locales/ convention, no reload. Catalog::parse takes a &str and the application decides where it came from. This is the security property, not an omission to be fixed.
  • No hot reload. A catalog compiled in with include_str! changes when the crate is rebuilt. arc dev watches .rs, Cargo.toml, Cargo.lock and .env*, so saving an .ftl file triggers nothing — but the include_str! makes rustc track it, so cargo build or touching any .rs picks it up.
  • No fallback for attributes. Catalogs has message and translate and no attribute. An attribute is only reachable through Catalog::attribute, on one catalog, so an untranslated .placeholder is I18nError::Missing rather than the default catalog’s text. Message-level fallback does not extend to it.
  • No session write. The negotiator reads the session key and never sets it. A language switcher that should persist a choice writes the session itself.
  • No cookie source. Accept-Language, a query parameter and a session entry are the three sources. A locale cookie is not one of them; read it in a handler and write the session, or put the tag in the query.
  • No Option<Locale> extractor, and no negotiation inside the extractor. A route without the layer answers 500.
  • No prelude entry. Import from arcature::i18n::.
  • No Deserialize for LocaleId. Take a String and parse it.
  • No Content-Language from a view unless you call .in_locale(..), and no inference of a rendered page’s language from the negotiated one.
  • No {{ t("key") }} template filter, by the same argument that made the view layer compile its templates.
  • No locale field in generated TypeScript. The Inertia prop is added after prop resolution and is not part of any page contract.
  • No number or date formatting API of its own. NUMBER and DATETIME inside a Fluent message are the whole surface; there is no locale.format_currency(..).
  • No diagnostics at all without observe. Every failure becomes the same generic 500, and the reason is dropped rather than logged.
  • LocaleNegotiator::resolve truncates accept_language at 512 bytes on a byte boundary. Through LocaleLayer the value has already passed HeaderValue::to_str, which admits only visible ASCII, so the cut is always a character boundary. Calling resolve directly with a longer non-ASCII string can land the cut mid-character and panic. The layer cannot reach it; a test harness calling resolve by hand can.

Database

One PostgreSQL pool, two first-class paths. SeaORM and SQLx share the same PgPool through SqlxPostgresConnector::from_sqlx_postgres_pool. There is no second pool, no global registry, and no thread-local.

The Db handle is Clone + Send + Sync + 'static, so it lives in Axum state like any other value.

Connecting

use arcature::database::{DatabaseConfig, Db, PoolConfig};

let config = DatabaseConfig::new(&std::env::var("DATABASE_URL")?)?
    .pool(PoolConfig::new().max_connections(20))
    .application_name("acme");

let db = Db::connect(config).await?;

Application::database(config) does this for you at startup and hands the handle to the state closure as resources.db().

SessionConfig sets per-connection PostgreSQL timeouts — statement_timeout, lock_timeout, idle_in_transaction_session_timeout — applied when a connection is established. SessionConfig::none() opts out.

db.orm() borrows the SeaORM DatabaseConnection; db.sqlx() borrows the PgPool. db.ping() checks liveness; db.close() shuts the pool down.

Reaching the handle from a handler

Db is not an Axum extractor. It comes out of state:

use arcature::axum::extract::State;

pub async fn index(State(state): State<AppState>) -> Result<Response> {
    let db = state.db.as_ref().ok_or_else(|| not_found("no database"))?;
    let users = user::Entity::query(db).all().await?;
    Ok(json(&users))
}

The generated application’s AppState holds db: Option<Db>, because a subsystem that was never configured contributes None rather than a panic.

Models

A model is an ordinary SeaORM entity. SeaORM is re-exported as arcature::database::sea_orm, so there is no second version to keep in step:

pub mod user {
    use arcature::database::sea_orm::entity::prelude::*;
    use arcature::{Deserialize, Serialize};

    #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
    #[sea_orm(table_name = "users")]
    pub struct Model {
        #[sea_orm(primary_key)]
        pub id: i64,
        pub email: String,
        pub name: String,
    }

    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
    pub enum Relation {}

    impl ActiveModelBehavior for ActiveModel {}
}

The struct must be named Model and must live in its own module: that is SeaORM’s requirement, not Arcature’s. DeriveEntityModel generates Entity, Column and PrimaryKey beside it.

The short path: #[model(table = "...")]

#[model] writes the module above for you. It expands to a private module holding a struct named Model – which is the name SeaORM’s DeriveEntityModel requires – and re-exports the family under predictable names in the parent scope:

#[model(table = "users")]
pub struct User {
    #[sea_orm(primary_key)]
    pub id: i64,
    pub email: String,
}

yields User, UserEntity, UserActiveModel, UserColumn, UserPrimaryKey and UserRelation. arc make:model generates exactly this.

The generated Relation enum is empty and there is no syntax to fill it: the enum lives inside the generated module, which an application cannot write into. A model that needs relations is written as a plain SeaORM entity module instead. #[model] is the short path, not the only one, and nothing else in the database layer depends on it – the query facade is a blanket impl over every SeaORM entity, so a hand-written entity gets it for free.

A model is a database row. It is not a #[resource] and not browser-safe by virtue of deriving Serialize; converting it for the browser is an explicit impl From<User> for UserResource. See Inertia.

Querying

Every SeaORM entity gains Entity::query(&db) through the blanket QueryModel impl. Bring the trait into scope with use arcature::database::QueryModel;:

let recent = user::Entity::query(&db)
    .where_eq(user::Column::Active, true)
    .where_not_null(user::Column::VerifiedAt)
    .latest()
    .limit(20)
    .all()
    .await?;

let one = user::Entity::query(&db)
    .where_eq(user::Column::Email, "ada@example.com")
    .one()
    .await?;

The predicates are where_eq, where_ne, where_gt, where_lt, where_in, where_null, where_not_null, and filter for a raw SeaORM condition. Ordering is latest, latest_by, oldest, order_by_asc, order_by_desc. limit and offset bound the window. The terminators are all, one and count.

paginate(per_page) returns a Paginated; page(n) fetches a 1-indexed page, and page_with_count(n) fetches the page and the grand total in two statements.

Writing

The CRUD free functions take the ActiveModel:

use arcature::database::{delete, find_by_pk, insert, update};

let created = insert(&db, user::ActiveModel { .. }).await?;
let changed = update(&db, active).await?;
delete(&db, active).await?;
let found = find_by_pk::<user::Entity>(&db, 1).await?;

Transactions

Transaction is a namespace, not a value. Both paths commit on Ok and roll back on Err:

use arcature::database::Transaction;

Transaction::orm(&db, |txn| Box::pin(async move {
    // SeaORM calls against `txn`
    Ok(())
})).await?;

Transaction::sqlx(&db, |txn| Box::pin(async move {
    sqlx::query("update accounts set balance = balance - $1")
        .bind(100)
        .execute(&mut **txn)
        .await?;
    Ok(())
})).await?;

The closure returns a pinned boxed future because the borrow of the transaction has to outlive the call; that is the price of not owning the transaction type.

Route model binding

Bound<T> loads a model from a route parameter before the handler runs:

#[arcature::route_model(entity = link::Entity, key = "id", key_type = i64)]
pub struct Link(pub link::Model);

pub async fn show(link: Bound<Link>) -> Result<Response> {
    let link = link.into_inner();
    Ok(json(&link.0.id))
}

The extractor reads KEY_PARAM from the path, parses it (400 on failure), calls RouteModel::load (500 on a database error), and returns a 404 problem when the row is absent.

Binding does not imply authorization. Bound<T> proves the row exists; it does not check that this user may see it. That check is a Policy, and it is a separate, explicit step. The invariant is permanent and is restated in the source of both bound.rs and route_model.rs.

Bound<T> obtains its Db through DbFromState<S>, an Arcature trait rather than axum::extract::FromRefFromRef and Db are both foreign types, so the blanket impl would collide with the orphan rule. An application writes one line:

impl DbFromState<AppState> for Db {
    fn db_from_state(state: &AppState) -> Db {
        state.db.clone().expect("database configured")
    }
}

For a lookup that is not a primary-key find_by_id — a slug scoped to a tenant, say — write the impl RouteModel by hand. The macro covers the common case and stops there.

Migrations

Migrations are SeaORM migrations. The module wraps the four operations against a MigratorTrait schema:

use arcature::database::migration;

migration::up::<Schema>(&db).await?;
migration::down::<Schema>(&db, 1).await?;
migration::fresh::<Schema>(&db).await?;
let status = migration::status::<Schema>(&db).await?;

From the CLI: arc migrate, arc db:fresh, arc db:reset, arc db:seed, and arc make:migration to scaffold one.

Drivers

The database feature splits three ways — db-postgres, db-sqlite, db-mysql — so a SQLite application does not compile the PostgreSQL wire protocol. The connection type above is PostgreSQL-specific, and the job queue requires PostgreSQL because it depends on FOR UPDATE SKIP LOCKED. See Jobs.

No ORM rewrite

Arcature does not own, reimplement, or rename SeaORM’s query builder, relation engine, or transaction system. Query is a thin facade over Select; when it runs out, db.orm() and db.sqlx() are right there. The cost is that two query vocabularies exist in one codebase, which is a smaller cost than a half-built ORM.

Cache

One multiplexed Redis/Valkey connection per Cache. Not a pool — redis-rs’s MultiplexedConnection is cheap to clone, thread-safe, and multiplexes commands over a single socket, so a second pool would buy nothing.

Cache is a value with methods. There is no Cache::remember(&cache, ..) static form; the handle comes from state and you call it directly.

Connecting

use std::time::Duration;
use arcature::cache::{Cache, CacheConfig, Namespace};

let config = CacheConfig::new(&std::env::var("REDIS_URL")?)?
    .response_timeout(Duration::from_secs(2))
    .namespace(Namespace::new("acme")?);

let cache = Cache::connect(config).await?;

Application::cache(config) does this at startup. cache.ping() checks liveness; cache.close() shuts it down.

CacheConfig never logs the password or the full URL — its Debug impl is written by hand to redact them.

max_payload_size(Some(bytes)) caps what a single value may store.

Namespaces

Namespace::new("acme") prefixes every key as acme:key. It rejects an empty prefix, a prefix ending in :, and control characters.

Namespace::none() is the explicit opt-out. It is a distinct value rather than None at the type level, so an unprefixed cache is a decision someone made rather than a field nobody filled in.

Operations

MethodDoes
get::<T>(key)JSON value, None when absent
set(key, &value)store JSON, no expiry
put(key, &value, ttl)store JSON with a TTL
get_bytes(key) / set_bytes(key, bytes)raw bytes
set_bytes_with_ttl(key, bytes, ttl)raw bytes with a TTL
forget(key)delete; returns how many keys went
exists(key)presence
incr(key, delta) / decr(key, delta)atomic counters
expire(key, ttl) / ttl(key)expiry control
set_if_absent(key, bytes)atomic compare-and-set
set_if_absent_with_ttl(key, bytes, ttl)the same, with a TTL

Cache-aside

use std::time::Duration;

let user = cache
    .remember("user:42", Duration::from_secs(300), || async {
        load_user(42).await
    })
    .await?;

remember reads the key, and on a miss runs the loader, stores the result with the TTL, and returns it.

The loader’s error type must implement Into<CacheError>. That is the part worth checking before you write the closure: a loader returning sqlx::Error needs a conversion, or the closure needs to map it.

A miss is not an error. A backend failure is, and it does not run the loader. Arcature does not decide fail-open semantics on your behalf: if you want the loader to run when Redis is unreachable, handle CacheError::Backend explicitly before calling remember. Silently degrading to the database under load is how a cache outage becomes a database outage.

What this module does not own

No Redis protocol reimplementation, no cache server, no connection pool, no TLS stack, no distributed-lock subsystem, no client-side caching layer. The redis-rs crate is re-exported as arcature::cache::redis when you need it directly.

Storage

Object and file storage over OpenDAL, behind a named-disk registry.

Storage is a value, not a namespace. Storage::disk("s3") is an instance method on a handle you got from state — there is no static Storage::disk.

Configuring

Single backend:

use arcature::storage::{Storage, StorageConfig, S3Config};

let storage = Storage::connect(StorageConfig::fs("storage/app")?).await?;

Storage::connect registers the backend as a disk named "default".

Several disks:

let storage = Storage::builder()
    .disk("local", StorageConfig::fs("storage/app")?)
    .disk("s3", StorageConfig::s3(
        S3Config::new("acme-uploads")?
            .region("eu-west-1")
            .access_key_id(std::env::var("AWS_ACCESS_KEY_ID")?)
            .secret_access_key(std::env::var("AWS_SECRET_ACCESS_KEY")?),
    ))
    .default_disk("local")
    .connect()
    .await?;

S3Config redacts the access key id and the secret in its Debug impl.

Application::storage(config) wires the single-backend path at startup.

Disks

CallBehaviour
storage.disk("s3")the named disk; panics if it was never registered
storage.try_disk("s3")Option<Disk>
storage.default_disk()the disk named by default_disk, or "default"
storage.disk_names()what is registered

disk panics deliberately: a disk name is a deployment constant, and a typo should stop the process at the first use rather than return an error every handler forgets to check. try_disk is there when the name really is dynamic.

Disk is cheap to clone — the OpenDAL Operator inside it is Arc-backed.

Paths

Every data-path method takes a &StoragePath, not a &str. Constructing one is where the validation happens:

use arcature::storage::StoragePath;

let path = StoragePath::new("avatars/1.png")?;
storage.disk("s3").put(&path, &bytes).await?;

Rejected: empty keys, absolute paths (/etc/passwd), any .. segment, backslashes, ASCII control characters, and empty segments (a//b).

Allowed: trailing slashes, because they are meaningful as list prefixes; Unicode of all kinds; dots inside a segment.

The check runs before any storage work does, so a traversal attempt fails at the type boundary rather than at the backend.

Operations

All on Disk:

let disk = storage.disk("local");

disk.put(&path, &bytes).await?;
let bytes: Bytes = disk.get(&path).await?;
let present: bool = disk.exists(&path).await?;
let meta = disk.stat(&path).await?;
let entries = disk.list(&prefix).await?;
disk.copy(&from, &to).await?;
disk.rename(&from, &to).await?;
disk.delete(&path).await?;

For large objects, disk.reader(&path) and disk.writer(&path) return the OpenDAL Reader and Writer and stream rather than buffering.

Disk::from_operator(operator) is the escape hatch when you want to configure the OpenDAL operator yourself; disk.operator() borrows it back.

Public files

arc storage:link links storage/app/public into public/storage, the Laravel convention, so files written to the local disk under public/ are served as static assets.

What this module does not own

No object-storage protocol implementation, no S3 signing, no AWS credential machinery, no multipart engine, no TLS. OpenDAL owns the protocol layer, the certified rustls plus aws-lc-rs stack owns TLS, Tokio owns the runtime. The crates are re-exported as arcature::storage::opendal and arcature::storage::bytes.

Uploads

multipart/form-data request bodies: bounded, sanitized, held to their own bytes, and written to a storage disk under a key derived from those bytes.

Off by default, and that is the security decision rather than a packaging one. An upload endpoint is the largest attacker-authored surface a web application has — the filename, the declared content type and the byte count all come from the client — and a build with no upload route has no business carrying a multipart parser for one.

Turning it on

uploads is not in default. It is in fullstack.

arcature = { version = "0.1", features = ["uploads"] }
It pullsBecause
axum/multipartthe parser (multer, via axum::extract::Multipart)
validationthe extractor lives beside the other validated extractors and reports RFC 9457 problem details
storage-fsan upload is written to a storage disk, never to a path the request named
dep:tokioone thing only: tokio::time::timeout. A byte cap cannot express “the client stopped sending”, because a request that never finishes never exceeds anything
unicode-normalizationan attacker-authored filename goes into NFC before the reserved-name and extension checks look at it
sha2the content address
inferthe magic-number table

arc make:upload avatar generates a controller with the whole shape in it.

The UploadedFile extractor

use arcature::prelude::*;
use arcature::storage::StorageError;
use arcature::validation::upload::UploadedFile;

/// The disk sub-tree these uploads live under. A constant, and the
/// application's own string -- nothing off the request is ever a prefix.
const PREFIX: &str = "avatars";

pub async fn store(State(state): State<AppState>, upload: UploadedFile) -> Result<Response> {
    let storage = state
        .storage
        .as_ref()
        .ok_or_else(|| Error::Storage("no storage disk is configured".to_string()))?;

    let address = upload.store_under(&storage.default_disk(), PREFIX).await?;
    let key = address.path_under(PREFIX).map_err(StorageError::from)?;

    Ok((
        StatusCode::CREATED,
        json(serde_json::json!({
            "path": key.as_str(),
            "bytes": address.byte_len(),
            "filename": upload.filename().to_string(),
        })),
    )
        .into_response())
}

That is the generated blueprint’s shape, not a standalone example: it needs an AppState with a storage field and a route to hang off, so it will not compile on its own.

Five things are true by the time the handler body starts, because extraction is what makes them true:

  1. Bounds. Total size, per-part size, part count and a read timeout, from the MultipartLimits in the request extensions.
  2. A whitelisted extension, from the UploadPolicy in the request extensions.
  3. A sanitized filename. The path, the controls, the bidi overrides and the Windows device names are gone, and the result is metadata.
  4. Bytes that match the extension. shell.php renamed avatar.png is refused here, not discovered later.
  5. A client-safe rejection, if any of the above failed.

UploadedFile implements FromRequest, not FromRequestParts. It consumes the body, so it has to be the last handler argument.

MethodReturns
filename()&SafeFilenamemetadata: show it, send it in a Content-Disposition, never resolve it as a path
content_type()Option<SniffedType>, derived from the object, never from the part’s header
bytes()&Bytes
byte_len()usize
into_bytes()Bytes, consuming the wrapper
store(&disk)writes under ab/cd/<digest>.<ext>
store_under(&disk, prefix)writes under <prefix>/ab/cd/<digest>.<ext>

Both store methods return a ContentAddress and fail with UploadError, which has exactly two variants: UploadError::Storage (the backend failed — a 5xx) and UploadError::Content (the bytes and the extension disagree — a 4xx). They are kept apart so that a disk that is down and a file that was refused do not answer with the same status. From<UploadError> for Error preserves the split, which is why the example uses ? and not a map_err.

store_under writes under the prefix, so address.path() — the address on its own — will not find the object afterwards. address.path_under(PREFIX) is the key that will.

What a refusal looks like

Every refusal is an RFC 9457 problem document shaped exactly like any other field validation failure, reported under the fixed key file (upload::UPLOAD_FIELD) and never under the part’s own name.

CodeStatusWhen
missing422the body ended without a part that carried a filename= the policy would accept
extension422the filename could not be sanitized, or its extension is off the whitelist
contents422the bytes and the extension disagree

A body that is not multipart/form-data at all is a 415. A bound that was crossed is a 413 or a 408 — see MultipartLimits.

The code and the message are both fixed strings chosen in the framework. Nothing from the request reaches the response body: not the filename, not the declared content type, and not the sniffed one — reporting the last of those would let an uploader use the endpoint as a free file-type oracle.

It buffers, and that is the small-file path

An extractor runs to completion before the handler is called, so it has nowhere to put the bytes except memory. That buffer is bounded by MultipartLimits::field_bytes, which defaults to 8 MiB; lowering it on an upload route is how an application keeps the buffer small.

For anything bigger than an avatar, do not use this extractor. Drive BoundedMultipart in the handler and write each chunk through UploadWriter, which never holds more than one chunk regardless of object size. The extractor is the convenience; that is the load-bearing path.

It is not authorization

A validated upload is not an authorized upload, exactly as a validated request is not an authorized request. Whether this user may write to this disk under this prefix is a separate, explicit decision.

UploadPolicy and AllowedExtensions

UploadPolicy is a tower::Layer. Applying it to a route puts the policy in that request’s extensions, where the extractor reads it back.

use arcature::storage::AllowedExtensions;
use arcature::validation::upload::UploadPolicy;

let router = axum::Router::new()
    .route("/attachments", axum::routing::post(store))
    .layer(UploadPolicy::new(AllowedExtensions::documents()).with_field("attachment"));

With no policy layer installed, the default is images and nothing else. UploadPolicy::from_extensions never returns None; it falls back to UploadPolicy::default(), which is UploadPolicy::new(AllowedExtensions::images()). A route that forgot the layer fails closed, because “no policy configured” must not mean “anything goes”. Widening the whitelist is the thing an application has to do on purpose.

Built-inExtensions
AllowedExtensions::images()jpg, jpeg, png, gif, webp
AllowedExtensions::documents()pdf, txt, csv
AllowedExtensions::new(["JPG", "pdf"])?whatever is listed, lowercased; errors on an entry that is not a valid extension
AllowedExtensions::default()empty — and an empty whitelist stores nothing at all, which is the right behaviour for a misconfiguration

svg is deliberately absent from images(). An SVG is an XML document that may carry script, so serving one inline is a stored-XSS primitive; an application that needs SVG adds it knowingly with .with("svg")?.

It is a whitelist and never a blacklist. A blacklist of dangerous extensions is a list of the ones somebody thought of, and the interesting ones are always the other ones — .phtml, .php7, .cgi, .jsp, .svgz, .htaccess.

The rest of the surface: contains(&extension), iter(), len(), is_empty(), and with(extension) to add one.

An Extension is one to sixteen ASCII alphanumerics, lowercased on parse, and nothing else. That is not tidiness. An extension is the string a web server, an operating system and a browser each use to decide what a file is, and allowing only [0-9a-z]{1,16} leaves them no encoding, no separator and no homoglyph to disagree about.

UploadPolicy::with_field("attachment") requires the file to arrive in the part of that name. Without it the first part carrying a filename= wins, which is convenient and slightly sloppy: a form with two file inputs becomes order-dependent.

Filenames are sanitized, and then they are metadata

SafeFilename::parse(filename, &allowed) is the one place a client-authored filename= is disarmed. It handles six different attacks, and a filter that knows about one of them is a filter that has not been written yet.

InputWhat it wantsWhat happens
../../etc/passwd.pngescape the storage rootthe directory part is discarded before anything else runs; the name is passwd.png
a filename with a NUL in ittruncate the name in a C API downstreamrejected: control characters are never repaired
CON.pngopen a Windows device instead of a filerejected, with any extension and with trailing dots or spaces
a name with a right-to-left override in itrender so the extension reads .jpgrejected: bidi and other invisible format controls are fatal
shell.php.jpgbe served by a mis-configured AddHandlerthe inner extension marker is replaced: shell_php.jpg
an ordinary Vietnamese filename with diacriticsnothingaccepted, NFC-normalized, otherwise unchanged

That last row is why this exists at all. StoragePath::new validates and rejects; it has no opinion about how to repair a name, so a filename with a space or a diacritic in it fails a check that was never aimed at it, and a sanitizer that rejects real names teaches applications to bypass the sanitizer.

The reject-versus-repair split is deliberate. A character is repaired (replaced with _) when a human plausibly typed it and it is only dangerous to a downstream parser: . (every remaining one, after the extension is split off), /, \, :, *, ?, ", <, >, |. A character is rejected when its presence is itself the attack: the C0 and C1 controls, DEL, the bidi overrides and isolates, the zero-width joiners, the word joiner, the byte-order mark, the blank-rendering separators and the Unicode tag block. Nobody names a holiday photo with a right-to-left override in it. The variation selectors are deliberately not fatal — they occur beside emoji in names people really have.

The whole name is fitted inside MAX_FILENAME_BYTES (255, the per-component limit on ext4, XFS, APFS and NTFS alike) by truncating the stem on a character boundary. The extension is never truncated: half an extension is a different file type.

FilenameError is the coarse reason the parse failed — Empty, TooLong, ControlChar, Traversal, MissingExtension, InvalidExtension, ExtensionNotAllowed, EmptyStem, ReservedName. It is coarse on purpose: the caller reports a fixed string to the client, never the offending input.

The name that comes out is still metadata. StoragePath::from_filename exists for the case where a sanitized name really is the key, and it produces exactly one path segment — but two users still collide on it, and one overwrites the other. Where the key does not have to be human-readable, use the content address and keep the filename for the download header.

MultipartLimits

A multipart body is a stream the client writes and the server reads until the client stops. Four separate things there are the client’s choice, and each is a separate way to exhaust a process.

SettingDefaultConstWhat it stops
fields32DEFAULT_FIELDSfifty thousand two-byte parts inside a 1 MiB body — the cost of a part is a header parse and an allocation, not its length
field_bytes8 MiBDEFAULT_FIELD_BYTESone part that is the entire budget. Deliberately below the total
total_bytes16 MiBDEFAULT_TOTAL_BYTESone request that fills the disk or the heap
read_timeout30 sDEFAULT_READ_TIMEOUTa byte a minute, holding a task and a socket open

These apply with no layer installed. MultipartLimits::from_extensions never returns None; it falls back to MultipartLimits::new(), the values above. There is no way to end up with no bound, and nothing here reads any value as unlimited — with_fields(0) means the body may contain no parts at all, and that is what it does.

Override them per route:

use std::time::Duration;
use arcature::http::multipart::MultipartLimits;

let router = axum::Router::new().route(
    "/avatar",
    axum::routing::post(store).layer(
        MultipartLimits::new()
            .with_total_bytes(2 * 1024 * 1024)
            .with_field_bytes(2 * 1024 * 1024)
            .with_fields(4)
            .with_read_timeout(Duration::from_secs(10)),
    ),
);

Readers: total_bytes(), field_bytes(), fields(), read_timeout().

The part count is checked before the read, so the part past the cap is refused rather than parsed and then refused. Byte counts are checked as soon as the chunk that crosses a cap arrives, so nothing past a cap is ever handed to the caller.

MultipartErrorStatus
TooManyFields { limit }413
FieldTooLarge { limit }413
BodyTooLarge { limit }413
ReadTimeout { after }408
Parse { source }whatever axum maps the parser error to

MultipartError::problem() builds the RFC 9457 document. Its detail is a fixed per-category string; axum’s MultipartError::body_text is deliberately not used, because it can carry the parser’s own message, which quotes header bytes the client wrote.

The streaming path

BoundedMultipart::new(multipart, limits) wraps axum’s parser rather than replacing it. Every read goes through the timeout, every part is counted before it is handed out, and every chunk is added to both a per-part and a whole-body total.

let mut multipart = BoundedMultipart::new(multipart, limits);
while let Some(mut field) = multipart.next_field().await? {
    while let Some(chunk) = field.chunk().await? {
        upload.write(chunk).await?;
    }
}

Parts are handed out one at a time and borrow the parser. That is multer’s requirement, not a choice made here: part n+1 cannot be read before part n has been consumed.

BoundedField offers name(), file_name() (raw and unsanitized, exactly as the client wrote it), declared_content_type(), byte_len(), chunk(), bytes() (consuming, bounded by field_bytes, and meant for the small text inputs beside the file) and text() (which returns Ok(None) rather than an error when the part is not UTF-8, because that is the caller’s schema failing, not the transfer). BoundedMultipart itself reports limits(), fields_read() and bytes_read().

Content-addressed storage

The object key is SHA-256(bytes) plus a whitelisted extension, split into two levels of fan-out:

ab/cd/abcd...9824.png

Path traversal is not one bug, it is a family of them, and the family is large because the input is a string whose structure the attacker chose: ../, ..\, %2e%2e%2f, ....//, a NUL that truncates the name in the C library underneath, an NTFS alternate data stream, a symlink the previous upload planted. A sanitizer answers those one at a time and is only ever as complete as the last person who thought about it. Content addressing answers all of them at once, structurally: no byte of the request reaches the path. There is nothing left for a traversal payload to be in.

Three things fall out of it. The same bytes uploaded twice land on the same key and occupy the storage once. Re-uploading is idempotent rather than a race between two writers for one name. And a filesystem disk gets 65,536 leaf directories instead of one directory with a million entries in it.

ContentAddress carries digest() (64 lowercase hex characters, DIGEST_HEX_LEN), byte_len(), extension(), path() and path_under(prefix). ContentAddress::of(bytes, extension) addresses a buffer already in memory; ContentHasher (new, update, byte_len, finish) does it a chunk at a time.

The original filename is not part of any of this. It is a label carried alongside the object, rendered in a Content-Disposition header and shown in a UI, and resolved by nothing. Keep both layers: the sanitizer makes the metadata safe to display, and content addressing makes the path safe to resolve. Neither substitutes for the other.

Staging, because the key is not known until the end

The digest exists only after the last byte has gone past, so bytes go to a unique transient key under STAGING_PREFIX (_staging) first and are moved onto the content-addressed key afterwards. The staging key is process id, wall-clock nanoseconds and a process-local counter — never anything the client sent.

let mut upload = disk.begin_upload_under("avatars").await?;
upload.write(chunk).await?;
let address = upload.finish_verified(Extension::parse("png")?).await?;

begin_upload() puts the object at the root; begin_upload_under(prefix) validates the prefix before a byte is written, rather than after the whole body has been streamed to a key that turns out to have nowhere to go.

UploadWriter::write sends the chunk to the backend and to the hasher and then drops it. Nothing accumulates: an upload of any size costs one chunk of memory, which is what makes the size caps a policy rather than the only thing standing between a request and the heap. The one exception is the sniff buffer, at most 512 bytes, readable as head().

finish(extension) closes the object — the close is what completes it on an S3-compatible backend, and it happens before the move — then moves it onto its content-addressed key. If something is already at the destination the staging copy is deleted instead. That is not an optimization: the key is the digest, so an object already there has exactly these bytes, and overwriting it would be a no-op that can still fail (on Windows a rename over a file another reader has open does).

finish_verified(extension) is the call an upload handler wants. It re-checks the leading bytes against the extension and, on disagreement, removes the staging object before returning the error, so a rejected upload costs no disk.

abort() drops the staging object. Neither finish nor abort is optional. Dropping an UploadWriter without calling one of them leaves the staging object behind, and on an S3-compatible backend leaves the multipart upload un-closed. There is no Drop that can fix that, because both fixes are async.

It is not access control

A digest is unguessable in practice, but “unguessable URL” is not authorization — it leaks through referrers, logs and browser history like any other URL. Authorize the download.

The declared Content-Type is carried and never believed

A multipart part arrives with two claims about its type, and the client wrote both of them. Content-Type: image/png is a string in a header the uploader chose. filename="avatar.png" is a string in the same header, chosen by the same uploader. Neither is evidence.

BoundedField::declared_content_type() hands the header value back, because an application may want to log it or compare it. Nothing in Arcature decides what a file is from it. The only statement about an upload that the client did not author is the one made by its first few hundred bytes.

sniff(bytes) compares the leading SNIFF_BYTES (512) against a table of magic numbers and returns Option<SniffedType>, which carries both the canonical extension() and the mime(). verify(bytes, &extension) holds the two to a symmetric agreement:

  • An extension with a known signature — png, jpg, pdf, docx, mp4, and the rest of the table — must sniff to that signature. Bytes that sniff to nothing are refused, not waved through: “unrecognized” is exactly what a PHP script renamed .jpg looks like.
  • An extension with no signature — txt, csv, an application’s own — must sniff to nothing. If a .txt upload’s first bytes are a PE header or a zip local-file header, the extension and the content disagree just as loudly, in the other direction.

Both directions are refusals, so there is no “unknown” state an upload can land in and be accepted by default. expected_signatures(&extension) exposes the table entry, or None for a format with no magic number. The OOXML entries accept plain zip beside their own type, because a .docx is a zip archive and whether the inner content-type part is close enough to the front of the stream to be seen inside 512 bytes depends on how the producing application ordered it.

SniffError has two variants: Unrecognized { declared } and Mismatch { declared, sniffed }.

A .txt or .csv upload therefore succeeds with content_type() of None. That is correct, not a gap: there was no signature to find.

Serving a stored file back

Accepting an upload safely and serving it safely are two different jobs, and doing the first perfectly buys nothing if the second hands the file to a browser as a document. A stored file served inline is content the attacker wrote, on the application’s own origin, with the application’s cookies attached — stored XSS with extra steps.

use arcature::http::download::Attachment;

let label = SafeFilename::parse(&stored_name, &AllowedExtensions::documents())
    .map_err(|_| arcature::bad_request("that filename is not storable"))?;

Ok(Attachment::from_disk(&disk, &key)
    .await
    .map_err(|_| arcature::not_found("no such file"))?
    .with_filename(&label)
    .into_response())

Every Attachment response carries all four of these, by construction:

HeaderValueWhy
Content-Dispositionattachment, plus the filename if one was setthe browser saves the file instead of rendering it. Nothing is a document, so nothing has an origin, so nothing has script
X-Content-Type-Optionsnosniffwithout it a browser is free to disagree with the declared type and render what it thinks it found
Content-Security-Policydefault-src 'none'; sandbox (DOWNLOAD_CSP)the belt to the disposition header’s braces, for the browser or plugin that renders it anyway
Content-Typethe sniffed media type, or application/octet-stream (OCTET_STREAM)never taken from the request

Content-Length is set as well when the length is known.

A recognized-but-scriptable media type is downgraded to application/octet-stream rather than forwarded. The list is text/html, application/xhtml+xml, image/svg+xml, text/xml, application/xml. The disposition header should already have made this moot, and nosniff and the sandbox policy should have made it moot twice; this is the third lock, and it is there because the cost of being wrong is stored XSS on the application’s own origin.

Attachment::from_disk(&disk, &path) reads only the leading 512 bytes up front, to decide the media type, and streams the remainder — so the response costs one buffer rather than one object. It returns StorageError when the object cannot be stat’d or read, including when it does not exist, which a handler should translate into a 404 rather than passing through. Attachment::from_bytes(bytes) serves something already in memory. with_content_type(sniffed) overrides the type when it was sniffed at upload time and stored beside the object; it takes a SniffedType and there is deliberately no way to set an arbitrary string, so every media type this response can carry is one some bytes were recognized as.

with_filename takes a SafeFilename rather than a &str on purpose — a string parameter would be a header-injection hole waiting for the one caller who forgot. An ASCII name goes out once, as filename="report.pdf". A name that is not ASCII goes out twice per RFC 6266 — the plain form plus an RFC 5987 filename*=UTF-8'' — because a name with diacritics is not representable in the first form and silently mangling it is worse than sending both. The extended form is only worth sending when the plain one lost something.

Limits

axum’s own 2 MiB body cap bites first

axum wraps a request body in http_body_util::Limited at 2 MiB unless the application raises DefaultBodyLimit. Arcature never touches DefaultBodyLimit — grep the crate and the only mentions are in prose. So on a default build the effective ceiling on an upload is 2 MiB, not the 16 MiB DEFAULT_TOTAL_BYTES above, and a 5 MiB photograph is refused by axum before MultipartLimits has seen a byte of it.

That failure arrives as MultipartError::Parse wrapping axum’s StreamReadFailed, and takes the status axum gives it — which is also a 413. axum downcasts the inner error to http_body_util::LengthLimitError and answers PAYLOAD_TOO_LARGE, so a client cannot tell which of the two caps refused it from the status alone. Only the detail string differs. An application that accepts files larger than 2 MiB has to raise the axum limit itself.

Stage 12 of the request pipeline is a separate, third cap: tower-http’s RequestBodyLimitLayer, applied only when the application calls Application::body_limit(bytes). It defaults to absent in the framework, and the generated application’s bootstrap/app.rs sets it to 2 MiB. Nothing in MultipartLimits can raise either outer wall, and nothing needs to: a body over one of them is refused without being buffered.

MultipartLimits is the inner bound that knows the body has parts. Carrying a total there as well is not redundant — an application that never configured stage 12 still gets one, and one that did gets to make a single upload route stricter than the rest of the application without loosening anything.

read_timeout is per read, not per request

The timeout wraps each individual read: each next_field() and each chunk(). A large upload over a slow link is many reads that each return promptly, and it is not what this refuses. What it refuses is the connection that goes quiet with the request half-sent.

The consequence runs the other way too: a client that sends one byte just inside the timeout, forever, is not stopped by the clock. It is stopped by total_bytes, which is why both bounds exist. There is no whole-request deadline here. Application::timeout(duration) at stage 13 of the pipeline is the place for one.

No image decoding, deliberately

Nothing in the upload path decodes anything. No image is parsed, no dimensions are read, no pixel is produced, and there is no image crate in the dependency graph. The type check is a comparison of at most 512 leading bytes against a table of magic numbers, and that is all it is.

A decoder is an interpreter for attacker-controlled input, and decoders are the densest source of memory-safety CVEs in any web stack. Running one in the request path trades a type-confusion bug for a heap-corruption bug. A prefix comparison has no state for hostile bytes to drive.

So if an application needs an image’s dimensions, a thumbnail, a re-encode or a strip of EXIF, that work belongs in a queue worker with its own memory bound and its own timeout — see Jobs — and not in the handler. Put the bytes on a disk, put the key in the payload, and let something that is allowed to crash do the decoding.

What else it does not do

  • It is not a virus scanner, and not a claim of safety. A file can be genuinely, verifiably a PNG and still be malicious. What sniffing answers is narrower and worth having on its own: the bytes and the extension agree.
  • It does not clean up after a process that died mid-upload. Staging objects live under one prefix (_staging) precisely so an operator can see at a glance whether anything was left behind, but nothing sweeps it.
  • It does not delete stored objects, ever. Content addressing means two references can share one object, so nothing here can know when the last one went away. Reference counting and garbage collection are the application’s.
  • It does not record the upload anywhere. No table, no row, no metadata store. The handler gets an address and a filename and decides what to persist.
  • It does not rate-limit. Stage 15 of the pipeline does, and an upload route usually wants a tighter limit than the rest of the application.

Authentication

This module owns the integration seams, not your identity schema. There is no framework User table, no roles table, no permissions system. You write the user type; Arcature gives it sessions, hashing, extractors, and an authorization seam.

The user contract

use arcature::AuthUser;

pub struct User {
    pub id: uuid::Uuid,
    pub email: String,
}

impl AuthUser for User {
    type Id = uuid::Uuid;
    const SESSION_KEY: &'static str = "user_id";
    fn id(&self) -> &uuid::Uuid { &self.id }
}

Id is what goes in the session — Uuid, i64, String, anything serializable. SESSION_KEY defaults to "user_id".

Then say how to load one back:

impl UserLoader<AppState> for User {
    type Error = sea_orm::DbErr;

    async fn load_user(id: &uuid::Uuid, state: &AppState) -> Result<Option<User>, DbErr> {
        // query state.db
        Ok(None)
    }
}

Ok(None) means the session is stale and the extractor answers 401. Err means the database failed, which is a different thing.

absolute_max_age() is the maximum session lifetime measured from the login timestamp, regardless of activity. It defaults to 30 days. This is separate from the session layer’s sliding inactivity timeout — one bounds how long a session can live, the other how long it can idle.

Extractors

ExtractorBehaviour
Auth<U>the signed-in user, or a 401 rejection
OptionalAuth<U>Option<U>, never rejects
AuthManager<U>login, logout, session rotation
Sessionthe session store
Flashflash messages
CsrfTokenthe current CSRF token
pub async fn dashboard(auth: Auth<User>) -> Result<Response> {
    Ok(text(StatusCode::OK, format!("hello {}", auth.user().email)))
}

Logging in and out

pub async fn store(auth: AuthManager<User>, /* ... */) -> Result<Response> {
    let user = /* look the user up and verify the password */;
    auth.login(&user).await?;
    Ok(redirect().to("/dashboard").into_response())
}

pub async fn destroy(auth: AuthManager<User>) -> Result<Response> {
    auth.logout().await?;
    Ok(redirect().to("/").into_response())
}

login returns a LoginBuilder; awaiting it does the work. .remember(true) extends the session’s max age.

Awaiting the builder rotates the session ID by calling cycle_id before binding the user. This is mandatory and not opt-in: the anonymous-to-authenticated transition is exactly where a session-fixation attack would persist, so the ID always changes. You do not need to call regenerate() after login(). It is there for rotating outside login.

Awaiting also stamps the authentication time into the session, which is what absolute_max_age() is measured against.

logout flushes the whole session rather than removing the user key.

Passwords

Argon2id, from the argon2 crate. No Arcature-written cryptography.

use arcature::auth::{PasswordConfig, PasswordHasher, PasswordHashString, PasswordSecret,
                     RehashOutcome, verify_password};

let hasher = PasswordHasher::new(PasswordConfig::default())?;
let stored = hasher.hash(b"correct horse battery staple")?;

let parsed = PasswordHashString::new(&row.password_hash)?;
verify_password(b"attempt", &parsed)?;

if matches!(hasher.needs_rehash(&parsed), RehashOutcome::Rehash) {
    // parameters changed since this hash was written; rehash on next login
}

PasswordSecret wraps a plaintext password, PasswordHashString a PHC-formatted stored hash. Both are secrecy-backed: Debug and Display never expose the secret and the buffer zeroizes on drop. No plaintext password, signing key, or token appears in logs, error output, or a Debug line anywhere in the framework.

Sessions

Sessions are tower-sessions. Arcature owns the cookie attributes and the signed jar:

use std::time::Duration;
use arcature::auth::{SameSite, SessionConfig, SessionKey};

let key = SessionKey::generate()?;         // or ::from_bytes(&secret)
let config = SessionConfig::new(key.as_bytes())?
    .with_cookie_name("acme_session")
    .with_same_site(SameSite::Lax)
    .with_max_age(Duration::from_secs(60 * 60 * 2))
    .with_absolute_max_age(Duration::from_secs(60 * 60 * 24 * 7));

let layer = config.into_layer(store)?;

SessionConfig::dev(key) relaxes Secure for plain HTTP in development. arc key:generate produces a signing key.

The store is yours to choose: any tower_sessions::SessionStore. Behind session-store-db, DbSessionStore keeps sessions in the application’s own database, which is what stops a deploy from signing everybody out; the scaffold wires it. Swap it for MemoryStore in a test, or for anything else implementing the trait.

From a handler:

pub async fn handler(session: Session) -> Result<Response> {
    session.put("last_seen", 1_700_000_000i64).await?;
    let value: Option<i64> = session.get("last_seen").await?;
    let taken: Option<i64> = session.forget("last_seen").await?;
    session.regenerate().await?;
    session.flush().await?;
    Ok(no_content())
}

session.raw() borrows the underlying tower-sessions Session.

Flash writes one-shot messages read and cleared on the next request: flash.success(..), .error(..), .warning(..), .info(..), and flash.messages() to read them.

Sign-in flows

Everything above is a seam: hash a password, bind a user to a session, authorize an action. A sign-in screen is those seams plus a handful of small decisions where the obvious implementation is wrong in a way nothing tells you about. auth::flows, behind auth-flows and off by default, owns that handful and nothing else.

TypeWhat the naive version leaks
CredentialCheckerSkipping the Argon2 verification when the address is unknown turns response time into a working list of who has an account.
EmailVerificationA link bound to the account rather than to the address verifies whichever address the account holds when it is clicked.
LoginThrottleCounting failures per account misses the actual attack, which is one guess each against ten thousand accounts; counting them per account only also hands anybody a way to lock anybody else out.
PasswordConfirmationA boolean in the session says a password was proved at some point, never says when, and is inherited by whoever holds the session next.
PasswordResets (auth-reset)Checking “is this token valid?” and then deleting it is two statements with a gap, and two requests carrying the same link both pass the check.
RememberTokens (auth-remember)A cookie that does not rotate cannot tell a returning user from a stolen one.

Each type documents its own attack in full. This is the half of a sign-in form that is the same in every application, and the half where being wrong is silent — which together is the whole reason it is here rather than in yours.

Scaffolding the other half

The account table, the handlers, the routes and the HTML are the application’s. arc make:auth user writes all but the last:

FileHolds
app/auth/user.rsthe account: the model, plus AuthUser and UserLoader
app/auth/user_registration_controller.rssign-up
app/auth/user_session_controller.rssign-in and sign-out
app/auth/user_password_controller.rsforgotten-password and reset
app/auth/user_routes.rsuser_auth_routes()
database/migrations/m<stamp>_create_users.rsthe table

Every file is declared as it is written, so no pub mod line is left to add by hand. Four notes are, and they are the whole of what is not automatic: auth-flows and auth-reset are not in the feature list arc new writes; the migration is not in Migrator::migrations(); the reset table is not in that migration, because PasswordResets::new(pool).migrate() owns it; and the route collection is not merged into bootstrap/app.rs.

Headless throughout — six files and no screens. Reach for arc make:page or arc make:view for what the user actually looks at.

Authorization

Authorization is never automatic and never implied. Validation proves a request is well-formed; Bound<T> proves a row exists; neither says the user may act on it.

pub struct LinkPolicy;

impl arcature::Policy<Link> for LinkPolicy {
    type User = User;
    fn check(user: &User, action: &str, link: &Link) -> bool {
        match action {
            "view" => true,
            "update" => user.id == link.user_id,
            _ => false,
        }
    }
}

Call it through Auth::authorize:

pub async fn update(auth: Auth<User>, link: Bound<Link>) -> Result<Response> {
    let link = link.into_inner();
    auth.authorize::<Link, LinkPolicy>("update", &link)?;
    Ok(no_content())
}

Both type parameters are required. authorize is generic over the model M and the policy P, and Rust has no partial turbofish, so authorize::<LinkPolicy>(..) does not compile — the model comes first. (The doc comments on Auth::authorize and on the Policy trait show the one-parameter form; they are wrong.)

false becomes AuthzError::Forbidden.

CSRF

CsrfLayer enforces a naive double-submit token. Not signed, not session-bound: the server issues a random nonce in a cookie, the client echoes it in a header, and the server compares the two. The strength is in the cookie attributes, not in a signature.

What is exempt, by design:

  • Safe methods: GET, HEAD, OPTIONS, TRACE. These get a fresh cookie if the request did not carry one.
  • Bearer-token requests. An unsafe request carrying Authorization: Bearer … is forwarded without the check and without a CSRF cookie. A bearer token is not sent automatically by the browser, so there is nothing to forge.

Unsafe non-bearer methods — POST, PUT, PATCH, DELETE — must present a matching cookie and header, or the request is rejected with 403.

Three presets

PresetCookieHeaderSecureSameSite
CsrfConfig::new()__Host-csrfx-csrf-tokenyesStrict
CsrfConfig::dev()arcature-csrfx-csrf-tokennoStrict
CsrfConfig::inertia()XSRF-TOKENx-xsrf-tokenyesLax

new() is the strongest. The __Host- prefix mandates Secure, forbids Domain, and pins the path to / (RFC 6265bis), so a sibling subdomain cannot overwrite the cookie. SameSite=Strict keeps it off every cross-site request. HttpOnly is false on purpose: JavaScript has to read the cookie to put it in the header, and the header is the proof the page is same-origin.

Why an Inertia application uses inertia()

Inertia’s client is axios. Axios reads a cookie named XSRF-TOKEN and echoes it in X-XSRF-TOKEN. Both are hard-coded and neither is configurable without writing application JavaScript.

Against CsrfConfig::new(), an Inertia form is rejected with 403 until the application ships a shim that reads the token and reconfigures axios. That shim is exactly the framework-owned client package Arcature does not publish (see ADR 0001), so the server moves to meet the client instead.

Two attributes weaken, deliberately:

  • No __Host- prefix, because axios will not look for one. A sibling subdomain able to set cookies on the parent domain can then overwrite XSRF-TOKEN. That is a session-fixation-shaped attack on the nonce, not a way to read it, and it requires the attacker to already control a subdomain of your site.
  • SameSite=Lax rather than Strict. Strict withholds the cookie on any cross-site navigation — an OAuth callback, a link from an email — so the first page load after one arrives with no token at all. Lax sends it on top-level GET navigations, which is the case Strict breaks and not one CSRF exploits: a forged unsafe request is still cookie-less.

The full reasoning, including the cost, is ADR 0002.

Keeping new() and configuring axios yourself

If you would rather not weaken those two attributes, keep CsrfConfig::new() and tell axios where to look. This is application JavaScript, in your own codebase, not a framework package:

import axios from "axios";

axios.defaults.xsrfCookieName = "__Host-csrf";
axios.defaults.xsrfHeaderName = "X-CSRF-Token";

Both defaults are writable, so no interceptor is needed. The trade is one file you maintain against two cookie attributes you keep.

Overriding individual attributes

let config = CsrfConfig::new()
    .with_cookie_name("__Host-app-csrf")
    .with_header_name("x-app-csrf")
    .with_same_site(SameSite::Lax)
    .with_secure(true)?;

with_cookie_name auto-enables Secure when the name starts with __Host-, and with_secure(false) returns an error if the cookie name carries that prefix. The invalid combination is not representable.

What it does not defend against

Not XSS: same-origin script can read the cookie and send the header. Not anything the reverse proxy owns — TLS termination, rate limiting, request-size limits. It defends against forged cross-site unsafe requests from an authenticated browser, which is the attack it is named after.

What this module does not own

Your user model, roles, permissions, or account schema. Cryptography — Argon2, HMAC, SHA-2 and TLS come from RustCrypto, cookie, and the certified rustls plus aws-lc-rs path. The screens: auth::flows owns the decisions behind a sign-in form and arc make:auth writes the handlers, but nothing in this module renders a page.

Everything above assumes a browser holding a session. For a CLI, a CI job or another service, the api-tokens feature issues an opaque bearer credential instead: the plaintext is shown once, the database holds only a SHA-256 digest, and lookup is constant-time. Tokens carry abilities and an expiry.

It is deliberately independent of auth – an API with no passwords and no sessions may still hand out a token, and should not be made to compile a password hasher to do it. CSRF also steps aside for a request carrying Authorization: Bearer, because a bearer request is not a browser-driven one.

API tokens

An opaque bearer credential for a client that has no cookie and no session — a CLI, a CI job, a mobile app, another service. The client sends Authorization: Bearer <token> on every request. The framework turns that back into a record, or refuses the request.

Off by default:

arcature = { version = "0.1", features = ["api-tokens", "db-postgres"] }

api-tokens = ["database", "dep:sha2", "dep:subtle", "dep:zeroize"]. It is not part of fullstack. It brings a table and a migration, and an application that only serves a browser never needs one. No new crate enters the dependency graph: sha2, subtle and zeroize are already pulled in by session-store-db, crypt and signed-urls, and the randomness comes from getrandom, which is unconditional.

The examples below need a live database, so they are marked ignore: they are neither compiled nor run. no_run would compile them without running them, which is the stronger marker, but these name an application’s own account store and state type — neither of which exists in this crate — so there is nothing here for a compiler to check them against.

The property the design exists for

A token is two halves: a public 16-byte id and a secret 32-byte half. The row holds the id in the clear — it is a lookup key, not a credential — and the SHA-256 of the secret. The secret itself is never written anywhere.

From src/tokens/migrations/postgres/0001_api_tokens.sql:

CREATE TABLE IF NOT EXISTS arcature_api_tokens (
    id            BYTEA       PRIMARY KEY,
    secret_digest BYTEA       NOT NULL,
    tokenable_id  TEXT        NOT NULL,
    name          TEXT        NOT NULL,
    abilities     JSONB       NOT NULL,
    expires_at    TIMESTAMPTZ NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
)

There is a secret_digest column and no column that could hold the secret. Disclosure of the table is therefore not disclosure of credentials: a stolen backup, a read replica, or a reporting account with SELECT yields 32 bytes of digest per token and no way to authenticate as anybody. A unit test in src/tokens/migrate.rs asserts that every bundled migration contains secret_digest and contains neither secret_plaintext nor token TEXT, so a future migration that adds a plaintext column has to delete that test first. An integration test reads the raw columns back and asserts the stored bytes equal digest_of(secret) and not the secret.

Issuing

use arcature::tokens::{Abilities, ApiTokens, NewApiToken};
use std::time::Duration;

let tokens = ApiTokens::new(pool.clone());
tokens.migrate().await?;

let issued = tokens
    .issue(
        &NewApiToken::expiring_in("user:42", "CI deploy key", Duration::from_secs(3600))
            .abilities(Abilities::of(["deploy:write"])),
    )
    .await?;

// Show this once. There is no second chance.
println!("{}", issued.plaintext().expose());

ApiTokens::new takes the application’s own pool (arcature::tokens::TokenPool, which is arcature::database::Pool). There is no connect_lazy twin as there is on DbSessionStore: a token store is used from handlers, by which time the pool is in hand, and a second pool would be a second slice of the database’s connection budget for no reason.

issue returns an IssuedApiToken: token() is the stored record, plaintext() is the credential, into_parts() splits them. The plaintext exists exactly once, in memory, in that return value. Nothing — not this module, not a SELECT, not a backup — can produce it again. Losing it means issuing another.

PlaintextToken is built to make an accidental second copy hard:

PropertyWhy
no Clonea credential that is trivially copied is a credential with an unknown number of copies
Debug prints PlaintextToken([redacted])the common way a secret reaches a log file is a struct that derived Debug three types up
zeroized on dropthe plaintext does not linger in freed heap for a core dump or a later allocation to find
expose(), not as_str()every call site should read as a decision

The zeroize is best-effort, not a guarantee. Anything the caller copies the string into — a response body, a format argument, a String of its own — is outside the type’s reach.

The plaintext is arcpat_ followed by 32 hex characters of id, _, and 64 hex characters of secret: 104 characters in total. TOKEN_PREFIX is a public const so a secret scanner — a pre-commit hook, a CI step, a log pipeline — can recognise an Arcature token in a paste or a diff from one literal. The prefix is not a security control; it is what makes one possible.

Failures from issue:

ErrorCause
ApiTokenError::Entropythe OS randomness source was unavailable. No fallback is attempted: a token minted from a clock or a counter is not a secret
ApiTokenError::IdCollision { attempts: 8 }eight random 128-bit ids were all already taken, which in practice means the random source is broken. Reported rather than retried forever, because a loop would hide it
ApiTokenError::Databasethe insert failed

The insert is ON CONFLICT (id) DO NOTHING on PostgreSQL, INSERT IGNORE on MySQL, INSERT OR IGNORE on SQLite. A clash arrives as zero rows affected rather than as an error, which is what lets issue draw another id instead of parsing a driver-specific constraint name out of an error string.

The migration and the table

tokens.migrate() creates arcature_api_tokens and its indexes. It is idempotent and safe to run from every replica at once. Call it at startup; a store whose table is missing fails on the first request instead, which is the same outage discovered by a user.

The migration is embedded per dialect and applied under an arcature_api_tokens_schema_migrations history table. Statements are split on a line reading --;; and executed one at a time, because MySQL rejects multiple statements in a single prepared query unless the connection opted in.

DialectMigration lockNotes
PostgreSQLpg_advisory_lock(71420003)a key of its own; tests/advisory_locks.rs is the registry and fails if two subsystems claim the same number
MySQL 8GET_LOCK('arcature_api_tokens_migrate', 10)a lock name of its own, for the same reason
SQLitenoneit serialises writers itself, and every statement in the migration is IF NOT EXISTS or INSERT OR IGNORE

The lock is taken on one acquired connection for the whole run, not one per statement, because pg_advisory_lock and GET_LOCK are held by the session. The unlock is best-effort: if it fails the session is already broken, and reporting that instead of the migration’s own error would hide the reason the caller needs. On MySQL the wait is bounded at ten seconds and the result of GET_LOCK is not inspected, so a migrator that waited out the timeout proceeds anyway — which converges rather than corrupts, because every statement in the file is idempotent.

Storage differs where the dialects differ:

ColumnPostgreSQLMySQL 8SQLite
idBYTEABINARY(16)BLOB
secret_digestBYTEABINARY(32)BLOB
tokenable_id, nameTEXTVARCHAR(191)TEXT
abilitiesJSONBJSONTEXT
expires_at, created_atTIMESTAMPTZDATETIME(6) in UTCINTEGER epoch milliseconds

sqlx::types::Json covers all three abilities columns, so the store has one code path. SQLite has no timestamp type: text timestamps compare correctly only while every writer agrees on the format down to the digit, and integers always do. The cost is that SQLite drops sub-millisecond precision on a round trip. MySQL compares against UTC_TIMESTAMP(6) rather than NOW(), so a connection set to a different session time zone cannot move an expiry.

Two indexes: tokenable_id for listing and mass revocation, expires_at for the sweep.

The extractor

ApiAuth reads the header, splits the credential, hashes the secret, and compares it against the stored digest. The route body runs only if a live token matched. The store reaches the extractor through an axum Extension, so an application installs it once on the router:

use arcature::axum::{Extension, Router, http::StatusCode, routing::get};
use arcature::tokens::{ApiAuth, ApiTokens};

async fn deploy(ApiAuth(token): ApiAuth) -> Result<String, StatusCode> {
    // Authentication says who; the ability says what.
    if !token.can("deploy:write") {
        return Err(StatusCode::FORBIDDEN);
    }
    Ok(format!("deploying for {}", token.tokenable_id()))
}

fn routes(tokens: ApiTokens) -> Router {
    Router::new()
        .route("/deploy", get(deploy))
        .layer(Extension(tokens))
}

ApiAuth is a tuple struct over ApiToken, so destructuring it in the argument list is the usual spelling. ApiAuth::token() and ApiAuth::can() are there when it is bound whole.

What the extractor answers:

ConditionResponse
no Authorization header401, WWW-Authenticate: Bearer, body Authentication required
a scheme other than bearer (matched case-insensitively)the same 401
Bearer with nothing after itthe same 401
a header value that is not UTF-8the same 401
a credential that is not the shape this module mintsthe same 401, with no query made
unknown id, wrong secret, or expired tokenthe same 401
the store was never installed as an Extension500, body API tokens are not configured
the database refused the query503

Every authentication failure is the same response with the same body. A client that can tell them apart is being told about tokens it does not hold.

The two non-401 rows are deliberate. A missing Extension is a wiring mistake, and answering 401 would send a correct client away to mint a token that would fail in exactly the same way. A database that is down is the server’s problem, and 401 would tell an honest client its credential was bad.

401 and not 403 for a missing token: 401 means “authenticate and try again” and obliges the response to name a scheme, which this one does. 403 is the answer a route gives after ApiToken::can returns false, as the example above does.

Why SHA-256 and not Argon2

This looks like the password path and is not, and the difference decides the algorithm.

A password hash is slow on purpose because a password is low entropy. Users pick from a distribution an attacker can enumerate — a wordlist, a leaked corpus, a few billion candidates — so the only defence a stolen hash has is that each guess costs real time and memory. Argon2 buys that time. It is the right call in auth::password, and it stays the right call there.

A token secret is 32 bytes straight from the OS CSPRNG: 256 bits of uniform randomness, with no distribution to guess from. Enumerating it is not expensive, it is impossible. Multiplying an impossible search by Argon2’s cost factor leaves it impossible. The slow hash buys nothing, because the entropy already did the work a slow hash exists to do.

The cost, meanwhile, is real and lands on every request. A token is presented on each API call, so verification is on the hot path in a way a login never is. Argon2’s default parameters are tens of milliseconds and tens of megabytes, tuned so that an attacker’s GPU farm is slow. Put that in front of every request and the tuning applies to the server: a client with a valid token and a loop becomes a memory-hard workload generator. Rate limiting does not save it, because the work happens before the request is known to be abusive. Choosing Argon2 here would not harden the token; it would hand anyone holding one a denial-of-service primitive against the application.

So: a single SHA-256, with no length-extension risk in this construction (the input is a fixed 32 bytes and the digest is never a prefix of a longer authenticated message). The reasoning generalises — hash slowly what humans chose, hash quickly what the CSPRNG chose — and it is why DbSessionStore stores a SHA-256 of a session id as well. The full argument lives at the hashing site, digest_of in src/tokens/store.rs.

The comparison is constant-time

From ApiTokens::authenticate in src/tokens/store.rs:

let stored: Vec<u8> = row.try_get(0)?;
let matches: bool = presented_digest.ct_eq(stored.as_slice()).into();

subtle::ConstantTimeEq reads every byte every time. A == would return at the first differing byte. A few hundred nanoseconds, averaged over enough requests, is enough to recover a digest one byte at a time — thirty-two rounds of two hundred and fifty-six guesses, instead of a search of 2^256.

Two more details of that path. The presented secret is hashed before the query, not after, so an unknown id and a known id with a wrong secret follow the same code and differ by one constant-time comparison. And the digest is selected by exactly one statement: FIND and AUTHENTICATE are identical except that the second also selects secret_digest, which makes every other read structurally incapable of loading a digest into memory. An integration test asserts that against the column names the server reports, not against the statement text, because the statement text is what a mistake would be written in.

What remains observable is whatever the database leaks by finding a row versus not finding one. That residue has the same shape as the account-enumeration oracle a login form must not have, and it is acceptable here for a reason worth stating: an email address is public and the password behind it is often guessable, so “this account exists” is a real step forward, whereas a token id is 128 random bits and the secret behind it is 256 more. Learning that some id exists costs the same 128-bit search either way.

authenticate returns Ok(None) for every authentication failure — malformed, unknown id, wrong secret, expired — and an Err only when the database fails or a row does not hold what the schema promises.

Abilities

A token carries a set of opaque strings the application chooses. Matching is exact.

use arcature::tokens::Abilities;

let scoped = Abilities::of(["posts:read", "posts:write"]);
assert!(scoped.contains("posts:read"));
assert!(!scoped.contains("billing:write"));

// The one wildcard, and the only one.
assert!(Abilities::all().contains("anything at all"));

// No prefix matching: `posts:*` grants `posts:*` and nothing else.
assert!(!Abilities::of(["posts:*"]).contains("posts:read"));
ConstructorResult
Abilities::none()grants nothing. This is Default, and the default on a NewApiToken
Abilities::of(iter)grants exactly those strings
Abilities::all()the reserved "*" (Abilities::ALL), which matches every ability
.with(ability)one more, by value

"posts:*" is a legal ability string and it grants the ability spelled "posts:*". A wildcard grammar here would mean every application’s authorization decisions depend on this module’s pattern matcher agreeing with the application’s intuition, which is not a bet worth taking in an authorization path.

The default is closed. A token minted without naming an ability can do nothing, and ApiToken::can returns false for everything. The only permissive setting is one somebody typed: Abilities::all().

Abilities are not checked by the extractor. ApiAuth proves the token is live; the route calls can. There is no ability extractor, because the check a route needs is a line of Rust and a second extractor would be a less readable way to write it.

Expiry

Every token has a deadline. NewApiToken has no constructor that omits one, and the column has no null state to hold one. A credential that outlives the reason it was minted is the ordinary way a leak stays useful, so “forever” has to be typed out as a date somebody chose.

use arcature::tokens::NewApiToken;
use chrono::{Duration, Utc};

// Spell the deadline...
let explicit = NewApiToken::new("user:42", "laptop", Utc::now() + Duration::days(30));

// ...or the time to live.
let ttl = NewApiToken::expiring_in("user:42", "CI", std::time::Duration::from_secs(3600));

A ttl too large for the calendar saturates at the furthest instant chrono can represent rather than wrapping into the past, because wrapping would mint a token that is already dead.

Every read carries expires_at > now(), evaluated by the database, in FIND, AUTHENTICATE and LIST_FOR alike. Three consequences:

  • An expired token stops working the instant it expires, whether or not any sweep has run.
  • The clock that decides is the database server’s, not the reader’s. A token does not outlive its expiry by however far one web node’s clock is fast.
  • An ApiToken that came back from a query is live by construction. expires_at() on it is in the future as of the moment of the read.

Revocation and sweeping

let revoked: bool = tokens.revoke(id).await?;             // one token
let count: u64 = tokens.revoke_all_for("user:42").await?; // sign out everywhere
let reclaimed: u64 = tokens.sweep_expired().await?;       // disk, not security

Revocation is a DELETE, not a flag. A revoked row that is still in the table is a row some future query can forget to filter; a row that is gone cannot authenticate anybody by accident. revoke reports whether there was a token to revoke; revoke_all_for reports how many went. The second is the “the laptop was stolen” path, and the one to call when a password changes.

sweep_expired deletes rows whose expiry has passed and reports how many. It reclaims disk. It is not what makes expiry correct — the predicate in every read already does that — so a deployment that never calls it is secure and merely wasteful. Nothing calls it for you; see the limits below.

Reading, for a token-management screen:

for token in tokens.list_for("user:42").await? {
    println!("{} ({}) expires {}", token.name(), token.id(), token.expires_at());
}

list_for returns every live token for one subject, newest first, ties broken by id (ORDER BY created_at DESC, id). find(id) reads one. Neither can return a plaintext, and neither loads a digest. ApiTokenId is safe to show, to log, and to accept from a revocation request — it travels in the header in the clear next to the secret, and it is not a credential. It parses from and prints as 32 lowercase hex characters, via ApiTokenId::from_hex and to_hex.

Independent of auth, on purpose

api-tokens implies database and nothing else. It does not imply auth.

An API with no passwords and no sessions may still hand out a token. Making it compile a password hasher and a session layer to get one would be a packaging decision pretending to be a security one. database is required because a revocable credential has to live somewhere revocable.

The two sides do not know about each other. tokenable_id is a String in the application’s own spelling — a user id, a tenant id, a service name — so this module never has an opinion about the shape of an application’s primary key, and never joins to a users table.

CSRF steps aside for a bearer request

CsrfLayer exempts any unsafe request carrying an Authorization: Bearer header: no double-submit check, and no CSRF cookie injected on the way out. The decision is made by is_bearer_request in src/auth/csrf.rs, which takes the bytes up to the first ASCII whitespace and compares them case-insensitively against bearer. Basic is not exempt, and a request with no Authorization header is not exempt.

The reason is that a bearer request is not browser-driven. Double-submit defends a cookie-authenticated browser against a forged cross-site request; a request that authenticates with a header the application handed to a CLI is not that request, and there is no cookie for a forged one to ride on.

Two notes about the shape of the exemption. It is keyed on the scheme alone, so it is granted before any token is validated — the request is then still rejected by ApiAuth unless it carries a live token, so the exemption skips CSRF, not authentication. And a route protected only by a session cookie gains nothing from a client that also sends a bearer header; a route that means to accept tokens should read ApiAuth.

What this does not do

No OAuth. No authorization server, no grant types, no consent screen, no client registration, no refresh endpoint, no discovery document, and no scope parameter. Abilities are the application’s own strings and mean whatever the application decides. The separate oauth feature is an OAuth 2 client — Authorization Code with PKCE against somebody else’s provider — and shares no code with this module.

No refresh tokens, and no rotation. The store’s only writes are issue, revoke, revoke_all_for and sweep_expired. There is no method that modifies a row, so a token’s expiry cannot be extended and its abilities cannot be edited. Both mean issuing a new token and revoking the old one.

No usage tracking. The table has no last_used_at and no counter, and authenticate performs no write. A token screen cannot show “last used three days ago”.

No caching, and no rate limiting. Every authenticated request is one SELECT. Nothing counts or throttles failed presentations the way auth::flows throttles failed logins. The 256-bit secret is the whole defence against guessing, which is the same reason the digest is fast.

No layer, and no framework wiring. ApiAuth is a per-route extractor. There is no ApiTokenLayer that protects a router subtree, and no Application::tokens(..) helper of the kind Application::storage(config) is. Install the store with Extension yourself.

No history. Revocation is a DELETE and every read filters on expiry, so there is no way to list expired or revoked tokens, and no audit record of who revoked what.

No scheduled sweep. sweep_expired runs when the application calls it. Nothing in the framework calls it, and there is no arc subcommand for tokens the way there is arc queue work. Wire it into a jobs schedule if you want one.

No transaction variants. Unlike the job queue’s enqueue_tx and migrate_tx, every method here runs on the pool. A token cannot be issued inside a transaction you already hold.

No serde. ApiToken derives Clone and Debug only — not even PartialEq, so two tokens cannot be compared with ==. ApiTokenId and Abilities add comparison traits. None of the three derives Serialize. Rendering a token list into an Inertia prop or a JSON body means a struct of the application’s own.

No relationship to a user row. tokenable_id has no foreign key and no cascade in any dialect, so deleting a user does not delete their tokens. Call revoke_all_for.

No limits on quantity, names, or length. A subject may hold any number of tokens, two of them may share a name, and list_for returns all of them in one Vec with no pagination. Nothing validates the length of tokenable_id or name; on MySQL both columns are VARCHAR(191) and longer values do not fit.

No transport check. Nothing here verifies that the request arrived over TLS. A bearer token is a password in a header, and the reverse-proxy front door owns TLS termination, as it does for the rest of the framework.

Encryption and signed URLs

Two things an application does with its own secret. Encrypter turns bytes into an opaque token it can hand out and get back. UrlSigner mints a link that proves the application issued it and, optionally, carries its own deadline.

Both hang off one secret, APP_KEY. Neither is on by default.

Two features, not one

They are separate features because they cost different things. Signing needs a MAC; encrypting needs a cipher. An application that only hands out one-hour download links has no reason to pull an AEAD into its graph, and an application that only encrypts has no reason to carry a URL parser.

Neither crypt nor signed-urls is in default, and neither is in fullstack. Turning one on is a decision written into the manifest: the moment a build can produce ciphertext is the moment somebody owns a key rotation story.

arcature = { version = "0.1", features = ["crypt", "signed-urls"] }
FeatureGives youPulls
cryptEncrypter, EncryptError, DecryptErrorchacha20poly1305, secrecy, zeroize, hmac, sha2
signed-urlsUrlSigner, Clock, SystemClock, SignedUrlErrorsecrecy, zeroize, hmac, sha2, subtle, percent-encoding

AppKey and AppKeyError are exported when either feature is on. The module arcature::crypt does not exist when both are off; the whole surface is also re-exported at the crate root (arcature::Encrypter, arcature::UrlSigner).

Why each crate is there:

CrateLoad it carries
chacha20poly1305the AEAD behind Encrypter, in its X variant
hmac + sha2subkey derivation from APP_KEY, and the MAC UrlSigner computes
subtleConstantTimeEq on the presented signature — see below
percent-encodingescaping and unescaping query components
secrecy + zeroizekey material that redacts in Debug and wipes on drop

There is no dep:subtle under crypt. The only comparison that feature makes is the AEAD’s own tag check, which chacha20poly1305 already does in constant time.

auth-flows implies signed-urls, because an email-verification link is a signed URL with an address binding on top. A build with auth-flows, auth-reset or auth-remember already has UrlSigner.

The application key

APP_KEY is 64 bytes, written into .env as 128 lowercase hexadecimal characters. It is the same material arcature::auth::SessionKey holds. There is deliberately one secret per deployment: one thing to rotate, one thing to store, one thing to keep out of a repository.

$ arc key:generate
APP_KEY written to .env

$ arc key:generate --show
APP_KEY=<128 lowercase hexadecimal characters>
BehaviourDetail
source64 bytes from the OS RNG, via SessionKey::generate
encoding128 lowercase hex characters, not base64 — hex has no alphabet variants for a .env parser to get wrong, and length alone says whether the value is intact
default actionrewrite the existing APP_KEY= line in .env, or append one
re-runningreplaces rather than appends, so a .env never ends up with three keys and a loader picking one
--showprints and touches nothing, which is what a pipeline wants when the secret belongs in a secret store
no .envan error naming the path, suggesting --show
RNG failurean error; no key is generated

The command is gated on the auth feature, not on crypt or signed-urls, because the key type and the certified RNG behind it live in the auth module. auth is in default, so a generated application has the command. A build that turns default features off and asks for crypt alone gets AppKey and no way to mint one from the CLI — produce 64 bytes from the OS RNG elsewhere and pass them to AppKey::from_bytes.

Reading the key

use arcature::crypt::AppKey;

let key = AppKey::from_hex(&arcature::config::env_required("APP_KEY")?)?;

AppKey::from_bytes(&[u8]) is the other constructor and takes exactly 64 bytes.

from_hex trims surrounding whitespace and accepts either case. The generator writes lowercase, but a value that has been through a secret store and back may not have stayed that way, and rejecting an unambiguous key on its capitalisation would be a fault report with no fault behind it.

The hex decoder is written out rather than built on u8::from_str_radix, which accepts a leading sign: with it, a 128-character string of +4 decodes to 64 valid bytes. A decoder for a secret should accept exactly one spelling of it, so +4, -4 and 4 are all refused, and a non-ASCII character is refused rather than sliced mid-codepoint.

AppKeyErrorMeans
Emptynothing but whitespace
NotHexadecimala character outside 0-9a-fA-F, or an odd number of digits
WrongLengthdecoded, but not to 64 bytes

Every variant is about the value’s shape, never its content, so an error that reaches a log carries no secret with it. Every Display ends with the same instruction: run arc key:generate to write a valid one into .env.

Nothing uses those 64 bytes directly

APP_KEY is never handed to a cipher or a MAC. Each consumer gets its own 32-byte subkey:

subkey(label) = HMAC-SHA256(APP_KEY, "arcature/kdf/v1" || len(label) || label)

len(label) is a big-endian u64. It is there so no two labels can produce the same input: without it, "ab" then "c" and "a" then "bc" are one string, and a future label could collide with a present one.

LabelConsumer
encrypterEncrypter
url-signerUrlSigner

Sharing one key across two algorithms is how a weakness in either becomes a weakness in both, and how a chosen-ciphertext oracle in one becomes a forgery in the other. Recovering the signing key does not hand anybody the ability to decrypt.

The derivation is a single PRF call rather than a full HKDF because there is nothing to extract from: APP_KEY is already 64 uniformly random bytes from the OS RNG, which is exactly what HKDF’s expand step wants as input. It is also exactly one HMAC block wide, so it is used as an HMAC key without being pre-hashed.

AppKey::subkey is pub(crate). A caller who can name a label can make two subsystems share a key, which is the mistake the derivation exists to prevent. Labels are constants in the module.

The bytes live in a secrecy::SecretSlice, which zeroizes on drop, and there is no accessor that hands the key out. Debug prints AppKey(<redacted 64-byte key>).

Encrypter

For values an application has to hand out and get back: a token in a link, an opaque cursor, a payload in a queue an operator can read.

use arcature::crypt::{AppKey, Encrypter};

let key = AppKey::from_hex(&arcature::config::env_required("APP_KEY")?)?;
let encrypter = Encrypter::new(&key);

let token = encrypter.encrypt_string("order 4417")?;
assert!(token.starts_with("v1."));
assert_eq!(encrypter.decrypt_string(&token)?, "order 4417");
MethodSignature
Encrypter::new(&AppKey)derives the encrypter subkey
encrypt(&[u8])Result<String, EncryptError>
encrypt_string(&str)Result<String, EncryptError>
decrypt(&str)Result<Vec<u8>, DecryptError>
decrypt_string(&str)Result<String, DecryptError>

new is cheap enough to call per request, though an application will normally build one at startup and keep it in state. Debug prints Encrypter(XChaCha20-Poly1305, <redacted key>).

Why XChaCha20-Poly1305

Every message gets a fresh 192-bit nonce from the OS RNG. That width is the whole reason for the X variant: at 192 bits a random draw per message is safe for any number of messages, so there is no counter for a caller to manage and no way for a caller to reuse one.

AES-GCM’s nonce is 96 bits. Random nonces there have a birthday bound a busy application can actually reach, and a repeat under GCM does not merely leak plaintext — it leaks the authentication subkey, so the attacker gains forgery as well. The alternative to a 192-bit nonce is asking every caller to own a counter that must never repeat across restarts, replicas and rollbacks.

The nonce comes from getrandom, which this crate depends on unconditionally, and an RNG failure is EncryptError::Rng rather than a fallback: a nonce from anything but the OS RNG is not a nonce.

Why not ring or aws-lc-rs

chacha20poly1305 is RustCrypto, pure Rust, compiled here with default-features = false (alloc for the Vec output, zeroize to wipe the key schedule on drop). ring and aws-lc-rs both carry C and assembly.

This is not a claim that no C runs in the process. A default build already links aws-lc-rs: sqlx selects tls-rustls-aws-lc-rs and lettre selects aws-lc-rs, both for TLS. The narrower decision is about which bytes reach it. A token pulled out of a query string or a cookie is attacker-authored input arriving at a parser, and this module’s threat model is that such bytes are handled by pure Rust with unsafe_code = "forbid" above them.

The token format

v1.<base64url( nonce || ciphertext || tag )>
PartSize
version tagthe literal v1.
nonce24 bytes
tag16 bytes (Poly1305)
ciphertextthe plaintext’s length

So a token is ceil((n + 40) * 4 / 3) + 3 characters for an n-byte plaintext. The body is unpadded base64url, so a token is safe in a URL path, a query value, a cookie and a JSON string with no further escaping.

Associated data is the fixed string arcature/crypt/v1. It is not secret and it is not the message; it is a statement the tag covers. Binding the version means a v1 token cannot be relabelled as a v2 one, so a future version that weakens something cannot be reached by editing four characters of an existing token.

Encryption is randomised. The same plaintext encrypted twice gives two different tokens, by design. An empty plaintext round-trips.

It fails closed

A token whose bytes have changed returns no plaintext at all. Which error depends on where: the version tag is stripped before anything else, so altering it gives DecryptError::UnknownVersion, and a body that is not base64url gives Malformed. Everything the cipher actually sees — nonce, ciphertext, tag — gives DecryptError::Authentication. There is no partial result and no “decrypted but unverified” path, because a caller holding attacker-chosen bytes that look like plaintext is the failure mode an AEAD exists to prevent.

EncryptErrorMeans
Rngthe OS RNG failed; nothing was encrypted
Oversizedthe plaintext exceeds what the cipher can address; not reachable on a 64-bit target
DecryptErrorMeans
UnknownVersionno version tag this build reads — not an Arcature token, or minted by a newer release
Malformednot unpadded base64url, or shorter than a nonce plus a tag (40 bytes)
Authenticationthe tag did not match: altered, or encrypted under a different key
NotUtf8authenticated, but the plaintext is not UTF-8. Only from decrypt_string, and not reachable by an attacker — it means encrypt was given bytes and decrypt_string was used to read them back

The variants distinguish shapes of failure so an application can tell a stale link from an attack in its own logs. None of them is a near-miss: every one means no plaintext was produced.

Not a password store, not a database column

Passwords go through arcature::auth::PasswordHasher. Hashing is one-way and encryption is not, so a stolen APP_KEY turns an encrypted password table into a plaintext one.

Encrypting a column you then want to query is also a trap. Two encryptions of one value differ, by design, so WHERE email = ? finds nothing.

UrlSigner

A signed URL is the answer to “let this one person fetch this one thing, without giving them an account and without leaving the door open”. The link is self-contained: nothing is written down when it is issued and nothing is looked up when it is presented, so it costs a MAC to make and a MAC to check.

use std::time::Duration;
use arcature::config::AppConfig;
use arcature::crypt::{AppKey, UrlSigner};

let key = AppKey::from_hex(&arcature::config::env_required("APP_KEY")?)?;
let config = AppConfig::new().url("https://example.com");
let signer = UrlSigner::new(&key, &config);

// Expires in an hour.
let url = signer.sign_temporary("/invoices/9", &[("as", "pdf")], Duration::from_secs(3600))?;
// https://example.com/invoices/9?as=pdf&expires=<unix seconds>&signature=v1.<base64url>

signer.verify(&url)?;
MethodBehaviour
UrlSigner::new(&AppKey, &AppConfig)derives the url-signer subkey, roots links at config.base_url()
.with_clock(Arc<dyn Clock>)replaces the clock expiry is measured against
.sign(path, params)a link with no deadline
.sign_temporary(path, params, valid_for)a link that stops verifying valid_for from now
.verify(url)Result<(), SignedUrlError>

params is &[(&str, &str)]. Debug prints the base URL and <redacted> in place of the key.

Defaults

ThingDefault
base URLAppConfig::base_url(), which is http://localhost:3000 on a fresh AppConfig::new() and drops any trailing slash
clockSystemClock, the wall clock
deadline from signnone at all — a URL signed with sign verifies forever
deadline from sign_temporarywhatever you pass; nothing caps it
MACHMAC-SHA256, untruncated, all 32 bytes

The base URL comes from APP_URL rather than from a request’s Host header because a signed link is usually built with no request in scope — it goes in an email, or in a job’s output — and behind a reverse proxy that header is not authoritative anyway.

sign is there for a link whose validity is a property of the target rather than of time: an unsubscribe link, say, which stops working when the subscription does. Prefer sign_temporary whenever a deadline makes sense. A permanent signed URL is a bearer token with no end date.

The expiry is inside the signature

sign_temporary writes the deadline into the expires query parameter as a Unix timestamp in seconds, and the MAC covers it along with the path and every other parameter. Moving the expiry forward changes the signed material, so the edited link returns Mismatch. An expiry outside the signature would make the whole feature decorative.

A URL is valid up to and including its expiry second, and invalid from the next one. With a signer frozen at second 1000 and a 60-second window: 1059 verifies, 1060 verifies, 1061 is Expired.

Clock is a trait (fn now_unix(&self) -> u64, Send + Sync + 'static) for one reason: a test for “the link stops working after an hour” written against the real clock either sleeps for an hour or proves nothing.

use std::sync::Arc;
use std::time::Duration;
use arcature::crypt::{Clock, SignedUrlError, UrlSigner};

struct Frozen(u64);
impl Clock for Frozen {
    fn now_unix(&self) -> u64 { self.0 }
}

let at = |second| UrlSigner::new(&key, &config).with_clock(Arc::new(Frozen(second)));

let url = at(100).sign_temporary("/download/42", &[], Duration::from_secs(30))?;
assert_eq!(at(130).verify(&url), Ok(()));
assert_eq!(at(131).verify(&url), Err(SignedUrlError::Expired));

SystemClock reads a pre-epoch wall clock as 0, and that fails open, not closed. The expiry check is if self.clock.now_unix() > expires_at, so a clock reporting 0 is never past any deadline and every expired link is accepted. A machine whose clock has fallen behind the epoch honours links forever rather than refusing them.

This is a real if unlikely failure mode — it needs a system clock set before 1970, which in practice means a dead RTC battery or a deliberately wound-back container. It is written down rather than fixed because the fix is a decision about what an application should do when it cannot tell the time, and that is not the signer’s call to make. If it matters to you, supply your own Clock that refuses rather than returning a sentinel.

Constant-time comparison

verify compares the presented MAC with the expected one using subtle::ConstantTimeEq, never ==:

if !bool::from(signature.as_slice().ct_eq(&expected)) {
    return Err(SignedUrlError::Mismatch);
}

A byte-by-byte comparison that returns at the first difference is a timing oracle. An attacker who can measure it recovers a valid signature one byte at a time — roughly 8,000 guesses instead of 2^256. ConstantTimeEq reads every byte every time, and it is a dependency rather than a five-line loop precisely because its job is to be the thing the optimiser is not allowed to turn back into an early return. There is no == on a signature anywhere in the crate.

The signature is checked before the expiry, so a tampered URL reports tampering whatever its deadline claims. Reading the expiry only after the MAC matched is what makes the parsed timestamp trustworthy.

What is signed, and how it is canonicalised

The MAC input is, in order: the domain separator arcature/signed-url/v1; the path, length-prefixed; the parameter count as a big-endian u64; then each parameter’s name and value, length-prefixed, sorted by name and then by value.

Length prefixes are load-bearing. Without them, a=bc and ab=c feed the MAC one identical string, and an attacker who can move a character across the boundary has a forgery for free.

Sorting is what lets a reordered query still verify. Parameters are compared after percent-decoding, so a link a mail client or a redirect has re-escaped still verifies; a link that has been edited does not.

On the way out, everything outside RFC 3986’s unreserved set (A-Za-z0-9-._~) is percent-encoded — including &, =, +, %, #, space and every non-ASCII byte — so a parameter value can never introduce a parameter. A space becomes %20 and never +: + is an application/x-www-form-urlencoded convention, not a URL one, and a verifier that undid it would decode a literal + in a signed value into something that was never signed.

A path is canonicalised to exactly one leading slash, so "reports/7" and "/reports/7" sign the same thing.

What verify accepts and rejects

url may be absolute, as sign returns it, or the path-and-query a request carries.

InputResult
the absolute URL as mintedverifies
the same, stripped to /path?queryverifies
the same with #anything appendedverifies — a fragment is never sent to a server, so it is not signed and not checked
the query reorderedverifies
any parameter editedMismatch
https://example.com.evil/...ForeignOrigin, decided before any MAC is computed
a relative URL not starting with /ForeignOrigin
no signature parameterMissingSignature
two signature parametersMalformed
a signature with no v1. tagUnknownSignatureVersion
a signature that is not base64urlMalformed
an unreadable percent-escapeMalformed
genuine, past its deadlineExpired
genuine, no expires at allverifies, at any time

The origin is not in the MAC because it does not have to be: the URL must sit under the configured APP_URL, which is checked first. The prefix check carries an explicit guard — the remainder must be empty or start with / or ? — because without it https://example.com is a prefix of https://example.com.evil/x. The comparison is a literal string prefix, so it is sensitive to scheme, host case and port.

Two reserved names cannot be passed as parameters, because a caller that could set them could contradict the signer:

Signing inputResult
a parameter named signature or expiresReservedParameter
a path containing ? or #QueryInPath — pass the query as params so it is canonicalised and signed rather than appended unsigned

Mismatch and Expired are worth logging apart. The first is somebody editing a link; the second is somebody using a link too late. An application that treats them alike cannot tell an attack from a slow reader.

The base64url decoder is written in-crate

Both formats encode with an unpadded base64url (RFC 4648 section 5) written inside the crate rather than pulled from a dependency. It is pub(crate): there is one implementation, not two, because a second decoder is a second place for a padding bug that makes two spellings of one token both valid.

Two properties are wanted that a general-purpose encoder does not promise.

The alphabet has to stay stable forever, because it is baked into every token the module has ever issued and a token outlives the release that minted it.

The decoder has to be strict. It rejects:

InputWhy
padding (Zg==)the encoder never writes it
any character outside A-Za-z0-9-_including the standard alphabet’s + and /
a length congruent to 1 modulo 4 (Z, Zm9vY)no byte string encodes to that length
non-canonical trailing bits (Zh, Zm9)the unused low bits of a final group are padding the encoder wrote as zero

The result is that a byte string has exactly one spelling. Zg decodes to 0x66; Zh carries the same byte in its top eight bits and is refused. A lax decoder gives an attacker a family of distinct strings that decode alike, which is how a token revoked by string comparison comes back to life under another name.

Sixty lines of table lookup is a smaller thing to own than a dependency whose behaviour on malformed input is a version-to-version decision. The same reasoning produced the hand-written hex in arc key:generate and in AppKey::from_hex.

Everything carries a version

ValueVersion tag
an encrypted tokenthe leading v1.
a URL signaturev1. inside the signature parameter value, not a parameter of its own
the key schedulearcature/kdf/v1, the derivation domain

Replacing an algorithm later is therefore additive: the new reader keeps the old branch, tokens and links already in flight keep working, and nothing has to be re-issued during a deploy. A format with no version can only ever be changed by breaking every holder of an outstanding token at once.

The signature version rides inside the parameter value so a future format is a change to one string a verifier already parses, rather than a new parameter every old verifier ignores.

Changing the derivation domain would be a different matter: it changes every subkey, and so invalidates every token and every signature in flight. A v2 there would be introduced alongside v1, not in place of it.

What is not here

No middleware and no extractor. verify is a call a handler makes; nothing in the routing layer checks a signature for you, and there is no SignedUrl-shaped extractor that rejects before your code runs.

No key store, no key registry, no key identifier in either format. No JWT, no PASETO, no public-key signature: both formats are this crate’s own and both are symmetric.

No storage of any kind. Signing writes no row and verification reads none.

Limits

Key rotation is the application’s problem

There is exactly one APP_KEY, one subkey per label, and no key identifier anywhere in a token or a signature. A verifier tries one key, and that key is whatever the process was started with.

What that means concretely, the day APP_KEY changes:

Outstanding thingOutcome after rotation
every encrypted tokenDecryptError::Authentication — indistinguishable, by design, from a forgery
every signed URLSignedUrlError::Mismatch
every signed session cookieinvalid, because SessionKey is the same material

Nothing in the module reads a second key, so there is no overlap window to configure. If you need one, it is yours to build: keep the old AppKey alongside the new one, try the new one first, fall back to the old, and stop falling back once the longest deadline you ever minted has passed. The module gives you the pieces for that — AppKey::from_bytes, and an Encrypter or UrlSigner per key — and none of the policy.

The corollary is that a deadline you mint is a commitment. A permanent signed URL from sign outlives every rotation plan you have.

A signed URL is a bearer token in a query string. Presenting it is the whole of proving you may have it, and presenting it twice works exactly as well as presenting it once. Nothing is recorded at signing time and nothing is consulted at verification time — that statelessness is what makes the link cost one MAC, and it is also what makes it replayable.

It will end up in browser history, in Referer headers, in access logs and in whatever archived the email. Within its window, anybody who reads it in any of those places can use it.

Two things follow.

Give a link the shortest lifetime the use allows, and do not sign an action a replay would make worse. A download link is a reasonable thing to sign; “close this account” is not.

If a link must be single-use, the application spends it. Record something when the link is redeemed — a nonce parameter marked used, a row’s state moved on, a version column bumped — and check that record after verify returns Ok. verify answers “did we issue this, and is it still in date”. It cannot answer “has this already been used”, because it never learns that anything was.

For the email-verification case the framework does part of this for you: the auth-flows link binds the address it was minted for, so a link is refused once the account’s address changes. That is a binding, not a spend counter.

An encrypted token has no deadline and no context

Encrypter is not UrlSigner. A token has no expiry field, and nothing in the module ever refuses one for being old. A token minted today decrypts in a year under the same key. If a deadline matters, put a timestamp in the plaintext and check it after decrypting.

The associated data is the constant arcature/crypt/v1. There is no parameter for caller-supplied context, so a token minted at one call site decrypts cleanly at another. If two purposes in one application must not accept each other’s tokens, put the purpose in the plaintext and check it after decrypting.

The path is signed as literal bytes

sign rejects ? and # in a path and otherwise passes it through unchanged: the path is not percent-encoded on the way out, and verify does not percent-decode it on the way in. Both sides MAC the same literal string, with leading slashes collapsed to one.

So anything that rewrites the path in transit — a client that escapes a space, a proxy that resolves a .. segment, a router that normalises a trailing slash — produces Mismatch. That fails in the safe direction, but it fails. Build signed paths out of characters that survive a round trip, and put anything else in a parameter, where it is escaped and unescaped for you.

What this module does not own

No cipher implementation, no MAC implementation, no hash. chacha20poly1305 owns the AEAD, hmac and sha2 own the MAC, subtle owns the constant-time comparison, percent-encoding owns the escaping, secrecy and zeroize own the key handling. All of them are pure Rust with no C and no assembly, and the crate compiles under unsafe_code = "forbid".

What the module owns is the composition: the key schedule, the two token formats, the canonical form the MAC is computed over, and the strict decoder that gives every token exactly one spelling.

OAuth

OAuth 2.0 Authorization Code with PKCE, against any provider. From a client’s point of view an authorization server is two URLs, and two URLs is all this module asks for.

It ends at the token response. Calling a userinfo endpoint, matching the result to a local account, starting a session — none of that is here, and the section at the bottom says why.

Turning it on

arcature = { version = "0.1", features = ["oauth"] }

oauth = ["dep:oauth2", "dep:url"]. It is in neither default nor fullstack. It also needs nothing else: CI builds it with --no-default-features --features oauth and runs both test binaries on that build, so the feature is known to stand up without a database, a job runner or a CLI underneath it.

oauth2 owns the protocol and vendors the HTTP client it drives, reachable as arcature::oauth::oauth2::reqwest. There is deliberately no direct reqwest in [dependencies]: adding one would put a second major version of the same client in the dependency graph, and nothing in src/oauth/ would ever reach it. The whole crate is re-exported as arcature::oauth::oauth2 so downstream code targets the version Arcature pinned rather than resolving its own.

Examples below are marked ignore — neither compiled nor run. They name a session, a callback route and an account store, the first behind the auth feature an oauth-only build never compiles, the last two absent from this crate, so there is nothing here for a compiler to check them against.

Configuring a provider

An Endpoints is a pair of &'static str. The bundled providers are const values of it, not variants of anything:

PresetAuthorization endpointToken endpoint
GITHUBhttps://github.com/login/oauth/authorizehttps://github.com/login/oauth/access_token
GOOGLEhttps://accounts.google.com/o/oauth2/v2/authhttps://oauth2.googleapis.com/token
DISCORDhttps://discord.com/oauth2/authorizehttps://discord.com/api/oauth2/token
use arcature::oauth::{Endpoints, OauthClient, GITHUB};

// A bundled provider.
let github = OauthClient::new(
    GITHUB,
    client_id,
    Some(client_secret),
    "https://app.example.com/auth/github/callback",
)?;

// A provider the framework has never heard of, configured identically.
const ACME_SSO: Endpoints = Endpoints {
    authorization: "https://sso.acme.example/oauth/authorize",
    token: "https://sso.acme.example/oauth/token",
};
let sso = OauthClient::new(ACME_SSO, client_id, Some(client_secret), redirect)?;

The rejected alternative was a Provider enum with a variant per vendor. It reads better in a signature, and it makes adding a provider a framework release: an in-house identity server could never be more than a second-class Provider::Custom { .. } beside the real ones. A const pair costs nothing, compares by value (Endpoints is Copy, PartialEq and Eq), and makes the company SSO and GitHub the same kind of thing. tests/oauth.rs asserts that by running the bundled presets and an invented one through identical assertions.

Endpoints known only at run time — read from configuration, or discovered — cannot be &'static str, so they take the other constructor:

let client = OauthClient::for_urls(
    &config.authorization_endpoint,
    &config.token_endpoint,
    &config.client_id,
    config.client_secret.clone(), // Option<String>
    &config.redirect_uri,
)?;

client_secret is an Option. None is a public client — a native or single-page app with no secret to keep, relying on PKCE alone — and the secret is then omitted from the token request rather than sent empty, which some providers reject outright. With a secret set, the client authenticates to the token endpoint over HTTP Basic; the round trip pins the exact header, Basic base64(client_id:client_secret).

An OauthClient owns its HTTP client and its endpoints and is not looked up from anywhere. Hold it as application state.

Transport: https, and one exception

All three URLs are parsed and transport-checked when the client is built, in this order:

PositionRole named in the errorRejects
1"authorization endpoint"unparseable, or plaintext off loopback
2"token endpoint"same
3"redirect URI"same

A URL that does not parse is OauthError::InvalidUrl { role }. One that parses and fails the transport check is OauthError::InsecureTransport { role }. The first failure wins, so role names the first bad URL, not all of them.

The rule itself, from require_transport_security in src/oauth/provider.rs:

URLVerdict
https:// anythingallowed
http://localhost (ASCII case-insensitive)allowed
http://127.0.0.1, any IPv4 loopbackallowed
http://[::1], any IPv6 loopbackallowed
http:// any other hostrefused
http://localhost.evil.testrefused
any other schemerefused

So plaintext HTTP is permitted, and only when the host is loopback. That is the one case with no network to intercept, and it is the case every local development redirect URI needs.

There is no flag to widen it, and that absence is the decision. An application that could switch the check off would eventually ship with it switched off, and the switch would be found in a production config file six months later. Development gets what it needs from the loopback exception and nothing more. A host that merely mentions loopback is not loopback: localhost.evil.test and 127.0.0.1.evil.test are both refused, and both are pinned by tests in src/oauth/provider.rs and in tests/oauth.rs.

tests/oauth_round_trip.rs runs on the exception on purpose. Its mock provider binds 127.0.0.1:0, so the suite needs no certificate and no network, and behaves identically on a pull request from a fork.

The HTTP client built for the token exchange sets exactly one option: redirect::Policy::none(). A token endpoint that answers 302 is a server-side request forgery primitive, not a provider quirk to accommodate.

The authorization redirect

use arcature::oauth::OauthClient;

pub async fn start(session: Session, client: OauthClient) -> Result<Redirect> {
    let start = client.authorize(&["read:user"])?;

    session.put("oauth.state", start.state().as_str()).await?;
    session.put("oauth.verifier", start.verifier().secret()).await?;

    Ok(Redirect::to(start.url().as_str()))
}

authorize returns an Authorization holding three things: the URL, the state, and the PKCE verifier. The browser is handed the URL. The other two have to survive until the callback, which means the session or somewhere like it — they are per-attempt values, not per-user ones, and a user with two tabs open has two of each. into_parts() takes the three apart by value when borrowing them is awkward.

Authorization’s Debug prints the URL up to the end of the path and then ?[redacted], because the state and the code challenge live in that query string and a Debug output is exactly the thing that ends up in a log.

PKCE (S256), and why

The challenge is built by PkceCodeChallenge::new_random_sha256(). The method is S256 and there is no way to ask for anything else.

The rejected alternative is RFC 7636’s other method, plain, where the challenge is the verifier. It exists for clients that cannot compute a SHA-256, which is no client this framework will ever run on, and it defends against nothing: an attacker who can read the authorization request can read the challenge, and under plain the challenge is the secret. Offering the option would only create a way to configure the protection off.

What PKCE buys is the case where the authorization code is intercepted — a malicious app registered on the same custom URI scheme, a code leaking through a Referer header, a shared-machine browser history. The code alone is not enough to redeem it: the token endpoint wants the verifier whose SHA-256 was committed to at the start, and only the client that started the flow has it.

tests/oauth_round_trip.rs is what turns that from a claim into a test. The mock provider recomputes the challenge from the verifier the token endpoint was handed and refuses the exchange when the two disagree, which is what a real authorization server does. The suite therefore proves three things a “the string appears in the URL” test cannot:

  • the code_challenge_method the provider saw was S256;
  • the challenge the provider saw is the base64url SHA-256 of the verifier the exchange later sent, and is not the plain verifier;
  • a well-formed verifier from somebody else’s flow is refused, arriving as OauthError::Provider { code: "invalid_grant" }.

The test writes out its own SHA-256 and base64url rather than pulling a crate. sha2 belongs to the uploads feature and is not compiled by an oauth build, and a test that shares an implementation with the code under test can agree with its bugs. The test’s arithmetic is pinned against the published FIPS 180-4 and RFC 7636 vectors.

The state parameter

PropertyValue
Sourcegetrandom::fill, the OS CSPRNG
Length32 bytes, STATE_BYTES in src/oauth/pkce.rs
Encodinglowercase hex, so 64 characters, safe in a query string unescaped
On RNG failureOauthError::Entropy, no fallback
ComparisonOauthState::verify -> constant_time_eq, same file

OauthState::generate returns Err(OauthError::Entropy) if the OS randomness source is unavailable. There is no fallback to a clock, a counter or a hash of the request, because a predictable state is not a weaker state, it is no state.

The comparison is constant time with respect to the contents of the two values. constant_time_eq XOR-accumulates every byte and tests the accumulator once at the end, and the accumulator goes through std::hint::black_box before that test — without it a compiler is entitled to notice that the accumulator can only grow and to break out of the loop early, which is precisely the timing signal the function exists to remove. Length is compared up front and does short-circuit; the length of a state is visible in the query string already, so hiding it buys nothing.

The rejected alternative is ==, which returns at the first differing byte. Correctness alone cannot tell the two apart — both give the same answer — so tests/oauth.rs asserts the property that can: the answer is identical wherever the difference sits, checked at every one of the 32 positions including the first, which is the one a short-circuiting comparison exits on immediately. A wall-clock measurement of the same property sits beside it under #[ignore], because a shared or loaded CI machine makes any tolerance wrong.

The state is checked before the code is redeemed. It is the first statement in exchange, and a mismatch returns without touching the network. Two tests pin the order rather than trusting it:

  • tests/oauth.rs points a client at a token endpoint that is not listening and asserts the error is StateMismatch and not Transport. If the check ran second, the variant would be the other one.
  • tests/oauth_round_trip.rs drives a real callback carrying a second flow’s state, then asserts the provider’s ledger recorded token_calls == 0 — a forged callback is refused before the code is spent, not after.

The order matters because an authorization code is one-time. A state check that ran after the exchange would let a CSRF callback burn a legitimate code, and would have handed the tokens over before anybody objected.

The callback and the exchange

use arcature::oauth::{OauthClient, OauthState, PkceVerifier};

pub async fn callback(
    session: Session,
    client: OauthClient,
    Query(params): Query<CallbackParams>, // code: String, state: String
) -> Result<Response> {
    let stored: String = session
        .forget("oauth.state")
        .await?
        .ok_or_else(|| Error::forbidden("no OAuth flow in progress"))?;
    let verifier: String = session
        .forget("oauth.verifier")
        .await?
        .ok_or_else(|| Error::forbidden("no OAuth flow in progress"))?;

    let tokens = client
        .exchange(
            &OauthState::from_stored(stored),
            &params.state,
            &params.code,
            PkceVerifier::from_secret(verifier),
        )
        .await?;

    // `tokens.access_token()` is a bearer credential. Send it; do not put it
    // in a log line or an error message.
    Ok(sign_in(profile_for(&tokens).await?).await?)
}

exchange takes the stored state by reference and the verifier by value. The verifier is consumed, so the same one cannot be reused for a second exchange by accident. Take both out of the session rather than reading them, which is what forget does here: a flow finishes once, and leaving the values behind leaves a live verifier sitting in the session for whatever arrives next.

What a successful exchange returns:

TokenSet accessorTypeWhat the round trip observed
access_token()&strthe provider’s access_token member
refresh_token()Option<&str>Some, and not equal to the access token
token_type()&str"bearer" — the provider sent Bearer, and this path lowercases
expires_in()Option<Duration>Some(3600s), from expires_in
scopes()&[String]["read:user"] after ["read:user", "profile"] was asked for

That last row is the reason the accessor exists at all. Narrowing the granted scopes is the provider’s prerogative, so the answer has to be read out of the response rather than echoed back from the request.

TokenSet::new(access_token, token_type) builds one directly, for tests and for applications that obtained tokens some other way and want the same redaction. It stores what it is given and lowercases nothing.

Fetching user info

The module does not do this, and that is the deliberate half of the two-URL model. An OAuth 2.0 authorization server is an authorization endpoint and a token endpoint; a userinfo endpoint belongs to a resource server, and its path, its JSON shape and its field names differ per provider — sub here, id there, login versus username versus preferred_username. A framework type that covered them would be a per-provider parser, which is the provider enum this module already declined, wearing a different hat.

So the leg after exchange is an ordinary authenticated HTTP request, with the access token as a bearer credential:

use arcature::oauth::oauth2::reqwest;

let profile: serde_json::Value = reqwest::Client::new()
    .get("https://api.github.com/user")
    .bearer_auth(tokens.access_token())
    .send()
    .await?
    .json()
    .await?;

arcature::oauth::oauth2::reqwest is the client oauth2 already vendors, so reaching for it adds nothing to the dependency graph. An application that already has an HTTP client should use that one instead.

The round trip makes this call for a reason beyond illustration. Everything before it compares strings against strings, and an access token parsed out of the refresh_token member is still a string that survives every assertion. A resource server is the only thing that can tell the two apart, so the test stands one up, presents tokens.access_token() to it, and asserts on the provider’s side that the credential it received was Bearer <access token> — a refresh token must never be the credential sent to a resource server.

Errors

OauthError is the one error type. Every variant is built from a fixed &'static str or from a provider-supplied error code, never from a response body:

VariantCarriesRaised byRetry?
InvalidUrl { role }the role, a &'static strconstructionno, it is a config bug
InsecureTransport { role }the roleconstructionno, same
Entropynothingauthorizeno, not recoverable by retrying
StateMismatchnothingexchange, before the networkno, start the flow again
Transportnothingexchange; also a client that fails to buildyes, this is the retryable one
Provider { code }the provider’s error memberexchangedepends on the code
MalformedResponsenothingexchangeno

Provider { code } carries invalid_grant, invalid_client, unsupported_grant_type and the rest of RFC 6749’s fixed vocabulary. It does not carry the error_description beside it, which is free-form text the provider wrote.

A token-endpoint response that does not parse becomes MalformedResponse, and the body is dropped on the floor. Upstream, RequestTokenError::Parse holds the raw bytes the provider sent; both it and RequestTokenError::Other collapse to MalformedResponse with nothing attached. The reason is the case that looks harmless: a malformed success response still contains an access token, so a variant that carried the body for diagnostics would put credentials into every log line that formatted the error.

The cost of that is real and worth stating. Debugging a provider that answers in a shape this implementation does not understand means reproducing the request, because the error will not tell you what it said.

The three failure modes of exchange stay distinguishable because an application may retry one of them and must not retry the others, and tests/oauth_round_trip.rs covers each: a replayed code arrives as Provider { code: "invalid_grant" }, a token endpoint that is not listening as Transport, and a callback from another flow as StateMismatch. an_oauth_error_never_carries_a_response_body in tests/oauth.rs renders the five runtime variants — StateMismatch, Entropy, Transport, MalformedResponse and Provider — and asserts none of them mentions access_token and none runs past 200 characters.

What is never logged

TypeDebug rendersDisplay
PkceVerifierPkceVerifier([redacted])none
OauthStateOauthState([redacted])none
TokenSetTokenSet([redacted])none
Authorizationthe URL through the path, then ?[redacted], plus the two redacted fieldsnone
OauthClientOauthClient { .. }none
Endpointsderived, in full — it holds two public URLsnone
OauthErrorderivedyes, and it carries no body

None of the secret-bearing types implements Display, so none of them can reach a log line through ordinary formatting. Reading a secret out means calling secret(), as_str() or access_token() by name, which is the point where a reviewer sees the decision. OauthClient’s Debug is hand-written rather than derived because the client holds a ClientSecret, and oauth2’s own redaction is not something this crate should rely on transitively.

Five tests in tests/oauth.rs pin this by formatting a real value and asserting the secret is absent from the output.

Separately, under the observe feature, the JSON log layer drops the value of any field whose name contains one of the fragments in arcature::observe::redact::DENY_LISTtoken, verifier, secret, auth, credential and the rest — with - and . folded to _ first. That is a second net under the first, not a replacement for it: it matches on field names, so it catches a field called oauth.access-token and does not catch a secret interpolated into a message string.

What this module does not do

No provider registry, and no discovery. There are three const Endpoints and no way to look one up by name — no enum, no FromStr, no table keyed by a string from a config file. There is also no OpenID Connect discovery: nothing reads /.well-known/openid-configuration. Fetch it yourself if you want it, and hand the two URLs to for_urls.

No token storage. The oauth feature brings no table, no migration and no model, and the module never touches a session. authorize hands you the state and the verifier, exchange hands you a TokenSet; where those live between the two requests, and whether the access token is kept after the flow at all, is the application’s decision. It is also why oauth needs no database.

No refresh loop, and no refresh method. exchange is the only thing on OauthClient that talks to a token endpoint. There is no background task watching expires_in, no interceptor that retries a 401 with a refreshed credential, and no refresh(). TokenSet::refresh_token() hands you the string; driving the refresh grant with it goes through the re-exported oauth2, which is exactly what a_refreshed_token_set_carries_the_new_access_token does. A refresh loop needs somewhere to write the new token back to, and the paragraph above is the reason there is no such place.

No OpenID Connect. No id_token on TokenSet, no JWT parsing, no signature verification, no nonce. An id_token member in a token response is ignored. Verifying one is a JWS implementation plus a key-set fetcher, and neither belongs behind a feature whose stated job is two URLs.

No revocation and no introspection. RFC 7009 and RFC 7662 are two more endpoints, and Endpoints holds two.

No routes, no extractor, no middleware. Nothing in src/oauth/ imports axum. There is no callback handler to mount, no Application::oauth(..) wiring, and no arc make: generator. The two handlers in this chapter are what an application writes.

No timeout on the token exchange. The HTTP client is built with one option set — the redirect policy — so the request inherits whatever the vendored reqwest defaults to. An application that needs a bounded exchange should wrap the exchange future in tokio::time::timeout.

Jobs

Durable background jobs on PostgreSQL. A FOR UPDATE SKIP LOCKED queue over the application’s existing PgPool — no second connection pool, no separate broker to operate.

The queue is at-least-once. A handler must tolerate running twice.

Declaring a job

use arcature::Job;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, arcature::Job)]
struct SendVerificationEmail {
    user_id: u64,
}

The derive generates impl DxComponent (so NAME is "SendVerificationEmail") and a JOB const describing the queue identity. The defaults are the struct name in snake_case, version 1, three attempts:

assert_eq!(SendVerificationEmail::JOB.kind(), "send_verification_email");
assert_eq!(SendVerificationEmail::JOB.version(), 1);
assert_eq!(SendVerificationEmail::JOB.max_attempts(), 3);

Override them with the helper attribute:

#[derive(Debug, Clone, Serialize, Deserialize, arcature::Job)]
#[job(kind = "custom_kind", version = 2, attempts = 5)]
struct CleanupSessions {
    user_id: u64,
}

version is part of the queue identity, not decoration. A handler registers for a (kind, version) pair, so bumping the version lets a new payload shape coexist with jobs already in the table rather than deserializing into the wrong struct.

JobModel::new(kind, version, max_attempts) builds the identity by hand if you would rather not derive it.

Enqueueing

use arcature::jobs::{JobRequest, Jobs};

let jobs = Jobs::new(pool.clone());
let request = JobRequest::new(
    &SendVerificationEmail::JOB,
    &SendVerificationEmail { user_id: 42 },
)?;
jobs.enqueue(&request).await?;

JobRequest builders: .delay(Duration), .run_at(DateTime<Utc>), .max_attempts(n) to override the model’s default for this one job.

jobs.enqueue_tx(..) enqueues inside a transaction you already hold, and enqueue_with(executor, ..) takes any SQLx executor. Enqueueing in the same transaction as the state change is the only way to avoid the job that runs before its row exists.

jobs.migrate() creates the queue tables; migrate_tx does it inside a transaction you own.

Payloads are size-capped (DEFAULT_MAX_PAYLOAD_BYTES, overridable per model with with_max_payload_bytes). A queue row is not a blob store; put the bytes in Storage and the key in the payload.

Handlers

A handler is a closure registered against the job model. Registry::add takes &mut self, so the registry is mutable while you build it:

use arcature::jobs::{JobError, Registry};

pub fn registry() -> Registry {
    let mut registry = Registry::new();
    registry
        .add(&SendVerificationEmail::JOB, |job: SendVerificationEmail| async move {
            send_email(job.user_id).await.map_err(JobError::retryable)
        })
        .expect("job kind is valid and registered once");
    registry
}

Registering the same (kind, version) twice is an error, not a silent overwrite.

The handler’s error type decides what happens next. JobError::Retryable means retry per the backoff policy until max_attempts is exhausted; JobError::Permanent means dead immediately. retryable(e) and permanent(e) wrap any std::error::Error; retryable_msg(s) and permanent_msg(s) take a string. Choosing between them is the handler’s job, because the framework cannot tell a bad payload from a flaky network.

#[job_handler] validates that a handler function is pub async fn with a return type and emits it unchanged. It generates no binding const and does not register anything: a handler’s proc-macro cannot see the job’s kind and version, since those come from #[derive(Job)] on the payload struct. Registration stays explicit in application code.

Running a worker

use std::time::Duration;
use arcature::jobs::{RetryPolicy, Worker, WorkerConfig};
use tokio_util::sync::CancellationToken;

let worker = Worker::builder(pool.clone(), registry())
    .worker_id("worker-1")
    .config(WorkerConfig::default().concurrency(16))
    .retry_policy(
        RetryPolicy::exponential(Duration::from_secs(5), 2.0, Duration::from_secs(600))
            .jitter(true),
    )
    .build();

worker.run(CancellationToken::new()).await?;

Worker::new(pool, registry) skips the builder when the defaults suffice. run takes a CancellationToken and returns when it fires, so shutdown is the caller’s to sequence.

WorkerConfig defaults: concurrency 8, poll interval 200ms, lease 300s, poll batch 8, sweep every 30s in batches of 64, per-job timeout 60s, heartbeat derived as lease / 3.

Or from the CLI: arc queue work, arc queue drain, arc queue stats.

Why claims are fenced

Every claim carries a per-claim UUID claim_token, and every completion mutation fences on (id, status = 'running', claim_token).

The reason is the lease. A worker claims a job for a bounded time; if it dies or stalls past the lease, the sweep requeues the job and another worker picks it up. Without the token, the first worker waking up late would write its result over the second worker’s claim — two runs, one of them clobbering the other’s outcome. With it, the stale worker’s UPDATE matches zero rows and does nothing.

At-least-once still means at-least-once. The fence stops a stale worker committing a result, not a job body running twice.

Retries

RetryPolicy::exponential(base, multiplier, cap) computes base * multiplier^(attempts - 1), capped. RetryPolicy::fixed(delay) for a flat wait. .jitter(true) enables full jitter, which is what stops a batch of simultaneous failures retrying in lockstep forever.

A job that exhausts max_attempts is dead. arcature::jobs::admin exposes requeue_dead, cancel, and sweep_expired_leases for the operator paths.

Scheduling

use arcature::jobs::{ScheduleBinding, ScheduleCadence, Scheduler};

const NIGHTLY: ScheduleBinding = ScheduleBinding {
    job: "cleanup_sessions",
    version: 1,
    cadence: ScheduleCadence::Daily { hour: 3, minute: 0 },
};

let scheduler = Scheduler::new().schedule(&NIGHTLY, move || {
    let jobs = jobs.clone();
    async move { /* enqueue */ Ok(()) }
});

scheduler.run(CancellationToken::new()).await?;

ScheduleCadence is an interval or a daily wall-clock time. The scheduler enqueues; the worker runs. They are separate processes if you want them to be. From the CLI: arc schedule.

Observability

Observer is the seam, defaulting to NoopObserver. Implement it and pass it to WorkerBuilder::observer to see claims, completions and failures.

PostgreSQL only

The queue requires PostgreSQL. FOR UPDATE SKIP LOCKED is the whole design, and SQLite and MySQL do not have a usable equivalent. An application on db-sqlite gets the rest of the framework and no job queue.

Events

In-process typed event dispatch. Not a message bus, not durable, not cross-process: if the process dies mid-dispatch, the remaining listeners do not run. For work that must survive a restart, use Jobs — a listener that enqueues a job is the usual bridge.

Declaring an event

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, arcature::Event)]
pub struct UserRegistered {
    pub user_id: u64,
    pub email: String,
}

The derive generates impl DxComponent with NAME = "UserRegistered" and the empty impl Event. Serialize and Deserialize are yours to add: dispatch erases the type through serde_json::Value, serializing once and deserializing per listener.

That is a deliberate choice over TypeId plus Any. The dispatch key is a &'static str name, which means the same mechanism describes itself to tooling and no downcast can silently miss. It costs one round-trip through JSON per listener.

Listeners

use arcature::events::{DispatchError, Dispatcher};

let dispatcher = Dispatcher::new()
    .register(|event: UserRegistered| async move {
        println!("welcome {}", event.email);
        Ok(())
    });

register consumes and returns the dispatcher, so registration chains. Several listeners may share an event type; they run in registration order.

#[listener(UserRegistered)] marks a free function as a listener and emits a LISTENER_BINDING static beside it for inspection:

#[arcature::listener(UserRegistered)]
pub async fn send_welcome_email(
    event: UserRegistered,
) -> Result<(), arcature::events::DispatchError> {
    let _ = event;
    Ok(())
}

assert_eq!(LISTENER_BINDING.event, "UserRegistered");
assert_eq!(LISTENER_BINDING.listener, "send_welcome_email");

The function is emitted unchanged and stays directly callable. The macro does not register it — you still pass it to Dispatcher::register. The binding const is metadata for the Unified Application Graph, not a registry that wires itself.

Dispatching

dispatcher
    .dispatch(&UserRegistered { user_id: 1, email: "a@b.com".into() })
    .await?;

Listeners run sequentially in registration order. A listener failure does not stop the others: the error is logged, every listener still runs, and dispatch returns the first error afterwards. Dispatching an event with no listeners is a no-op that returns Ok(()).

Sequential, not concurrent, and in-process, so a slow listener delays the request that dispatched the event. Listeners should be short; anything with latency belongs in a job.

Errors

DispatchError has three variants. Serialize(String) carries the serde message. Listener(String) carries whatever the listener chose to expose. Deserialize carries no message at all, deliberately: a serde deserialization error can echo the payload it choked on, and an event payload is exactly the kind of thing that should not end up in a log line.

Testing

Dispatcher::recording() records dispatched event names:

let dispatcher = Dispatcher::recording();
dispatcher.dispatch(&UserRegistered { user_id: 1, email: "a@b.com".into() }).await?;
assert!(dispatcher.was_dispatched("UserRegistered"));

dispatched_events() lists them and listener_count(name) reports how many listeners an event has. In a non-recording dispatcher was_dispatched always returns false, so recording is opt-in rather than something production pays for.

Mail

SMTP over lettre. Arcature owns the ergonomics; lettre owns the protocol and the certified rustls plus aws-lc-rs stack owns TLS.

Mail is a value, not a namespace. There is no Mail::to("a@b.com") static constructor — to is a method on a facade that already knows the mailer and the From address.

The transport

use std::time::Duration;
use arcature::mail::{Mailer, SmtpConfig, SmtpCredentials, TlsMode};

let config = SmtpConfig::new("smtp.example.com")?
    .port(587)
    .tls_mode(TlsMode::StartTls)
    .credentials(SmtpCredentials::new("user", "secret"))
    .timeout(Duration::from_secs(10));

let mailer = Mailer::smtp(config)?;

SmtpConfig::from_url("smtps://user:pass@host:465") parses a connection URL instead.

Mailer is Clone + Send + Sync + 'static. Application::mail(config) builds one at startup.

Credentials never reach a log. SmtpCredentials has a Debug that prints only the type name and deliberately has no Display; SmtpConfig implements both by hand and never emits the password or the full URL.

Sending

Implement Mailable for the message type. The build method receives an Email builder that already has From and To set:

use arcature::mail::{Email, EmailError, Mailable};
use arcature::mail::lettre::message::Message;

pub struct WelcomeEmail {
    pub name: String,
}

impl Mailable for WelcomeEmail {
    fn build(&self, email: Email) -> Result<Message, EmailError> {
        email
            .subject(format!("Welcome, {}", self.name))
            .plain(format!("Welcome, {}", self.name))
    }
}

Then send:

use arcature::mail::Mail;

let mail = Mail::from_str(mailer, "noreply@example.com")?;
mail.to(user.email).send(&WelcomeEmail { name: user.name }).await?;

Mail::new(mailer, mailbox) takes an already-parsed Mailbox if you have one; parse_mailbox("Ada <ada@example.com>") produces one.

The message builder

Email::builder() starts a message. The chainers — from, reply_to, to, cc, bcc, subject — return Self and do not fail. Only the body terminators return a Result, because that is where the message is actually assembled:

let message = Email::builder()
    .from(parse_mailbox("noreply@example.com")?)
    .to(parse_mailbox("ada@example.com")?)
    .subject("Welcome")
    .html("<h1>Welcome</h1>")?;
TerminatorProduces
plain(body)text/plain
html(body)text/html
alternative(plain, html)multipart/alternative
mixed(..)multipart/mixed
plain_with_attachments(body, attachments)text plus attachments
alternative_with_attachments(plain, html, attachments)both, plus attachments

EmailAttachment::new(..) builds an attachment. Its Debug redacts the body bytes — an attachment in a log line is a data leak, not a diagnostic.

Email::from_builder(builder) and email.into_builder() cross to and from lettre’s own MessageBuilder when the wrapper runs out.

Testing

Mailer::capture_ok() records every message in memory and always succeeds. Mailer::capture_error() always fails the send, which is the one a retry path needs. Neither opens a socket.

let mailer = Mailer::capture_ok();
let mail = Mail::from_str(mailer.clone(), "noreply@example.com")?;
mail.to("ada@example.com").send(&WelcomeEmail { name: "Ada".into() }).await?;

let sent = mailer.captured().await.expect("capture mailer");
assert_eq!(sent.len(), 1);

captured() returns None for an SMTP mailer. is_capture() and is_smtp() report which kind you hold, so a production guard can refuse to start with a capture transport.

What this module does not own

SMTP, TLS, or cryptography. The lettre crate is re-exported as arcature::mail::lettre.

Notifications sit on top of this

Mail is one channel. The notifications feature adds the layer above it: one event, told to one person, over whichever channels apply – mail plus an in-app inbox (notifications-db), a live push over WebSocket/SSE (notifications-broadcast), or mail handed to the job queue so the send does not happen inside the request (notifications-queue).

Reach for it when the same event has to arrive more than one way, or when “who gets told what” is a decision worth writing down once. A single transactional mail is what this chapter already covers. arc make:notification <name> writes the starting point.

Notifications

One event, told to one person, over whichever channels apply: an email, a row in an in-app inbox, a live push to a socket that is open right now.

Not enabled by default. notifications is absent from the crate’s default feature list, and so are the three that build on it.

Notifier is a value, not a namespace. Nothing in Application constructs one — you build it at startup and put it in application state, the same way you would a Mail or a Jobs.

What a notification is

A notification is a type that knows how to render itself for each channel. The trait has one method per channel and every one of them defaults to None:

MethodReturnsChannel
to_mail(&self, recipient)Option<MailContent>Channel::Mail
to_database(&self, recipient)Option<DatabaseContent>Channel::Database
to_broadcast(&self, recipient)Option<BroadcastContent>Channel::Broadcast
use arcature::notifications::{MailContent, Notification, Recipient};

struct InvoicePaid {
    amount_cents: i64,
}

impl Notification for InvoicePaid {
    fn to_mail(&self, recipient: &Recipient) -> Option<MailContent> {
        // No address, no mail -- and no error, because this notification is
        // genuinely not a mail notification for this person.
        recipient.email_address()?;

        Some(MailContent::new(
            "Your invoice is paid",
            format!("We received {}.{:02}.", self.amount_cents / 100, self.amount_cents % 100),
        ))
    }
}

impl Notification for Silent {} compiles and reaches nobody. That is what makes adding a channel later additive: a notification written today keeps compiling when a fourth method appears, and does not use it.

There is no via

Laravel names the channels in via() and renders them in toMail/toDatabase/toBroadcast. Two places, and nothing keeps them agreeing: a channel in via() with no method behind it throws at runtime, and a method via() forgot is never called.

Here the channel set is derived rather than declared. A notification reaches a channel exactly when that channel’s method returns Some, so the list is the methods. The per-recipient decision via($notifiable) exists to make is still available — every method receives the Recipient — but it is made in the same place that produces the content.

to_database and to_broadcast exist whatever features are on, and so do the Channel::Database and Channel::Broadcast variants. Rendering costs nothing but serde_json; it is delivering that needs a feature. A method compiled out by a feature flag would be a notification that silently changes what it does.

The three content types

MailContent::new(subject, text) takes the plain-text body as a mandatory argument and MailContent::html(html) adds the HTML one. That order is deliberate: an HTML-only email is unreadable in a text client, in a screen reader that falls back, and in the preview line every mail app shows, and it is one of the older signals a spam filter weighs. html_body() is None until .html(..) is called, and the last call wins. The HTML is used verbatim — nothing escapes what a caller interpolates into it.

DatabaseContent and BroadcastContent are the same pair of fields — a kind string and a serde_json::Value payload — and deliberately two types. An inbox row is read on purpose and can afford detail; a live push arrives unasked, is usually a toast or a badge, and is often smaller. A notification that wants them identical builds both from the same value, which is one line; one that wants them different has nowhere to say so if they share a method.

Both have two constructors. new(kind, value) cannot fail, because serde_json::json! produces a Value infallibly. serializing(kind, &T) takes a Serialize value and hands back the serde_json::Error. The split exists because to_database returns an Option and an Option has nowhere to put an error: a constructor that serialised would turn a #[serde(..)] mistake into a notification that never appears.

The kind is the application’s own name — "invoice.paid", "mention" — and deliberately not a Rust type path. It is stored in a row and switched on by a front end, so deriving it from a type name would make refactor: rename a silent protocol change.

Recipients

use arcature::notifications::{Notifiable, Recipient};

struct User {
    id: i64,
    email: String,
}

impl Notifiable for User {
    fn recipient(&self) -> Recipient {
        Recipient::new(format!("user:{}", self.id)).email(&self.email)
    }
}

A Recipient is a stable key plus whatever a channel needs to reach them. The key is the same shape the rest of the framework uses for a subject — the string an API token is issued to — so a notification, a token and an audit line name the same person the same way. It should be a primary key rather than an email address, because the inbox stores it alongside every delivered row.

A fresh Recipient has no email address; email_address() returns None until .email(..) is called, and a second call replaces the first. A recipient with no address is ordinary rather than broken — a notification that only writes to an inbox needs no way to email anybody.

recipient() is called once per send, so it may allocate. It must not query a database.

Recipient implements Notifiable for itself, so notifier.send(&recipient, ..) works without a wrapper type.

The four features

FeatureImpliesWhat it adds
notificationsmailNotification, Recipient, Notifiable, Notifier, Delivery, Channel, NotificationError, the three content types, and the mail channel
notifications-dbnotifications, databaseDatabaseNotifications, StoredNotification, NotificationId, NotificationPool, Notifier::with_database — plus one table and one migration
notifications-broadcastnotifications, realtimeBroadcastChannels, PerRecipientChannels, BroadcastNotifications, Notifier::with_broadcast
notifications-queuenotifications, jobsNotifier::queue, NotificationQueue, QueuedMail, MAIL_JOB, register_mail_handler

None of the four adds a crate to the dependency graph. notifications is mail plus the unconditional thiserror; notifications-db rides the sqlx that database already brings, with serde_json and getrandom unconditional; notifications-broadcast is realtime, which is tokio, futures and bytes — axum is unconditional and no feature turns it on; notifications-queue is jobs, which is database plus tokio and tokio-util, both of which the default feature set already brings.

Why four and not one

notifications implies mail rather than splitting a channel-less core into its own feature. Mail is the channel a notification system is overwhelmingly used for, and the alternative — a notifications that can deliver nothing plus a notifications-mail on top — would be two features and two powerset dimensions to spare a dependency the same application has almost certainly already enabled.

The other three earn their separation because each costs something a mail-only application should not pay:

  • notifications-db brings a schema. A table and a migration are not a line in Cargo.toml; they are a thing an operator has to run and a thing a backup has to hold. An application that only sends mail should not carry them.
  • notifications-broadcast answers a different question from the inbox. The inbox is what a recipient sees when they arrive; the broadcast is what they see without reloading. Wanting one is not wanting the other, so the cost of each is opt-in on its own.
  • notifications-queue changes where the work happens. It is the only one of the four that moves work rather than adding work: an application enabling it takes on running a worker process, and one that has no worker should not be offered a method that writes rows nobody drains.

Wiring the notifier

use arcature::jobs::Jobs;
use arcature::mail::Mail;
use arcature::notifications::{
    BroadcastNotifications, DatabaseNotifications, NotificationQueue, Notifier,
    PerRecipientChannels,
};

let channels = PerRecipientChannels::new(64).expect("capacity is non-zero");

let notifier = Notifier::new()
    .with_mail(Mail::new(mailer, "noreply@example.com".parse()?))
    .with_database(DatabaseNotifications::new(pool.clone()))
    .with_broadcast(BroadcastNotifications::new(channels.clone()))
    .with_queue(NotificationQueue::new(Jobs::new(pool.clone())));

That example needs all four features; each with_* past with_mail is gated on its own. Notifier::new() (and Notifier::default()) has nothing wired: every channel is absent until it is given a backing. has_mail(), has_database(), has_broadcast() and has_queue() report what is there.

Notifier is cheap to clone. Its Debug prints one boolean per channel and nothing from behind them — a Mailer holds SMTP credentials and a pool holds a database URL, and a Debug that printed either would put it in the first log line that formats application state.

Sending

let delivery = notifier.send(&user, &InvoicePaid { amount_cents: 1250 }).await?;
assert!(delivery.reached(Channel::Mail));

The order is part of the contract: inbox, then live push, then mail. The durable local record first, then the local push, then the one thing that leaves the process. The inbox cannot fail for a reason outside the application, so writing it first means an SMTP server that is down leaves the notification visible in the application rather than losing it along with the email. The reverse order would trade a recoverable failure for an unrecoverable one.

Delivery stops at the first failing channel.

Delivery is returned rather than discarded because “reached nobody” is a real outcome and an invisible one:

CallAnswers
delivery.channels()the channels that ran, in the order they were tried
delivery.reached(channel)whether that channel ran
delivery.queued()the channels handed to the queue instead of run
delivery.is_queued(channel)whether that channel was queued rather than run
delivery.is_empty()whether nothing ran and nothing was queued

channels() and queued() never overlap. A job row is not a delivery, and folding the two together would make reached(Channel::Mail) say yes to a row in a table.

Channel::Broadcast appears in channels() only when at least one connection actually received the push. Nobody connected is not a failure — it is the ordinary state of a recipient who is not looking at the application — so it is reported as the channel not being among the ones that ran.

Nothing is delivered quietly

Asking for a channel the notifier was never given returns NotificationError::NotConfigured instead of skipping it. A forgotten .with_mail(..) at startup fails on the first send rather than becoming password-reset emails that never arrive.

VariantRaised whenNeeds
NotConfigured { channel }the notification rendered content for a channel with no backing
NoAddress { key }mail content for a recipient with no email address
Mail { source }the transport refused the message or could not deliver it
Database { source }the database rejected a statement or was unreachablenotifications-db
Decode(String)a stored row did not hold what the schema promisesnotifications-db
Timestamp(String)a stored epoch-millisecond value is not a representable timenotifications-db, SQLite only
IdCollision { attempts }eight random ids were all takennotifications-db
Entropythe OS randomness source was unavailablenotifications-db
Encode(String)a broadcast payload could not be serializednotifications-broadcast
Queue { source }the job row could not be writtennotifications-queue
QueueNotConfiguredNotifier::queue was called with no queue wirednotifications-queue

NotConfigured is also what you get for Channel::Database or Channel::Broadcast when the feature is off entirely, rather than a compile error. The trait methods exist in every build, so the mistake surfaces on the first send, naming the channel that has no backing.

QueueNotConfigured is feature-gated where NotConfigured is not, because a louder signal exists there: Notifier::queue cannot be called without notifications-queue, so the same mistake is already a compile error.

The mail channel

The mail channel is the one notifications itself brings. A MailContent goes through the same Mail::to(..).send(..) path a hand-written Mailable uses, so address parsing, the From header and the transport’s error mapping stay in one place. See Mail for the transport.

Two failures are distinguished. A notification that returns None from to_mail for a recipient with no address is not an error — it decided mail does not apply. A notification that returns content anyway for a recipient with no address is a contradiction, and raises NoAddress { key }.

The database channel: an in-app inbox

notifications-db adds a table. Enabling the feature is not enough; the schema has to be created.

The table and its migration

DatabaseNotifications::migrate() creates arcature_notifications and its two indexes. It is idempotent, records what it applied in arcature_notifications_schema_migrations, and is safe to run from every replica at once: PostgreSQL takes pg_advisory_lock(71420006), MySQL takes GET_LOCK('arcature_notifications_migrate', 10), and SQLite takes no lock because it serialises writers itself and every statement is IF NOT EXISTS. Call it at startup, or run the bundled SQL alongside the application’s own migrations.

One row per notification delivered to this channel:

ColumnPostgreSQLSQLiteMySQL 8
idBYTEA primary keyBLOB primary keyBINARY(16) primary key
notifiable_keyTEXT NOT NULLTEXT NOT NULLVARCHAR(191) NOT NULL
kindTEXT NOT NULLTEXT NOT NULLVARCHAR(191) NOT NULL
dataJSONB NOT NULLTEXT NOT NULLJSON NOT NULL
read_atTIMESTAMPTZ, nullableINTEGER epoch ms, nullableDATETIME(6) NULL
created_atTIMESTAMPTZ NOT NULL DEFAULT now()INTEGER NOT NULL, computed defaultDATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)

Indexes: arcature_notifications_inbox_idx on (notifiable_key, created_at DESC) for the listing, and arcature_notifications_unread_idx on (notifiable_key, read_at) for the badge.

Three things about that schema are decisions rather than accidents:

  • notifiable_key is not a foreign key. A notification is a record of something that was said, and it should outlive a soft-delete or an account merge rather than vanish with it. The cost is that nothing cascades: an account deletion has to call delete_all_for itself.
  • read_at is nullable, and null is the whole meaning of unread. A boolean would answer “has this been read” and nothing else; a timestamp answers “when”, which is what an inbox grouping by day and a support engineer reading a complaint both need.
  • There is no expiry column. Unlike an API token, a notification is not a credential and nothing gets safer by dropping it on a schedule.

SQLite stores both timestamps as epoch milliseconds because it has no timestamp type: text timestamps compare correctly only while every writer agrees on the format down to the digit, and integers always do. Sub-millisecond precision is dropped there.

Reading and writing the inbox

let inbox = DatabaseNotifications::new(pool);
inbox.migrate().await?;

let row = inbox
    .store("user:42", &DatabaseContent::new("invoice.paid", serde_json::json!({ "amount": 4200 })))
    .await?;

assert_eq!(inbox.unread_count("user:42").await?, 1);
assert!(inbox.mark_read("user:42", row.id()).await?);
CallReturns
store(key, &content)the written StoredNotification
inbox(key, limit)that recipient’s notifications, newest first, at most limit
unread(key, limit)the unread ones only, same order and bound
unread_count(key)u64, a COUNT rather than the length of a listing
mark_read(key, id)bool — whether the statement changed a row
mark_all_read(key)u64 rows affected
delete(key, id)bool — whether it existed
delete_all_for(key)u64 rows affected
prune_read_before(cutoff)u64 rows affected, across all recipients
pool()the NotificationPool underneath

NotificationPool is the application’s own pool — the same Pool the database feature exposes. The inbox opens no connection of its own.

There is no unbounded listing. limit is mandatory on both readers, because an inbox read is a page render and a method that could return every notification a long-lived account ever received is a memory spike waiting for the one account that has them.

unread_count is the badge. It is a COUNT because the number next to a bell is asked for on far more page loads than the inbox is opened, and it should not cost the rows.

mark_read returning false does not say which of three things happened: no such notification, somebody else’s notification, or one already read. That is deliberate — a handler that could distinguish “not yours” from “does not exist” is an oracle for which ids exist, and none of the three calls for a different response. A notification that was already read keeps its original read time, because the statement carries read_at IS NULL.

StoredNotification exposes id(), notifiable_key(), kind(), data(), read_at(), is_read() and created_at(). The payload is a serde_json::Value rather than a typed struct: the rows one query returns were written by different notifications with different shapes, and a list that could hold only one shape would not be an inbox. Match on kind() first, then deserialize.

The inbox cannot be read across recipients

Every method takes the recipient key, including the ones that already have an id, and the key is in the WHERE clause rather than checked in Rust afterwards. There is no statement in the store a handler can reach with an id alone. Passing somebody else’s notification id returns false, not a deletion.

This is the difference between an ownership check a handler can forget and one it cannot reach around. An inbox is exactly the endpoint that grows an insecure-direct-object-reference bug.

prune_read_before is the single exception, and it is scoped by read_at instead: it can only reach rows a recipient has already seen.

Ids are random

A NotificationId is 16 bytes from the OS randomness source, with no fallback — an id drawn from a clock is guessable, and Entropy is reported rather than worked around. store draws a fresh id and retries on a clash up to eight times before returning IdCollision; eight collisions on a 128-bit id is not chance, it is a randomness source that is not random.

Random rather than sequential because the id appears in the URL a “mark as read” button posts to. Guessing one still gets nobody anywhere, since every statement is recipient-scoped too, but it makes the two defences independent rather than one defence written twice.

NotificationId::from_hex(text) parses the 32-character spelling that arrives from a route parameter, returning None for anything that is not exactly 32 hex digits. to_hex() writes it back in lowercase; uppercase input parses to the same id.

Retention

Nothing expires on its own. An unread notification is still worth reading a month later, so how long an inbox keeps history is an application decision, made by calling prune_read_before(cutoff) on whatever schedule suits.

That sweep only ever touches notifications that were read. An inbox that quietly empties itself of things nobody has seen is worse than one that grows.

The broadcast channel

notifications-broadcast pushes to whoever is connected right now, over the realtime WebSocket and SSE machinery.

realtime offers one thing: a Broadcast, a bounded fanout where every subscriber receives every message. That is right for “the build status changed” and wrong for something addressed to a person — publishing notifications onto one shared Broadcast would hand every connected user every other user’s notifications.

So the broadcast channel is not a channel. It is a BroadcastChannels resolver: given a recipient key, hand back the Broadcast that recipient’s connections are subscribed to, or None.

pub trait BroadcastChannels: Send + Sync + fmt::Debug {
    fn channel_for(&self, notifiable_key: &str) -> Option<Broadcast>;
}

Targeting is then which channel the bytes go into, not a filter applied afterwards. There is no code path that puts one recipient’s payload into another’s channel, so there is no rule for a handler to remember.

If you write your own resolver — grouping by tenant, team or document — the contract is that everything subscribed to the returned channel is entitled to see this recipient’s notifications. A resolver that maps two people onto one channel to save an allocation has turned a targeted notification into a leak, and nothing downstream can detect it.

PerRecipientChannels

The built-in resolver: one Broadcast per recipient key, created when the first connection subscribes.

let channels = PerRecipientChannels::new(64).expect("capacity is non-zero");

// A websocket handler subscribes the connection it has accepted.
let subscription = channels.subscribe("user:1");
assert_eq!(channels.connections("user:1"), 1);

new(capacity) returns Option<Self> and gives None for a capacity of zero — a channel that can hold nothing drops every message. There is no default capacity; the argument is mandatory. It is per recipient, and it bounds how far one connection may fall behind before it starts missing messages. It does not need to be large: a notification the recipient missed is still in the inbox if notifications-db is on, and a connection thousands of notifications behind has a problem a bigger buffer postpones rather than solves.

Dropping the Subscription releases the connection. The map entry is reclaimed lazily, on a later call to subscribe, which keeps the drop path free of a lock. connections(key), len() and is_empty() report the shape; len() counts entries including unswept ones, so it is a metric rather than a count of who is online.

channel_for deliberately does not create. A resolver that created a channel per push would grow the map once per notification sent to someone offline — which is most of them — and none of those channels would have a subscriber to sweep it away.

Debug on PerRecipientChannels prints the capacity and the entry count, never the keys: the keys are recipient identifiers, and printing them would put a list of everyone currently online into a log line.

What reaches the browser

BroadcastNotifications::push(key, &content) publishes the JSON object {"kind": <kind>, "data": <data>} and returns how many connections received it. A subscriber gets those bytes verbatim. Nothing filters them on the way out, so a field the recipient should not learn does not belong in data even if the page would not display it.

Ok(0) means the recipient has no live connection. The only error is Encode, for a payload that could not be serialized. A recipient with no channel, a channel with no subscribers, and a channel whose last subscriber dropped between the lookup and the send all report zero.

The inbox and the push are complements

The push is what a recipient sees without reloading; the inbox is what they see when they arrive. A recipient who was offline missed the push and lost nothing, provided the inbox was written too.

An application enabling notifications-broadcast alone is choosing best-effort delivery.

Queueing the mail channel

Notifier::send talks to the SMTP server while the request is still open. Notifier::queue, behind notifications-queue, writes a job row instead and lets a worker do the talking.

let delivery = notifier.queue(&user, &InvoicePaid { amount_cents: 1250 }).await?;
assert!(delivery.is_queued(Channel::Mail));
assert!(!delivery.reached(Channel::Mail));

Wiring a queue changes nothing about send, which still sends inline. The two are separate methods so that a handler asking to defer is saying so, rather than finding out from whether startup happened to call with_queue.

Only mail is queued

The inbox row and the live push still run inline, in the same order send runs them. Both for reasons about correctness rather than taste:

  • The inbox is a write to the same database the job row goes into. Deferring it would buy nothing and cost the guarantee that matters: a recipient who opens the application immediately after the event would find an empty inbox, because the row they are looking for is sitting in a queue behind it.
  • The live push reaches the connections held by this process. A worker is a different process and holds none of them, so queueing a push is not deferring it — it is dropping it.

A queued send is an inline send with one thing moved: the part that leaves the machine.

What the latency claim is

The request stops waiting on SMTP. A connection, a TLS handshake, and a server that may itself be waiting on a DNS lookup become one INSERT into a table the request is already connected to.

The variation goes with it. How long an SMTP conversation takes depends on the address at the other end — whether the domain resolves, whether the server greylists, whether the recipient exists — and a handler that answers a registration form at a speed that depends on those things is telling anyone with a stopwatch which addresses are already taken. The enqueue costs the same for an address that will bounce as for one that will not.

It does not make the handler constant-time. The inbox write and the live push still happen inline, and password hashing — the usual reason a registration handler is timed — is somewhere else entirely. This removes one oracle, not the category.

An email can arrive twice

Jobs is at-least-once. A worker that hands a message to the SMTP server and then dies before marking the row complete leaves a job another worker will claim, and the message is sent again.

That is not a bug that can be fixed here. Handing bytes to a remote server and recording that you did are two operations in two systems, and no amount of care makes them one. Anything whose second delivery is harmful — a one-time code consumed on send, an email that charges a card — should not rely on the send being the only record that it happened.

The job, and the worker that runs it

MAIL_JOB is the shared identity: kind "arcature.notifications.mail", version 1, three attempts. It is public because the two halves live in different processes — the web process enqueues against it, the worker registers a handler for it — and a disagreement of one character would leave jobs sitting in the table with nobody to run them. The kind is namespaced under arcature. because the table is shared; an application’s own job called mail would otherwise collide.

// In the worker process.
use arcature::jobs::Registry;
use arcature::notifications::register_mail_handler;

let mut registry = Registry::new();
register_mail_handler(&mut registry, mail)?;

The Mail given to the worker need not be the one the web process was built with, and usually is not — once its mail goes through the queue, the web process may have no SMTP credentials at all. Registering twice returns RegisterError::AlreadyRegistered, because two registrations mean two transports for one job and which one wins is an accident of call order.

Retry classification is the one decision the handler makes. A message the transport could not build — a malformed address, a body that is not valid MIME — is permanent; nothing about waiting fixes an address that will not parse. Everything else is retryable, because SMTP reply codes are advisory and a 5xx from a misconfigured relay is not the recipient’s fault. Retrying a genuinely permanent failure costs two extra attempts; treating a temporary one as permanent costs the email.

What is stored in the row

QueuedMail holds the rendered email — to, subject, text and an optional html — not the notification.

Laravel serializes the notification object and re-renders it in the worker. That needs every notification to be serializable, plus a registry mapping a stored type name back to a Rust type, and it means the content is produced by whatever version of the code the worker is running, which during a deploy is not the version that decided to send it.

Here the render happens in the request, where it is a few string allocations, and what is stored is the result. Nothing new is required of Notification, a notification holding borrowed data queues as well as any other, and the email that arrives says what the code that sent it meant.

The cost is that the payload carries the body rather than a reference to it, so a large email is a large row. MAIL_JOB inherits the queue’s default payload limit, and an oversized payload is refused at enqueue rather than truncated.

QueuedMail::request() hands back the JobRequest, which is what an application needs to enqueue the mail in the same transaction as whatever caused the notification, via Jobs::enqueue_tx. Enqueueing outside that transaction is a job that runs for a change that then rolled back.

NoAddress is checked at queue time rather than in the worker. An address that does not exist is not going to appear by the time the job runs, and failing now puts the error in the request that caused it instead of in a dead job row. QueueNotConfigured is likewise loud rather than falling back to an inline send — the fallback would take exactly the latency the caller asked to avoid, and only under load, which is when it is least affordable and hardest to see.

arc make:notification

arc make:notification InvoicePaid

Writes app/notifications/invoice_paid.rs and registers the module in the sibling mod.rs.

The generated file implements all three channels, because every method on Notification defaults to None, which makes a channel nobody considered indistinguishable from a channel that was considered and declined. Deleting the two that do not apply is how the decision gets recorded.

The kind string is a named constant — const KIND: &str = "invoice.paid", the file stem with underscores turned into dots — with the reason beside it. It is not derived from the Rust type, so renaming the type stays free and changing the protocol stays a migration.

It is one of the few make: kinds whose output does not compile in a fresh application. arc new does not enable notifications, and the generator does not edit Cargo.toml: a generator that reaches into the manifest is a generator that can break a build it was never pointed at. The artifact’s notes name the feature instead, and add that to_database and to_broadcast render whatever the features are but need notifications-db and notifications-broadcast to deliver.

Limits

What needs a migration. Only notifications-db. It adds arcature_notifications and arcature_notifications_schema_migrations. Enabling the feature does not create them — call DatabaseNotifications::migrate() at startup or run the bundled SQL yourself. An inbox whose table is missing fails on the first notification, which is the same outage discovered later. Nothing else here touches the schema: the mail channel has no storage, the broadcast channel has none, and notifications-queue writes into the tables Jobs already owns through jobs.migrate().

What needs a worker process. Only notifications-queue. Notifier::queue writes a row and returns; unless something runs a Worker whose registry had register_mail_handler called on it, the rows accumulate and none of the emails are sent. Nothing warns.

arc queue work is not that something, and reaching for it is the mistake this paragraph exists to prevent. It builds a worker with an empty registry — it sweeps expired leases, marks jobs it has no handler for as dead, and prints a note saying so. Pointed at a queue of notification mail it will discard the rows rather than send them. Real dispatch is the application’s own in-process worker, through ApplicationBuilder::jobs.

Registering the handler is a second, separate step: enabling the feature and running a worker are not enough on their own, because a worker with no registration for arcature.notifications.mail leaves the rows exactly where they are — or, with arc queue work, does something worse than leave them.

The broadcast is per process, and there is no switch. Broadcast wraps a tokio::sync::broadcast, a channel between tasks inside one process. A push from instance A reaches only the connections held by instance A. Nothing errors and nothing warns — subscribers on instance B never see it. This bites notifications harder than the rest of realtime, because a notification is exactly the kind of thing an application sends from a background worker, and a worker holds none of the web process’s sockets: a push from a queue worker reaches nobody at all. Until a cross-process bridge exists, an application running more than one instance should treat the push as an optimisation over the inbox and enable notifications-db alongside it. The same limit, and the three honest ways to live with it, are set out in Deployment.

A failed send does not report what already succeeded. Delivery stops at the first failing channel and the whole call returns Err, so a mail transport failure after the inbox row was written gives you NotificationError::Mail and no Delivery. The row is still there. The channel order exists so that this is the recoverable direction, but the caller cannot learn from the error which earlier channels ran.

No deduplication and no delivery log. Sending the same notification twice writes two inbox rows and sends two emails. Delivery is returned to the caller and stored nowhere.

No preference storage. There is no opt-out table and no per-channel subscription model. The mechanism is returning None from a channel method for that recipient; where the preference is kept is the application’s decision.

No templating. MailContent takes strings. The HTML body is used verbatim, and nothing escapes what a caller interpolates into it — an email body is a good place to land a phishing link. Render it through a template engine that escapes.

Three channels, no extension point. Mail, database and broadcast are the methods on the trait; Channel is #[non_exhaustive] and a fourth would be added here rather than by an application. There is no SMS, push-notification or chat channel.

Nothing wires it for you. There is no Application::notifications. Build the Notifier and put it in state.

Realtime

WebSocket and Server-Sent Events over axum. One bounded tokio::sync::broadcast channel fans a payload out to every connection the process is holding.

The wrappers are thin on purpose. There is no Arcature realtime protocol, no X-Arcature-* headers, and no client package to install: a browser talks to this with WebSocket and EventSource, the two APIs it already has. The rejected alternative is the Pusher/Echo shape — a framed protocol carrying subscribe and unsubscribe messages, plus a JavaScript client to speak it. That buys channel multiplexing over a single socket, and it costs a second wire format to version, a package to publish and keep in step with the server, and a debugging story where the network tab shows you frames to decode instead of events to read. axum::extract::ws and axum::response::sse stay first-class escape hatches; when the wrapper is the wrong shape, drop to the thing it wraps.

Realtime is app-owned. The framework mounts no route, registers no service and holds no global. You build a Broadcast, a Registry and a ShutdownConfig once, put them in your state, and clone WebSocketEndpoint / SseEndpoint into handlers.

Turning it on

realtime is in default, so cargo add arcature already has it. The manifest entry is the whole of what it pulls:

realtime = ["dep:tokio", "dep:futures", "dep:bytes"]

Three optional dependencies, and on a default build all three are already present — tokio from macros, bytes and futures from storage-fs. So on the default feature set realtime adds no crate to the graph, only edges. On a default-features = false build that names realtime, it adds those three.

What it does not pull is the WebSocket implementation. The axum dependency enables ws unconditionally, so tungstenite compiles into every build of this crate whether realtime is on or off. Turning the feature off removes Arcature’s wrappers, not the protocol underneath them.

arcature::realtime is the module. Broadcast, SseEndpoint and WebSocketEndpoint are also re-exported at the crate root and from the prelude.

Broadcast and publishing

use arcature::realtime::{Broadcast, ChannelPayload};

// Capacity is a required argument. There is no default and no `Default` impl.
let broadcast = Broadcast::new(256).expect("capacity is non-zero");

let payload = ChannelPayload::from_bytes(serde_json::to_vec(&update)?);
let delivered: usize = broadcast.publish(payload)?;

Broadcast::new returns Option<Self> and gives None for a capacity of zero. A channel that can retain nothing drops every message, so handing one back as if it had worked would be worse than saying no. It never panics.

ChannelPayload is opaque bytes behind an Arc<[u8]>. The channel stores a value once and clones it per receiver, so the Arc makes that clone a refcount bump rather than a copy of the payload. There is no JSON helper and no envelope: serialize it yourself, and the bytes on the wire are the bytes you published.

publish has one result that surprises people:

Situationpublish returns
n subscribersOk(n)
no subscribersErr(ChannelError::Closed)
all Broadcast handles droppedErr(ChannelError::Closed)
buffer fullOk(n) — tokio overwrites the oldest retained message

An empty channel and a dead channel are the same error, because tokio::sync::broadcast::Sender::send reports failure when the receiver count is zero and publish maps every send failure to Closed. Treat Err(Closed) as “nobody was listening”, not as “something broke”. tests/realtime.rs pins this.

Broadcast::capacity() returns the number you passed. Tokio rounds the ring buffer up to the next power of two, and lag is measured against the rounded size, so a Broadcast::new(100) reports 100 and starts lagging receivers at 128.

ChannelError has a third variant, Full. Nothing in this crate constructs it. Both endpoints match on it, and neither arm can be reached.

broadcast.subscribe() hands back a Subscription whose recv() yields the next payload. Subscription also exposes its pub rx, the raw broadcast::Receiver, so it composes with tokio::select!. Broadcast::subscriber_count() is a separate atomic maintained by subscribe() and Subscription::drop, not tokio’s own receiver count — a receiver you conjure by calling rx.resubscribe() yourself is invisible to it.

Subscribing over SSE

use arcature::realtime::{OriginPolicy, Registry, SseEndpoint, SseLimits,
                         ShutdownConfig, VerifiedOrigin};

let sse = SseEndpoint::new(
    broadcast.clone(),
    OriginPolicy::allow_exact(VerifiedOrigin::from_trusted("https://app.example.com")),
    registry.clone(),
    SseLimits::conservative(),
    shutdown.clone(),
);

// In a handler:
sse.clone().handle(headers, channel_id).await

Admission is origin, then the connection limit. There is no authorizer on this path: SseEndpoint::new takes one Broadcast and every admitted request subscribes to it. The channel_id argument to handle is accepted and ignored. If SSE needs per-channel authorization, it belongs in a layer above the handler — an SSE request is a plain GET, so ordinary middleware works on it in a way it cannot for an upgrade.

The first event on the stream is a retry: preamble carrying SseLimits::retry_ms, which tells EventSource how long to wait before reconnecting. After that, each published payload becomes one data: event.

The payload is converted with str::from_utf8(..).unwrap_or(""). A payload that is not valid UTF-8 does not error, is not logged, and is not dropped: it becomes an event with an empty data field, which browsers ignore. Publish text to a channel with SSE subscribers on it.

Keep-alive is a :keep-alive comment every keep_alive_interval. It exists so proxies and load balancers with an idle-read timeout do not cut a quiet stream.

Subscribing over WebSocket

use arcature::realtime::{Authorizer, Broadcast, WebSocketEndpoint, WsLimits};

#[derive(Clone)]
struct DocumentAccess { /* ... */ }

impl Authorizer for DocumentAccess {
    fn authorize(
        &self,
        headers: &HeaderMap,
        channel_id: &str,
    ) -> impl Future<Output = Option<Broadcast>> + Send {
        let this = self.clone();
        let channel_id = channel_id.to_owned();
        async move { this.channel_if_permitted(&channel_id).await }
    }
}

let ws = WebSocketEndpoint::new(
    DocumentAccess::new(state),
    origin_policy,
    registry.clone(),
    WsLimits::conservative(),
    shutdown.clone(),
);

Authorizer returns Option<Broadcast>, not bool. That is the design decision in this module. Authorizing a connection and choosing its channel are the same call, so there is no code path where a request names a channel and gets subscribed to it without something having handed that Broadcast back. A channel name never implicitly authorizes, because a name is not what selects the channel. The rejected alternative — a predicate plus a name-to-channel lookup — puts the two halves in separate places, and then the lookup has to be trusted to have been guarded.

AllowAll implements the trait by admitting everything to one fixed channel. It is there for tests.

Admission is origin, then the authorizer, then the connection limit, and all three run before the upgrade. A refused request gets a clean HTTP status on an unupgraded connection rather than a socket that opens and then closes.

Once upgraded, the loop:

  • sends each published payload as a binary frame, not text
  • answers nothing the client sends. Close ends the loop and Pong refreshes the liveness clock. Every other inbound frame is matched and discarded. The server parses no client payload, so there is no request surface here.
  • pings every heartbeat_interval and closes when the last pong is older than heartbeat_timeout

The pong check is elapsed > heartbeat_timeout, evaluated only when the heartbeat fires, and the clock starts at connection open rather than at the first pong. With the defaults that means a client that never pongs is closed at the 60-second tick: at 20s and 40s the elapsed time is not yet greater than 40s, so both of those send another ping.

Note the asymmetry with SSE. The same publish call reaches a WebSocket client as opaque binary and an SSE client as a UTF-8 data: line. A payload that is valid UTF-8 arrives intact on both.

The connection registry and its cap

Registry is an Arc-backed counter plus a drain signal. It is the only thing that knows how many realtime connections a process is holding, and it is shared between the WebSocket and SSE endpoints.

The cap is not stored on the Registry. It is passed to Registry::acquire by the caller, and both endpoints pass ShutdownConfig::max_connections(). So the number lives on the shutdown config, and two endpoints built with two different ShutdownConfigs enforce two different numbers against one shared count.

acquire refuses when current >= max — at the cap, not past it. A cap of 100 admits 100 connections. It returns a ConnectionGuard, and dropping the guard decrements the count and wakes any drain waiter.

Both transports hold their guard for the whole connection. run_connection takes the guard as an argument and holds it for the life of the socket. SseEndpoint::handle carries its guard in the stream’s unfold state, so it lives exactly as long as the stream and drops when the client disconnects or the drain ends it. The cap therefore bounds concurrent connections rather than concurrent admissions, on both paths.

This was not always true of SSE. The line that should have held the guard was let _ = guard;, which drops it immediately — _ is not a binding — under a comment claiming the opposite, so the cap gated the instant of admission and nothing after it. It is fixed, and a_second_sse_stream_is_refused_while_the_first_is_still_held in tests/realtime.rs fails against the old code rather than merely passing against the new.

What a client sees when it lags

A subscriber that falls further behind than the (rounded-up) channel capacity has messages overwritten under it. Tokio reports this once, as RecvError::Lagged(n), and then resumes the receiver at the oldest message still retained — the connection is not closed and the subscription is not broken.

What reaches the client:

TransportOn lagMissed-message count
SSEa :lagged comment linenot sent
WebSocketnothing at all — the loop continuesnot sent

Subscription::recv maps RecvError::Lagged(n) onto a unit ChannelError::Lagged, so the count of dropped messages is discarded before either endpoint could report it. An SSE comment is invisible to EventSource: no listener fires for it, and a browser client cannot observe the lag through that API. A WebSocket client gets no signal whatsoever.

The practical consequence is that a lagging client silently has a hole in its stream. If the messages matter, they need a durable source the client can reconcile against — the pattern notifications-broadcast uses, where the live push is an optimisation over an inbox row that is still there.

Graceful drain

use std::time::Duration;
use arcature::realtime::{self, ShutdownConfig};

let shutdown = ShutdownConfig::new(1_000); // max_connections

// On shutdown:
realtime::drain(&registry, &shutdown, Duration::from_secs(10)).await?;

realtime::drain is two steps: shutdown.begin_drain(), then registry.drain(bound). begin_drain is idempotent — it flips an AtomicBool with swap and only notifies waiters on the transition, so calling it twice from two signal paths is safe. ShutdownConfig carries both the connection cap and the drain flag; it is Arc-backed and cheap to clone into every endpoint.

registry.drain(bound) returns Ok(()) immediately if the live count is already zero. Otherwise it waits for zero within bound and then re-reads the count, returning Err(RealtimeError::Shutdown { remaining }) if any connections are still live. The waiter is woken by each guard drop and also re-checks every 100ms, so a missed notification costs latency rather than a hang.

How each transport responds:

TransportResponse to begin_drain
WebSocketdrain_notified() is a select! arm, so the loop sends a Close frame and exits at its first pass
SSEthe flag is read once per event, before the wait for the next payload

A new connection during a drain is not refused. Neither handle checks is_draining() during admission, so an arriving WebSocket is upgraded and then immediately closed, and an arriving SSE request gets its retry: preamble followed by end-of-stream — which, with the default 3-second retry hint, means EventSource reconnects into the same outcome three seconds later. Stop accepting realtime requests at the router or the load balancer if that reconnect loop matters.

Defaults

Everything the two conservative() constructors set, and the two values that have no default at all.

SettingDefaultSource
Channel capacitynone — required argument to Broadcast::new; None for 0Broadcast::new
Connection limitnone — required argument to ShutdownConfig::newShutdownConfig::new
Origin policyDenyAll (the Default impl)OriginPolicy
SSE retry hint3000 msSseLimits::conservative()
SSE keep-alive interval15 s, sent as a :keep-alive commentSseLimits::conservative()
WS max message size65536 bytes (64 KiB)WsLimits::conservative()
WS max frame size65536 bytes (64 KiB)WsLimits::conservative()
WS heartbeat interval20 sWsLimits::conservative()
WS pong timeout40 s (strict >, checked on heartbeat ticks)WsLimits::conservative()

SseLimits, WsLimits and ShutdownConfig have no Default impl. Ask for conservative() or fill in the fields; the numbers above are not applied to anything you did not construct. Registry::default() exists and is an empty registry — it carries no cap of its own.

Statuses a refused connection gets, from admission_status:

RefusalStatus
Origin denied403 Forbidden
Authorizer returned None (WebSocket only)403 Forbidden
At or above the connection limit503 Service Unavailable

The mapping is an explicit function rather than an IntoResponse impl, so a refusal returns a status and no body. A denied origin and a failed authorization are deliberately the same status: the difference between “you are not from here” and “you may not have this channel” is information the caller does not need.

OriginPolicy defaults to DenyAll, and a policy with an allow-list denies a request that carries no Origin header at all. VerifiedOrigin::from_header rejects a non-ASCII value; from_trusted validates nothing, which is the point of the name. Matching is exact string equality — an origin is public, so constant-time comparison is not wanted here.

Limits

Fan-out reaches one process, and there is no switch. Broadcast wraps a tokio::sync::broadcast channel, which is a channel between tasks inside one process. A message published on instance A reaches only the WebSocket and SSE subscribers connected to instance A. Nothing errors and nothing warns: subscribers on instance B never see it, because the channel delivered correctly to everyone it can see and it cannot see the other process. With two instances and clients spread evenly, roughly half of every broadcast is missing from a given client’s view. This is the one limit here with no configuration switch — sessions share through session-store-db and rate limiting shares through RateLimit::redis, and realtime has no equivalent. Deployment lists the three honest ways to live with it: run one instance, pin realtime upgrades to one instance, or write the bridge by hand. A Redis pub/sub bridge is the obvious general answer and is deliberately not written, because its delivery semantics, ordering and back-pressure would have to be decided rather than inherited.

Publishing from a job worker reaches nobody. It is the same limit with a sharper edge: a worker is a different process, and it holds none of the web process’s sockets.

An idle SSE stream does not notice a drain. The is_draining() check runs once per event, immediately before the wait for the next payload. A stream parked in that wait when the drain begins stays parked. It ends after delivering one more payload, or when the last Broadcast handle drops and the channel closes. The keep-alive tick does not help: axum emits the keep-alive comment when the inner stream is pending and does not re-enter it, so from the client’s side the connection keeps looking healthy while the server is trying to shut down. Because the stream holds its connection guard for its whole life, the registry does count it, so a parked stream does not vanish from the drain — it stalls it. realtime::drain waits out its full bound and then returns Err(RealtimeError::Shutdown { remaining }) naming the streams still open, rather than returning Ok(()) while they linger.

The WebSocket path copies each payload per subscriber. The Arc<[u8]> keeps the channel’s own fan-out cheap, but the send does Bytes::from(payload.as_bytes().to_vec()), which allocates and copies once per connection per message.

Three public variants are unreachable from inside this module. ChannelError::Full, RealtimeError::Protocol { hint } (and every ProtocolHint) and RealtimeError::Channel(_) are declared, matched or mapped to a status, and never constructed by any code in the crate. They are usable by an application that constructs them; do not write a handler that waits for the framework to hand one over.

What this module does not do

No message history and no replay. A subscriber receives what is published after it subscribes, and a lagging subscriber’s missed messages are gone. If a client needs to catch up after a reconnect, that comes from your database, not from here — there is no Last-Event-ID handling and no id: field on the events.

No presence, no rooms, no channel registry. There is no list of who is connected, no join or leave notification, and no server-side directory mapping names to channels: a Broadcast is a value your application holds, and the Authorizer is the only thing that turns a name into one.

No client-to-server messages. The WebSocket loop discards every inbound frame except Close and Pong. This is one-directional fan-out, and a client that needs to tell the server something should post to a route.

No routes and no automatic wiring. The framework mounts nothing at /ws or /events, registers no service, and reads no configuration. Every value in this chapter is one your application constructs and stores.

No cross-process bridge, as above. No ordering guarantee across channels — ordering holds within one Broadcast, which is all a single ring buffer can promise. No backpressure on the publisher: publish never blocks and never fails because a subscriber is slow; the slow subscriber lags instead.

API

RFC 9457 problem details for errors, and an OpenAPI 3.1 document derived from the application graph. Two subjects behind two features, sharing one rule: both are generated from what the code already declares, never written a second time by hand.

Turning it on

arcature::api compiles unconditionally. Problem, ProblemBuilder, ProblemKind and PROBLEM_JSON exist in a build with no features at all. The reason is the validation subsystem: it answers a failed #[validate] with a Problem, and a feature gate under Problem would make validation depend on api.

FeatureIn defaultWhat it adds
nonearcature::apiProblem, ProblemBuilder, ProblemKind, PROBLEM_JSON
apiyesProblem and ProblemKind in the prelude, http::json(), Bound<T> (with dx + database), TestResponse::assert_problem (with test-kit). Pulls http and validator.
api-docsnoapi + uag, which is what compiles arcature::uag::codegen::openapi

api-docs is in neither default nor fullstack. An API description is a map of the attack surface, so it is named explicitly or not at all.

A problem document

use arcature::{Problem, ProblemKind};

Problem::of(ProblemKind::NotFound)
    .with_detail("user 42 does not exist")
    .with_instance("/users/42")
{
  "type": "urn:arcature:problem:not-found",
  "title": "Resource not found",
  "status": 404,
  "detail": "user 42 does not exist",
  "instance": "/users/42"
}

The IntoResponse impl sets the status from the problem, Content-Type: application/problem+json, and Content-Length.

MemberComes fromOmitted when
typekind.type_uri(), or the URI given to customnever
titlekind.title(), or the status reason phrase for customnever
statuskind.status(), or the status given to customnever
detailwith_detail(..) / .detail(..)not set
instancewith_instance(..) / .instance(..)not set

Three constructors:

ConstructorFor
Problem::of(kind)one of the distinguished categories
Problem::builder(kind)the same, chained, finished with .build()
Problem::custom(type_uri, status)a category outside the list

Problem::custom takes the title from the status reason phrase — StatusCode::PAYMENT_REQUIRED gives "Payment Required" — falling back to "Request error" for a status with no canonical reason. Pass "about:blank" as the type when the status is the whole story.

This is not a { "success": false, "message": "..." } envelope, and it is not mandatory. A handler returning any IntoResponse is free to ignore Problem entirely.

Extensions

Anything beyond the five standard members is an extension member, serialized flat alongside them. with_extension(key, value) and .extension(key, value) add one; with_extensions(&value) and .extensions(&value) add every top-level pair of a value that serializes to a JSON object.

Four things are dropped in silence:

DroppedWhy
a key equal to type, title, status, detail or instancean extension must never be able to rewrite a standard member. A key that could set status to 200 on a 500 is the attack.
a value that serializes to JSON nullabsent and null say the same thing
a value whose serialization failsa response is not the place to discover it
a with_extensions argument that is not a JSON objectthere are no top-level pairs to take

Extensions live in a BTreeMap<String, Value>: one entry per key, last write wins.

The detail member must be short and client-safe. Nothing in Problem::of, Problem::custom or the IntoResponse impl adds server-side context, so what leaves is what you put in. Extension members are entirely the application’s responsibility.

If serializing the whole document fails, the body falls back to a fixed urn:arcature:problem:internal string rather than panicking. The status line is still the problem’s own status; only the body is replaced.

Problem derives Debug and Clone, and implements Serialize by hand. It is not PartialEq and not Deserialize. ProblemKind derives Debug, Clone, Copy, PartialEq and Eq.

The kinds

VariantStatustitletype
BadRequest400Bad requesturn:arcature:problem:bad-request
MalformedJson400Malformed JSON request bodyurn:arcature:problem:malformed-json
Authentication401Authentication requiredurn:arcature:problem:authentication
Authorization403Access deniedurn:arcature:problem:authorization
NotFound404Resource not foundurn:arcature:problem:not-found
MethodNotAllowed405Method not allowedurn:arcature:problem:method-not-allowed
Timeout408Request timed outurn:arcature:problem:timeout
Conflict409Request conflicts with current stateurn:arcature:problem:conflict
PayloadTooLarge413Request body too largeurn:arcature:problem:payload-too-large
UnsupportedMediaType415Unsupported media typeurn:arcature:problem:unsupported-media-type
Validation422Validation failedurn:arcature:problem:validation
RateLimit429Rate limit exceededurn:arcature:problem:rate-limit
Internal500Internal server errorurn:arcature:problem:internal
Unavailable503Service unavailableurn:arcature:problem:unavailable

ProblemKind::ALL is the same fourteen as a &'static [ProblemKind], kept by hand so that adding a variant without adding it there fails a test rather than quietly narrowing what the tests check.

The type values are URNs, not URLs. The rejected alternative was an https:// URI under a docs domain, which reads better and promises a page that has to stay alive at that exact address for as long as any client is running. RFC 9457 permits a type that does not dereference, and a client is required to treat an unknown one as about:blank, so the URN costs nothing and commits to nothing.

The list is closed. An application-specific category is Problem::custom, not a new variant.

Turning a bare status into a kind

ProblemKind::for_status(status) -> Option<ProblemKind> is what gives a status a body when whatever produced it did not.

StatusResult
any status in the table above, except 400that row’s variant
400BadRequest, never MalformedJson
anything else — 402, 418, 502, 504None

The mapping is partial on purpose. 400 resolves to the generic kind because a bare 400 arriving from a layer is not evidence about JSON, and a status with no distinguished kind gets a generic document rather than being pushed into a category it does not belong to.

How a framework error becomes a problem response

There are two paths through the framework, and they are not the same code.

Errors a layer produced: ErrorMapping

Most error responses in a Rust web stack come from something other than the application. Axum answers an unmatched path with a bare 404; tower-http answers an oversized body with a bare 413 and an expired deadline with a bare 408. Bare is literal: status line, no Content-Type, no body. A fetch() caller gets "" to parse.

ErrorMapping is stage 11 of the pipeline. It is not installed by default — the slot is None until .error_mapping(..) is called — and the application arc new generates calls it:

use arcature::http::ErrorMapping;

Application::<AppState>::new()
    .catch_panic()
    .error_mapping(ErrorMapping::new())

It sits inside the panic catcher and outside the body limit, the timeout, the session, CSRF and the router, so it sees the responses it exists to dress and a mapped response is still compressed, still carries the security headers, and is still logged under its real status.

Precedence, in order:

  1. A custom mapper from ErrorMapping::with(..), if it returns Some.
  2. Redaction, if the response is a text/plain 5xx and redaction is on.
  3. A problem body, if the response has no Content-Type at all.
  4. Otherwise the response is passed through untouched.

Anything that is not a 4xx or a 5xx is returned untouched before any of that runs.

A replacement keeps every header the original carried except Content-Type and Content-Length. That matters more than it looks: a 405 carries Allow, a 429 carries Retry-After, a 401 carries WWW-Authenticate. Those are the parts a client acts on, and dropping them to deliver a nicer body would be a bad trade.

ErrorMapping::with(..) is handed the status and the headers of the request, never the response body. Reading the body would mean buffering every error response, and a mapper that needs it is a handler. The request headers are what content negotiation actually wants — Accept, X-Requested-With, X-Inertia — so a mapper can answer HTML to a browser and a problem document to everything else.

Errors a handler returned: Error

A controller returns Result<Response>, whose error type is arcature::Error. Its own IntoResponse does not build a Problem.

VariantStatuscode
NotFound404not_found
BadRequest400bad_request
Unauthorized401unauthorized
Forbidden403forbidden
Validation422validation_failed
Redirect400invalid_redirect
Io500io_error
Database500database_error
Cache500cache_error
Storage500storage_error
Mail500mail_error
Job500job_error
Serialization500serialization_error
Config500config_error
Other500internal_error

The body is application/json — not application/problem+json — with type set to urn:arcature:problem: plus the code above. Those codes carry underscores, so Error::NotFound produces urn:arcature:problem:not_found while ProblemKind::NotFound produces urn:arcature:problem:not-found. Two different strings for the same idea.

Its redaction is keyed on the APP_ENV environment variable, read at response time: production or prod (case-insensitive) emits type, title and status and stops; anything else, including an unset variable, adds detail from the error’s Display, which for Error::Database is the underlying driver message. Because the body is application/json, ErrorMapping passes it through unchanged.

Redaction

ErrorMapping::new() sets redaction to !cfg!(debug_assertions).

Buildredacts()
cargo build, cargo testdebug-assertions onfalse
cargo build --releasedebug-assertions off by defaulttrue

ErrorMapping::redact_errors(bool) overrides it in either direction. true in a development build is how a test asserts that nothing leaks.

Keying on debug_assertions rather than on an environment variable is the decision. The rejected alternative reads APP_ENV, which means a production binary can be talked into leaking by whoever can set a variable on the host, with no redeploy and no diff. A compile-time key is decided by the build that produced the artifact.

What the stage does to a 4xx or 5xx:

Response leaving the stageResult
no Content-Type at allreplaced with a problem document
405, 408 or 413 with text/plainreplaced, whether redaction is on or off
any other 5xx with text/plainreplaced when redacts() is true
a 4xx with text/plainuntouched
any status with text/html, application/json, application/problem+json, or anything elseuntouched

405, 408 and 413 are the three statuses that, inside this pipeline, come from a layer rather than a handler, so a text/plain body on one of them is a library’s string — length limit exceeded — and not a message anyone wrote for this application’s clients. A handler that returns one of those itself has its body replaced too. That is a smaller loss than leaving an API client with an unparseable sentence.

A text/plain 4xx is left alone because it is a message written for the client, and deleting it would delete the explanation.

The narrowness is deliberate: a 5xx carrying HTML or JSON is a body somebody chose, and only the shape nothing chooses on purpose gets replaced. The cost of that choice is stated under what this deliberately does not do.

Panics are separate. .catch_panic() (stage 10, also opt-in) answers with Problem::of(ProblemKind::Internal) and discards the payload entirely — no detail, in any profile. A panic message is written for a developer reading a backtrace and routinely contains a path, a SQL fragment, or the value that caused it. The operator still gets all of it from tower-http’s log.

Building an API resource

use arcature::resource;

#[resource]
pub struct LinkResource {
    pub id: String,
    pub url: String,
    pub title: Option<String>,
}

#[resource] takes no arguments, requires named fields, and emits three things:

  1. the struct unchanged, with a #[derive(Serialize)] added;
  2. impl inertia::ClientData, whose exposure_schema() is built from the named fields — the explicit browser-exposure opt-in;
  3. impl ResourceMetadata, the same fields as a &'static [FieldShape], which routes! resolves when a route declares query: T.

It generates no PAGE_CONTRACT. A resource is a value nested inside page props, not a page. It needs macros, dx and inertiaClientData lives in the Inertia module.

A SeaORM entity is not a resource. Convert explicitly with impl From<Link> for LinkResource. The reason is that Serialize is not a safety boundary: a field whose type is not a recognised primitive maps to PropsSchema::nested::<T>, which requires T: ClientData, so an internal domain model nested inside a resource fails to compile. Deriving exposure from Serialize would make every model that can be logged also a model that can be served.

Returning one:

Return typeResponseFeature
Json<T>T as JSON, application/json, Content-Length setdx
Empty204, empty bodydx
Problemthe document, application/problem+jsonnone
json(value)the same as Json, as a free functionapi or inertia

Declaring the shape at the route:

KeyMeansConstraint
action: Tthe request body type; resolves T: RequestMetadata, which #[request] emitsa non-safe method. A GET is a compile error.
query: T or query: Vec<T>the response type; resolves the element’s ResourceMetadataGET only. A POST is a compile error.
query_string: Tthe typed query string of a query routerequires query: on the same route

action: and query: on one route is a compile error: a route mutates or it reads.

routes! {
    pub api {
        state: AppState;
        get  "/links"        => LinksController::index { name: links.index, query: Vec<LinkResource> }
        get  "/links/{link}" => LinksController::show  { name: links.show,  query: LinkResource }
        post "/links"        => LinksController::store { name: links.store, action: StoreLinkRequest }
    }
}

Bound<T> loads a model from the database by a route parameter and answers with a problem when it cannot:

use arcature::{Bound, Json, Result};

async fn show(link: Bound<Link>) -> Result<Json<LinkResource>> {
    let link = link.into_inner();
    // authorize here -- binding did not
    Ok(Json(LinkResource::from(link)))
}
FailureKindStatus
the request has no path parametersBadRequest400
no parameter named T::KEY_PARAMBadRequest400
the value will not parse as T::KeyBadRequest400
T::load returned an errorInternal500
T::load returned NoneNotFound404

Binding is not authorization. Bound<T> proves the row exists; whether this caller may see it is a policy check the handler still owes. That invariant is permanent — the alternative, an extractor that also authorizes, would make every route’s access rule invisible at the route.

Bound<T> needs dx + database + api together. It reads the database handle through DbFromState, not axum::extract::FromRef, to avoid orphan-rule conflicts in application state types.

The OpenAPI document

api-docs turns on uag, and the document is generated from the UAG — the same deterministic artifact behind arc routes and arc typegen.

There is no utoipa and no annotation on the handler. Everything in the document already exists in the route descriptor: routes! baked the request and response field shapes in, and #[validate(...)] rules travel with the fields. The rejected alternative is attributes above each handler, which is a second source of truth and is wrong the first time someone renames a field in one place.

use arcature::uag::build;
use arcature::uag::codegen::openapi::{self, OpenApiOptions};

let artifact = build(&app::graph(), &app::page_contracts());
let document = openapi::generate_json(&artifact, &OpenApiOptions {
    title: "Acme API".to_owned(),
    version: "2026.8".to_owned(),
    description: None,
})?;

generate returns a serde_json::Value; generate_json returns pretty JSON. OpenApiOptions::default() is title "Arcature application", version "0.0.0", no description. There is no generated_at and no timestamp anywhere: a timestamp would make every regeneration a diff, which is the one thing a derived artifact exists to avoid.

Top level:

KeyContents
openapithe const "3.1.0"
infotitle, version, and description when set
pathsone item per path, keyed by lowercase method
components.schemasone entry per named action: and query: type; absent when there are none

Per operation:

KeySourceAbsent when
operationIdthe route’s name: verbatim; otherwise the lowercase method followed by the path with every non-alphanumeric replaced by _never
tagsa single tag, the module namethe route has no module name
parametersone in: path per path parameter, plus one in: query per query_string: fieldthere are none
requestBodyapplication/json, required: true, the action: schemathere is no action:
responsesbelowsee below
The route declaresresponses
query: T200, application/json, $ref to T
query: Vec<T>200, application/json, an array of $ref to T
page: / pages: and no query:200, text/html, no schema
neitherthe key is omitted entirely

An omitted responses is valid OpenAPI 3.1 and is the honest statement. Claiming a 200 for a handler that redirects would make the document worse than silence.

Axum and OpenAPI already agree on {name}, so only the wildcard marker is rewritten: /files/{*rest} becomes /files/{rest}.

Rust types reach JSON Schema through one mapping, shared with the TypeScript emitters:

RustJSON Schema
String, str, char{"type": "string"}
any integer or float{"type": "number"}
bool{"type": "boolean"}
Vec<T>{"type": "array", "items": T}
Option<T>{"anyOf": [T, {"type": "null"}]}, and the field is left out of required
anything else{}

References and lifetimes are stripped and a path is reduced to its last segment, so &'a std::string::String and String map the same. Integer width is not carried, because JSON has one number type and pretending a u64 survives JavaScript intact would be a claim the generated types cannot back up. An unrecognised type becomes the empty schema, which accepts anything — the honest statement about a type the mapping does not model.

Option<T> becomes anyOf rather than an omitted key because serde writes an absent Option as null. Requiredness is the separate fact recorded in the object’s required list.

Validation rules become constraints only where the translation is exact:

RuleBecomes
email"format": "email"
url"format": "uri"
length(min, max) on a stringminLength / maxLength
length(min, max) on a VecminItems / maxItems
length(equal = n)both bounds set to n
range(min, max)minimum / maximum
anything else, regex(...) and custom(...) includednothing

A non-numeric argument is skipped rather than coerced. regex(...) names a Rust const, not a pattern the document could carry, and a constraint stated wrong is worse than one left out, because a generated client enforces it.

Constraints land on the non-null branch: Option<String> with length(max = 5) is anyOf: [{string, maxLength 5}, {null}], not a maxLength on the union.

What this deliberately does not do

Nothing serves the document. The api-docs comment in Cargo.toml names /_arcature/openapi.json and /_arcature/docs. Neither route exists. No source file in the crate is compiled under cfg(feature = "api-docs"), so the feature’s entire effect today is to enable api and uag. Producing the document means calling openapi::generate_json yourself, from a binary or a test you write.

No arc command emits it. arc typegen writes four files to resources/js/generated/routes.ts, pages.d.ts, forms.ts, index.ts — and the OpenAPI document is not one of them.

The document describes success only. The generator emits exactly one response, a 200, and only for a route that declares query: or a page. Not one of the problem documents in this chapter appears in it: not the 422 from a validated extractor, not the 404 from Bound<T>, not the 429 from the rate limiter. A client generated from it has no error types.

No security, no servers, no securitySchemes. A route’s policy: and policies: are in the artifact and the generator does not read them. The document does not say which routes need authentication or what they need.

No summaries, descriptions or examples per operation. Rust doc comments are not in the route descriptor, so there is nothing to copy across.

Path parameters are always strings. {"type": "string"} for every one, whatever the handler parses it into. The descriptor carries the name, not the type.

A request body is always application/json. A route whose action: type arrives as a form submission is still described as JSON.

Redaction does not cover a JSON body. Only text/plain on a 5xx is replaced. A 500 whose body is application/json or application/problem+json is passed through in every profile. Bound<T> produces one of those: its database-error branch is a ProblemKind::Internal problem whose detail is "database error: " followed by the driver’s message. ErrorMapping::with(..) runs ahead of the redaction check and is the place to catch it.

Error is not Problem. A handler error takes the second path described above: application/json, an underscored type URI, and redaction keyed on APP_ENV at response time rather than on the build. An unset APP_ENV is the non-production branch, so it includes detail. TestResponse::assert_problem fails on such a response twice over, on the content type and on the type URI, which is the fastest way to notice which path a route is on. The two errors shapes differ too: validation_problem writes an object keyed by field name holding [{ "code", "message"? }], while Error::Validation writes an array of { "field", "message" } — and writes no errors member at all on the production branch.

The type URIs do not resolve. They are URNs. There is no page behind urn:arcature:problem:not-found and none is planned.

ErrorMapping is not on unless asked for. The pipeline slot is None until .error_mapping(..) is called. arc new calls it; an application that assembles its own builder and does not gets bodiless 404s and no redaction at all.

No content negotiation, and no mapper shipped. Everything is a problem document whatever the request’s Accept header, so a browser hitting an unmatched path receives JSON. ErrorMapping::with(..) exists precisely to fix that, and receives the request headers for that purpose, but the framework ships no HTML error page to install.

No response envelope. Json<T> writes T and nothing around it — no data key, no meta, no links, no sparse-fieldset or filtering vocabulary. paginate(per_page).page(n) hands back rows and page_with_count hands back rows and a total; shaping those into a response is the resource’s job, because an envelope the framework picked would be one every client then has to unwrap.

Problem cannot be parsed back. It implements Serialize and not Deserialize. A test or a Rust client reads a problem response as serde_json::Value, or through assert_problem.

Observability

Request ids, newline-delimited JSON logs, an access log, a Prometheus text endpoint, W3C trace context, and OTLP export. All of it sits on tracing, which the framework re-exports as arcature::observe::tracing so downstream code targets the pinned version through Arcature.

Nothing here installs itself. There is no global recorder, no global tracer provider, and no subscriber the crate registers on import — a JsonLog sink, a Metrics registry and a Telemetry pipeline are values the application holds and clones, and the subscriber is installed by a call the application makes from main. The rejected alternative is the usual one: a library that grabs the global subscriber when it is linked. It costs the binary the ability to choose, and it costs a test the ability to capture only its own output.

Turning it on

# Logs, request ids, the access log, the Prometheus registry, trace context.
arcature = { version = "0.1", features = ["observe"] }

# The same, plus OTLP span export. `otel` implies `observe`.
arcature = { version = "0.1", features = ["otel"] }
FeatureGives youPulls
observeinstall_logging, JsonLog, RequestId + RequestIdLayer, AccessLogLayer, Metrics + MetricsLayer, TraceContext + TraceContextLayer, redacttracing, tracing-subscriber, uuid
otelTelemetry, TelemetryBuilder, the observe::otel moduleobserve, opentelemetry, opentelemetry_sdk, opentelemetry-otlp, tracing-opentelemetry
Whereobserveotel
framework defaultonoff
framework fullstackonoff
generated applicationonoff, and arc new scaffolds nothing for it

otel = ["observe", ...], so it is an addition and never an alternative. It is an operator opt-in: four crates and the gRPC stack under them enter the graph, and most applications never enable it.

otel adds the OTLP span exporter and nothing else. The Prometheus endpoint belongs to observeobserve::metrics is not gated on otel, as the comment above the feature in Cargo.toml also says — so a default build already has it.

tracing-subscriber is pinned with registry and fmt and nothing else. ansi is off, so colour is unavailable rather than disabled; env-filter is off, so the filter is Targets rather than EnvFilter. json is off too, which is why the JSON formatter in this module is written by hand. That last one is not a saving, it is the design: the layer owns the serialisation, so redaction runs on every field with no way for a caller to opt out of it.

Installing the subscriber

// The first line of the generated application's `run`, before anything that
// might have something to say.
arcature::observe::install_logging(LOG_FILTER).map_err(std::io::Error::other)?;

install_logging(default_filter) -> Result<(), ObserveError>. Call it once, from main, before anything that might log. tracing events go nowhere at all until a subscriber exists: without this call the access log runs on every request and emits into the void, and a job that fails does so without a line anywhere. Nothing errors — the process is quiet, which is the worst way for logging to be broken, because it looks like nothing is happening.

The format is chosen by build profile, not by an environment variable, on the grounds that the shape of a log line is a property of the build:

BuildLayerRedaction
debug (cfg!(debug_assertions))tracing_subscriber::fmt, no ANSI, target shownnone
releaseJsonLog on StderrSinkevery field

Both write to standard error, so a log line never interleaves with what the process writes to standard output.

The redaction column is not a typo, and it is the single most misread thing in this module. install_logging installs JsonLog in release builds only; a debug build gets the fmt layer, which prints every field it is given, verbatim, including one named password. An application that wants redaction while developing composes its own subscriber with JsonLog in it (see the sinks) rather than calling install_logging.

default_filter is used when RUST_LOG is unset. The variable name is in FILTER_ENV, and it is RUST_LOG and not ARCATURE_LOG because every Rust operator already knows the name.

RUST_LOGdefault_filterResult
unset, or blankparsesthe default
set, parsesthe variable
set, does not parseparseswarning on stderr, then the default
unsetdoes not parsewarning on stderr, then info

A typo in a log filter must not stop a process from booting, and an application whose own hard-coded default does not parse has a bug that must not be silent either — hence a warning in both directions and a running process in both directions.

Targets rather than EnvFilter is a deliberate trade. EnvFilter brings a regex engine along for span-field matching that a web application almost never uses; Targets reads the target=level syntax people actually write — info, info,sqlx=warn, my_app=debug — and costs no additional dependency.

install_logging returns Err(ObserveError::Logging) if a global subscriber is already installed. That is a real error rather than a no-op: the second caller’s configuration is being discarded, and the honest response is to say so.

The generated application calls it on the first line of run, with a filter chosen the same way:

BuildLOG_FILTER
debuginfo,<app>=debug,arcature=debug
releaseinfo

What a log line looks like

One JSON object per line, no pretty printing, no trailing state. Every mainstream shipper reads that without configuration.

{"fields":{"client_ip":"203.0.113.9","duration_ms":7,"method":"GET","path":"/dashboard","request_id":"6f1e6f8c-6e5e-4a2b-9a24-6f3a2f0f1c77","status":200},"level":"INFO","message":"GET /dashboard 200 7ms","target":"arcature::observe::access_log","timestamp":"2026-08-23T10:15:04.220Z"}
KeyPresentValue
timestampalwaysRFC 3339 UTC, millisecond precision, always Z
levelalwaysTRACE / DEBUG / INFO / WARN / ERROR
targetalwaysthe emitting module path
messagewhen the event recorded onethe formatted message, lifted out of the fields
spanswhen the event is inside an entered spanspan names, outermost first
fieldswhen there is at least onethe event’s fields, plus inherited span fields

Keys are serialised from a BTreeMap, so they come out in alphabetical order — both at the top level and inside fields. Nothing depends on that; it is what a reader will see.

message is only another field to tracing. Lifting it to a top-level key is what makes the line readable to a human and indexable by everything else.

Span fields are folded into each event inside the span, with a nested span’s field beating its parent’s and the event’s own field beating both. The timestamp is written by hand — Howard Hinnant’s civil_from_days plus a format string — because chrono belongs to the database feature and a logging layer must not drag a database dependency behind it.

JsonLog::without_span_fields() drops the inherited fields and logs only what the event itself recorded. Smaller lines, at the cost of losing whatever the enclosing span was carrying.

Sinks

LogSink is one method, write_line(&self, line: &str), and the line arrives without its terminator. A trait rather than std::io::Write because a sink is shared across threads and must serialise whole lines: a writer that interleaved two events would produce unparseable output.

SinkUse
StderrSinkstandard error, one writeln! under the lock
CaptureSinkkeeps lines in memory; cloning shares the buffer

CaptureSink is how a test asserts on its own output without touching a global:

use arcature::observe::{CaptureSink, JsonLog};
use tracing_subscriber::layer::SubscriberExt as _;

let sink = CaptureSink::new();
let subscriber = tracing_subscriber::registry().with(JsonLog::new(sink.clone()));
tracing::subscriber::with_default(subscriber, || {
    tracing::info!(user = "ada", password = "hunter2", "signed in");
});
assert!(sink.transcript().contains("[redacted]"));

lines() returns the vector, transcript() joins it. A poisoned mutex is recovered from rather than propagated in both sinks: a panic in one log call must not silence every later one.

The request id

RequestId is a validated, low-cardinality identifier. RequestIdLayer resolves it once per request and puts it in request extensions, and every response carries it back as x-request-id — the wire-compatible name, with no X-Arcature-* prefix, so a reverse proxy and a client library both already understand it.

QuestionAnswer
Where fromthe inbound x-request-id header, if it parses; otherwise a fresh UUID v4
CharsetASCII alphanumerics plus - _ . : @ + / =
MaximumMAX_REQUEST_ID_BYTES, 128 bytes
Rejected inputempty, oversized, or a disallowed byte
On rejectiona fresh id is generated — RequestId::from_header never errors
Responsex-request-id, on every response the layer sees

Reusing the upstream value is what makes a trace survive the hop from a reverse proxy. Validating it is what stops the same header from becoming an injection point into every log index downstream: RequestId::parse_str enforces the allow-list, and hostile input is replaced rather than reported, because failing a request over a malformed correlation id would be a denial of service with no upside.

RequestIdError is Empty, TooLarge { size, limit } or InvalidChar, and only the parsing entry points return it.

How it reaches a log line

AccessLogService reads the id out of request extensions and records it as the request_id field on the access line it writes. That is the path, and it is one hop: the id reaches the access line because the access line writes it, not because anything is inherited.

Two consequences worth being exact about:

  • The order matters. RequestIdLayer must run outside AccessLogLayer or the id is not in extensions when the access line is written. The pipeline does this by construction — request id is stage 8 and the access log is stage 9 — and switching the request id off while leaving the log on produces lines with an empty id rather than an error.
  • A handler’s own tracing::info! does carry the request id. AccessLogService attaches its arcature.request span to the inner call with Instrument, so the span is entered exactly while that future is polled and a handler’s own events inherit request_id, method, path and client_ip. Instrument rather than a span.enter() guard: in async code a guard stays entered across every await point, including the ones where the task is parked and another request is running on the thread, which would attribute other requests’ lines to this one. tests/observe_request_span.rs pins both halves — the access line carrying the id, and a handler event inheriting it.

Both layers are off unless asked for:

Application::new()
    .request_id()
    .access_log()

The access log

One tracing event per request, at INFO, from arcature::observe::access_log. The message is "{method} {path} {status} {duration}ms" and the structured fields are:

FieldTypeValue
methodstringthe request method
pathstringuri.path() — the path only
statusnumberthe response status as u16
duration_msnumberwhole milliseconds, truncated
request_idstringthe resolved id, or "" if RequestIdLayer did not run
client_ipstringthe resolved ClientIp, or "" if nothing resolved one

That is the whole line. No request body, no response body, no headers, and no query string.

The query string is the interesting omission. It is the part of a URL that ends up in every proxy log on the way, and applications put credentials in it — an OAuth code, a PKCE code_verifier, a password-reset token. AccessLogService records uri.path() and tests/observe_redaction.rs sends a request whose query string carries a PKCE verifier and asserts it appears in none of the outputs, so uri cannot quietly replace uri.path() one refactor later.

An empty string rather than an absent field is deliberate for both optional values: a reader can tell “not known here” from “this field was never part of this log”.

The client address is a field and never the message. An IP address is personal data in most of the places this will run, and redaction decides per field name — so an address interpolated into the human-readable message would be past the only checkpoint there is. It is written as client_ip and passed through redact::apply("client_ip", ..) on the way, using the same string for both, so adding an address term to the deny-list would withhold it everywhere rather than everywhere-except-the-message. A unit test asserts the name written and the name asked about are the same string.

The layer sits outside the panic catcher, the body limit and the timeout, so a 500, a 413 and a 408 are all logged.

Metrics

Metrics is a registry the application holds and clones. Two Metrics values are two independent registries, which is what lets a test assert on exactly its own counters; cloning shares one, so the handle given to a middleware and the handle given to the /metrics route are the same set of series. There is no global recorder and no macro that reaches for one.

CallDoes
Metrics::new()empty registry, DEFAULT_BUCKETS
Metrics::with_buckets(&[..])same, with your bounds — sorted on the way in, non-finite values dropped
describe_counter/gauge/histogram(name, help)attach # TYPE and # HELP for a name
increment(name, labels, by)add to a counter series
set(name, labels, value)set a gauge series
observe(name, labels, value)record one histogram observation
counter_value(name, labels)Option<u64>, for assertions
render()the whole registry as Prometheus text
response()the same, as an HTTP response with the content type

Recording a series that was never described registers the name with the implied kind and empty help, so a metric is never lost for want of a description. Labels are a BTreeMap internally, so [("a","1"),("b","2")] and [("b","2"),("a","1")] are one series and not two.

The exposition

Prometheus text format, version 0.0.4 — the one every scraper reads, including OpenMetrics parsers, which accept it as a subset. PROMETHEUS_CONTENT_TYPE is text/plain; version=0.0.4; charset=utf-8.

# HELP http_requests_total Total HTTP requests handled.
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200"} 3
# HELP http_request_duration_seconds HTTP request duration in seconds.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",le="0.005"} 1
...
http_request_duration_seconds_bucket{method="GET",le="+Inf"} 3
http_request_duration_seconds_sum{method="GET"} 0.11
http_request_duration_seconds_count{method="GET"} 3

Series are grouped by name and emitted in name order, because the format requires every sample of a name to sit under its one # TYPE line; interleaving names is a parse error, not a style choice. # HELP is written only when help text exists. Whole numbers render without a trailing .0, so a bucket bound of 1.0 is le="1". Label values are escaped for backslash, quote and newline.

tests/observe_prometheus.rs parses the rendered document with a purpose-written validator rather than a contains assertion, and the validator is itself tested against documents that break each rule, because a contains assertion cannot see whether the document around the substring parses — and a scrape a scraper rejects is silent.

The HTTP layer

MetricsLayer::new(metrics) records two series per request:

NameTypeLabelsHelp
http_requests_totalcountermethod, statusTotal HTTP requests handled.
http_request_duration_secondshistogrammethodHTTP request duration in seconds.

MetricsLayer::labelled(metrics, "/users/{id}") adds a route label to both.

DEFAULT_BUCKETS, in seconds: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0 — the set the Prometheus client libraries ship, bracketing the range a web request lives in from a cached 5 ms answer to a 10 s timeout.

There is deliberately no path label taken from the request URI. A URI is unbounded input and an unbounded label value is how a scrape target runs out of memory. The rejected alternative — label with uri.path() and hope — costs the process its memory the first time a crawler walks a parameterised route. An application that wants a route dimension installs the layer per route or per group and names the template itself, and route is a &'static str precisely so it cannot be a value derived from a request.

Wiring it

The builder installs the layer but never the registry and never the route. ApplicationBuilder::metrics(registry) puts MetricsLayer at stage 9, which is where you want it — see What this module does not do for why stage 21 is not. Nothing in the pipeline constructs a Metrics, and there is no /metrics route unless the application adds one.

Hand-wiring the layer instead puts it among the user layers at 21:

use arcature::observe::{Metrics, MetricsLayer};

let metrics = Metrics::new();
let metrics_for_layer = metrics.clone(); // a clone shares the registry

let router = axum::Router::new()
    .route(
        "/metrics",
        axum::routing::get(move || {
            let metrics = metrics.clone();
            async move { metrics.response() }
        }),
    )
    .layer(MetricsLayer::new(metrics_for_layer));

An ordinary route, so it inherits whatever the application puts in front of it. A metrics endpoint usually wants an allow-list or basic auth, and that belongs to the application: the framework has no way to know which of the two is right, and shipping either one as a default would be wrong for half the deployments and invisible to the other half.

Constants live at arcature::observe::metrics::{DEFAULT_BUCKETS, HTTP_REQUESTS_TOTAL, HTTP_REQUEST_DURATION, PROMETHEUS_CONTENT_TYPE}. Only Metrics, MetricsLayer and MetricsService are re-exported at arcature::observe.

Trace context

TraceContextLayer reads W3C Trace Context off the inbound request, puts a TraceContext in extensions, and opens a span carrying the ids so log lines can be joined to the trace.

Inbound headers are untrusted. A malformed traceparent is discarded and a fresh root started rather than propagated, because half a trace id is worse than none: it silently corrupts every trace it joins.

Ruletraceparent
Lengthexactly 55 bytes, dashes at 2, 35 and 52
Version00 only; anything else, including the reserved ff, is rejected
Hex caselowercase only — accepting both would make two spellings of one id
Trace id16 bytes, all-zero rejected
Parent id8 bytes, all-zero rejected
Flagsone octet; bit 0 is FLAG_SAMPLED
Ruletracestate
Length512 bytes maximum
MembersMAX_TRACESTATE_MEMBERS, 32
Bytesprintable ASCII 0x200x7e only
Without a valid traceparentdropped — it describes a trace this request is not part of
On rejectiondropped; the trace itself still propagates

The member cap and the byte range are not pedantry: tracestate is attacker-controlled text that would otherwise be copied onto every outbound request the service makes. It is stored as the original text rather than a parsed member list, because the only operations performed on it are “carry it forward” and “prepend our own member”, and re-serialising a parsed form risks normalising away something a downstream vendor depends on.

The layer records three fields on an arcature.request span, which it does enter:

FieldMeaning
trace_id32 lowercase hex characters
parent_span_id16 lowercase hex characters
continued_tracetrue if an upstream trace was joined, false if this is a root

continued_trace earns its place: a service at the edge starts roots and one behind a gateway should not, and the difference is otherwise invisible.

It enters that span with span.enter() and holds the guard across the inner call, rather than attaching it to the future with Instrument. The guard is an entry on a thread-local stack and a future that yields does not unwind it, so which of a handler’s own events end up inheriting these fields depends on how the runtime scheduled the task. Correlate on lines that record an id themselves; treat span-field inheritance as a convenience, not a contract.

Nothing is written back onto the response. traceparent is a request header, and echoing it would tell a client the internal trace ids for no benefit.

For a downstream call, context.outbound_headers() returns a HeaderMap with a traceparent whose span id is a fresh child — which is what makes the next hop a child of this one rather than a sibling of the caller — plus the tracestate if one survived validation.

Root ids come from getrandom when the oauth feature has pulled it in, and otherwise from a SplitMix64 mix of the clock, a per-process counter and a stack address. Trace ids need to be unique, not unpredictable — they carry no authority and grant no access — so the fallback is adequate, and this module refuses to require a crypto dependency for a correlation identifier.

OTLP export

With otel, Telemetry is an OTLP-over-gRPC span pipeline the application holds:

use std::time::Duration;
use arcature::observe::{JsonLog, StderrSink, Telemetry};
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::util::SubscriberInitExt as _;

let telemetry = Telemetry::builder("checkout")
    .endpoint("http://collector:4317")
    .timeout(Duration::from_secs(5))
    .build()?;

tracing_subscriber::registry()
    .with(JsonLog::new(StderrSink))
    .with(telemetry.tracing_layer())
    .init();

// ... run the application ...

telemetry.shutdown()?;
PointBehaviour
Service namerequired by Telemetry::builder, never defaulted
TransportgRPC over tonic; the grpc-tonic feature is the only one enabled
Signaltraces only — opentelemetry and the SDK are pinned with trace and nothing else
Resource attributesservice.name, and that is all this crate sets
Runtimea Tokio runtime must already be running when build() is called
ShutdownTelemetry::shutdown flushes; dropping the value without it loses whatever is buffered
Samplingnone configured here, and nothing in this crate consults the sampled flag

A deployment reporting as unknown_service is a deployment nobody can find, so the name is a parameter rather than a default, and tests/observe_otlp.rs asserts it actually travels as the service.name resource attribute.

endpoint is worth reading carefully. When it is not called, build() does not call with_endpoint at all, so opentelemetry-otlp’s own default applies — which is the layer that reads OTEL_EXPORTER_OTLP_ENDPOINT. observe::otel::DEFAULT_ENDPOINT is published as http://127.0.0.1:4317, the address a local collector listens on out of the box, but nothing in build() passes it: it is documentation of the address, not a value this builder sends.

The pipeline must be built inside the async entry point and not in a static initialiser, because the batch processor spawns a background task. shutdown is fallible because a collector that is already gone cannot accept the final batch, and that is worth reporting rather than swallowing. A collector that never answers does not panic the application — there is a test that binds a port, drops the listener, and exports at the dead address.

tests/observe_otlp.rs runs a real TraceService gRPC server on a loopback port and decodes the protobuf the exporter wrote, so the assertions are against bytes that crossed a socket: a span arrives, a parent and child share one trace id, a child names its parent’s span id, a three-level nesting arrives as a chain rather than a fan, and a span field arrives as an attribute.

The two id spaces meet, under otel

TraceContextLayer and the OTLP exporter both deal in trace ids, and joining them is the point of a distributed trace.

When otel is on and the request carried a usable traceparent, the layer calls set_parent with a remote SpanContext built from the parsed ids, so the span it exports is part of the caller’s trace rather than the root of a new one. The tracestate travels with it when the exporter accepts it. The traceparent is also still recorded as the trace_id and parent_span_id attributes, which is what correlates a log line.

This did not always hold, and the failure was invisible from the log side. The layer parsed the header and opened a span carrying the ids as fields, and never called set_parent — so tracing-opentelemetry minted a fresh trace id for the exported span. A request arriving with a traceparent started a new trace at this service, and the two halves never met in the backend, while every log line looked correct. an_incoming_traceparent_becomes_the_exported_trace_id in tests/observe_otlp.rs reads the id off the span the collector holds rather than off a log field, because reading the field would pass against the bug.

Two details worth knowing. The parent is only set when the trace was continued: inventing a remote parent for a root would have a backend draw an edge to a span that never existed. And set_parent is called before the span is entered, because it refuses with AlreadyStarted afterwards and would be a silent no-op.

Without otel, none of this compiles in, and the layer is what it always was — correlation in your logs against the ids your upstream used.

Redaction

Logging is the most common way a secret escapes a process, because a log line is written once and then copied everywhere — to a file, to a shipper, to a third-party index, into a support ticket. So the defence is a property of the writer, not a rule the caller remembers: JsonLog asks redact::is_sensitive about every field name it is about to serialise, and a field that matches is written as REDACTED, the fixed marker "[redacted]". A marker rather than an omission, so a reader can tell “this field was withheld” from “this field was never recorded”.

Two mechanisms are at work and they are not the same size. The first is that the framework’s own layers record structured fields and never format a secret into a message string: the access log records method, path, status and duration, and the metric labels are a method, a status and a &'static str. That mechanism holds everywhere. The second is the deny-list, which covers a field an application adds — and it is much narrower than it first looks.

Which sinks it reaches

The deny-list is consulted in exactly two files: json_log.rs, which asks is_sensitive about every field of every event and every span, and access_log.rs, which calls redact::apply on the one client_ip value. metrics.rs, otel.rs and trace_context.rs never call either.

ChannelRedactedNote
JSON log, event fieldyesthe name is matched, the value is dropped
JSON log, span fieldyesredacted when the span is created, so the folded copy is already safe
JSON log, message stringnothe deny-list catches the field, not the sentence
Debug-build console (fmt)noinstall_logging only installs JsonLog in release builds
Metric label valuenoescaped for the exposition format, not redacted
Exported OTLP span attributenotracing_opentelemetry has its own visitor

The last two are pinned by tests that assert the leak, named so nobody mistakes them for tests of a working defence:

  • a_secret_recorded_as_a_span_field_reaches_the_collector_in_full — the same field the JSON layer wrote as [redacted] leaves the process in plaintext, over the wire, to a collector.
  • a_secret_recorded_as_a_metric_label_is_rendered_in_full — a series labelled with a session id publishes it on /metrics.

Both assert today’s behaviour so that closing either gap is a visible change rather than a silent one. If either starts failing because the value is now redacted, the defence has been extended and the test is to be deleted.

Until then: treat a metric label and a span attribute as unredacted channels. Keep the value out of them, or record it as a type whose own Debug renders redacted — the JSON visitor’s fallback formats through Debug, so a secret newtype protects itself in every channel that formats one. A label value is also a series dimension, so a secret used as one is usually an unbounded-cardinality bug as well.

How a name is matched

pub fn is_sensitive(field: &str) -> bool

Three steps, in this order:

  1. Every - and every . becomes _.
  2. Every other character is ASCII-lowercased.
  3. The result is tested for containment of any needle in DENY_LIST.

Substring rather than exact name, on purpose: password, user_password and db.password are all the same mistake, and a deny-list that only catches the spelling someone thought of is not a deny-list. False positives cost a debugging session; false negatives cost a credential.

Separator folding, also on purpose: an HTTP header is x-api-key, an OpenTelemetry attribute is http.request.header.authorization, a struct field is api_key. One secret, three spellings, and a needle written with _ is a substring of only the third. Every needle uses _ and none contains - or ., so folding can only ever match more.

DENY_LIST is public, sorted and lowercase — a unit test asserts the last two — so an application can check its own field names against it:

access_tokenapi_keyapikeyauth
bearerbindbodycache_value
cardcookiecredentialcsrf
cvvid_tokenotppassphrase
passwdpasswordpayloadpin_code
private_keypwdrefresh_tokensecret
session_idsignaturesql_argstoken
verifier

Because the test is containment, ordinary names collide with short needles and are redacted:

Field nameRedacted by
author, authorityauth
wildcard, discard, cardinalitycard
bindingbind
body_bytesbody
token_counttoken

That is the trade the module chose, stated so it is not a surprise at three in the morning when a field reads [redacted] and nothing is wrong.

What is not folded: any separator other than - and .. A space, a slash, a colon and a camelCase word boundary all survive step 1, so a multi-word needle cannot match across them. Non-ASCII case is not folded either — a field name is not expected to contain non-ASCII, and folding Unicode would widen the surface without widening the protection.

redact::apply(field, value) is the borrowing form: it returns REDACTED or the value unchanged, so nothing is copied for a field that is allowed through. is_sensitive and REDACTED are re-exported at arcature::observe; DENY_LIST and apply live at arcature::observe::redact.

The disclosed gap: camelCase multi-word names

tests/observe_redaction.rs pins it by name:

the_deny_list_is_written_in_snake_case_and_therefore_misses_camel_case_spellings

The list is written in snake_case, and camelCase has no separator to fold. privateKey lowercases to privatekey; the needle is private_key; there is no match. The test walks the spellings side by side:

SpellingResultWhy
x-api-key, X-Api-Keyredacted- folds to _, matching api_key
http.request.header.authorizationredacted. folds, and auth matches anyway
apiKeyredactedapikey is on the list as its own needle
accessTokenredactedthe single-word needle token matches
privateKeynot redactedprivatekey does not contain private_key
sessionIdnot redactedsessionid does not contain session_id

The exposure is narrower than the headline. Only multi-word needles are reachable this way, single-word needles catch most camelCase spellings anyway, and Rust field names are snake_case — so what is left is an application that records a JSON body’s keys under the names the client chose. If that is your application, check the names against DENY_LIST in a test of your own, or normalise them before recording.

As with the leak tests: if privateKey starts coming out redacted, the matcher has been widened and that test should be narrowed to whatever spelling is still missed, or deleted.

A list of names cannot see values

The other limit is structural, and it also has a test: a_secret_under_a_field_name_nobody_denied_is_logged_in_full. A field called note carries a password in one handler and a postcode in the next. No list of names can catch that.

Nor can any formatter undo a secret a caller has already interpolated into a message string. tracing::info!("signing in {password}") is past every checkpoint there is by the time the layer sees it. Record fields, not sentences.

The defence for both is the same one the framework applies to itself: its own layers never record a field whose contents they have not chosen. The list of what that means in practice, from the module’s own documentation —

  • Request and response bodies. Method, path, status and duration; never the payload.
  • SQL bind values. A query’s text may be recorded; the parameters bound into it may not, because that is where the row data lives.
  • Cache values. Keys are loggable and are logged; values are not.
  • Credentials of every kind — passwords, password hashes, API keys, bearer tokens, OAuth access and refresh tokens, PKCE verifiers, CSRF state, session identifiers, cookies, and Authorization headers.
  • Email bodies and recipients’ message content. A send is recorded as an event with a message id; the letter is not.
  • Job payloads. A job is recorded by name, queue and attempt count.

— holds for the framework’s own layers in every channel. tests/observe_redaction.rs drives one request carrying a password, a bearer token, a session cookie and a PKCE verifier through the whole stack — request ids, access logging, metrics, trace context — with a log sink, a metrics registry and a live OTLP collector capturing at once, then searches the log transcript, the metrics exposition, the exported span attributes and the exported resource attributes for each secret’s value. It also asserts that each channel captured something, because a harness that silently captured nothing would pass every absence assertion in the file.

Stable span names

Seven &'static str constants at arcature::observe, with is_stable(name) and ALL to iterate them:

ConstantValueOpened by the framework
REQUESTarcature.requestyes — AccessLogService and TraceContextService
DB_QUERYarcature.db.queryno
CACHE_GETarcature.cache.getno
JOB_HANDLEarcature.job.handleno
PAGE_RENDERarcature.page.renderno
EVENT_LISTENERarcature.event.listenerno
SCHEDULE_TICKarcature.schedule.tickno

Six of the seven are reserved names rather than spans anything currently emits: no code in the crate opens them today. They are here so that instrumentation added later, in the framework or in an application, agrees on one spelling instead of inventing a second. arcature.request is opened twice when both layers are installed — once by each — so a spans array can carry the name more than once.

What this module does not do

Install anything on its own. No global subscriber, no global recorder, no global tracer provider. install_logging is a call the binary makes.

Add a /metrics route, or protect one. The registry and the layer are values; routing and access control are the application’s.

Use .metrics(..), not .layer(MetricsLayer::new(..)). Both compile. Only one of them counts a refused request.

A user .layer() lands at stage 21 — inside the body limit (12), the timeout (13), maintenance (14) and the rate limiter (15). A request refused with a 413, a 408, a 503 or a 429 never reaches stage 21, so a counter installed there does not see it. The request total then quietly means “requests that got through admission”, the access log at stage 9 disagrees with it, and the gap is widest under exactly the load an incident is about.

ApplicationBuilder::metrics(registry) installs the same layer at stage 9, beside the access log, where it sees what the access log sees.

let metrics = Metrics::new();

let app = Application::<AppState>::new()
    .routes(routes())
    .rate_limit(RateLimit::per_minute(60))
    .metrics(metrics.clone())   // stage 9: counts the 429s too
    .build();

tests/observe_metrics_stage.rs pins the difference: the same request through the same limit is counted at stage 9 and missed at stage 21, and both placements agree on a request that is served. The second of those asserts the gap on purpose, so that anyone who later moves user layers outside the admission stages learns it from a failing test rather than from a graph.

Use .trace_context() for the same reason. It installs TraceContextLayer at stage 8, beside the request id and outside the admission stages, so the access line for a 429 carries the caller’s trace id. Through .layer(..) it would land at 21 and a refused request would produce log lines with no trace on them at all — and a refused request is one somebody is very likely to go looking for.

tests/observe_trace_stage.rs pins that pair the same way the metrics tests do, including the negative: a user layer is asserted to miss the refusal, so moving user layers later fails a test rather than quietly costing traces.

Redact in debug builds. The fmt layer prints fields verbatim.

Redact metric labels or OTLP span attributes. See the table above.

Sample. TelemetryBuilder sets no sampler and reads no sampling environment variable, so whatever the SDK’s own default does is what happens. TraceParent carries and preserves the sampled flag, but nothing in this crate consults it to decide whether to record or export.

Export metrics or logs over OTLP. Traces only. opentelemetry and the SDK are pinned with the trace feature and nothing else; metrics leave as Prometheus text or not at all.

Register a propagator. The inbound join is done directly with set_parent rather than through a global TextMapPropagator, and outbound headers come from TraceContext::outbound_headers. Nothing installs a global propagator, so a third-party client that expects to find one injects nothing.

Write to a file, rotate, or ship. Both formats go to standard error. The process manager owns the file, and every mainstream one already does this better than a library could.

Throttle or deduplicate log lines. A chatty target is what the filter is for.

Provide error tracking. No Sentry, no crash reporter, no aggregation. An ERROR line with structured fields is what the module produces; turning that into an incident is a shipper’s job.

Testing

Arcature applications are tested the way Tower services are tested: build the router, drive it in-process, assert on the response. There is no socket, no port to allocate, and no teardown race.

Driving the router

Application::into_router hands back the composed Router. From there tower::ServiceExt::oneshot sends a single request through the whole router-level pipeline and returns the response.

use arcature::axum::Router;
use arcature::axum::body::Body;
use arcature::axum::http::{Request, Response};
use tower::ServiceExt as _;

async fn send(router: Router, request: Request<Body>) -> Response<Body> {
    router.oneshot(request).await.expect("infallible")
}

fn get(uri: &str) -> Request<Body> {
    Request::builder()
        .uri(uri)
        .body(Body::empty())
        .expect("request")
}

tower is not re-exported by Arcature, so a test crate using oneshot needs tower = { version = "0.5", features = ["util"] } under [dev-dependencies]. axum is re-exported, as arcature::axum.

This is the pattern the framework’s own tests/application.rs uses. It exercises stages 3 through 20 of the pipeline: everything the builder composes onto the router.

It does not exercise stages 1 and 2 – the dev proxy and the pre-routing proxy. Those are composed around the router as a service in run_with_state and serve, because they rewrite the URI before route selection. A test that needs them has to build the service, not the router.

Asserting layer order

Layer order is a contract, so the framework asserts it rather than only documenting it. The technique is a marker layer that appends its own name to a response header; the resulting header value spells out the order the layers actually ran in. If a future edit reorders the stack, the assertion fails with a diff a reader can act on, instead of a subtly different security posture nobody notices.

If your application depends on where its own .layer() calls sit relative to the framework’s, the same technique works: user layers are stage 18, inside everything the builder installs and outside the router.

Route tables

A route table is data, so it can be asserted without a request at all. The generated application ships exactly this as its smoke test:

use my_app::routes;

#[test]
fn home_route_is_registered() {
    let routes = routes::routes();
    assert_eq!(routes.url_for("home", &[]).unwrap(), "/");
}

url_for returns Err(Error::NotFound(..)) for a name that is not in the table, so a renamed route fails the test rather than silently producing a broken link at runtime.

Events

Dispatcher::recording() builds a dispatcher that remembers the names of the events it dispatched. was_dispatched(name) and dispatched_events() read that record back. Both return false / an empty vector on a dispatcher built with Dispatcher::new() – recording is opt-in and costs nothing in production.

Assert on the event, not on the listener’s side effect, when what you care about is that the event fired. Assert on the listener when what you care about is what it did.

Mail

Mailer::capture_ok() accepts every message and keeps it; Mailer::capture_error() rejects every message. Both are constructors, so a test builds one directly instead of pointing SMTP at a local catcher.

use arcature::mail::Mailer;

let mailer = Mailer::capture_ok();
// ... run the code under test ...
let sent = mailer.captured().await.expect("capturing mailer");
assert_eq!(sent.len(), 1);

captured() returns Option<Vec<(Envelope, String)>>None when the mailer is not a capturing one, so a test that accidentally runs against real SMTP fails on the expect rather than passing vacuously. The String is the serialised message; the Envelope carries the actual sender and recipients, which is what you want to assert on, since the envelope and the To: header can legitimately differ.

capture_error() is the one that finds the bugs: it proves the calling code handles a send failure instead of unwrapping it.

Jobs

A job handler is an ordinary async function over a deserialised payload. Test it by calling it. That covers the interesting part – the business logic and the JobError::Retryable / JobError::Permanent decision – without a database.

Testing the queue itself needs PostgreSQL, because the claim protocol is FOR UPDATE SKIP LOCKED and there is nothing to emulate it with. The framework’s own suite takes a DATABASE_URL for exactly this reason; see Deployment for the CI service definition.

Validation

Validated<T> and its siblings are extractors, so they are tested through a request. A 422 with an errors extension on the problem document is the success case for a validation test – assert on the field names in that extension, not on the message strings, which are not a stable interface.

Databases in tests

There is no per-test transaction rollback helper and no test database provisioner. A test that needs a database connects to one named by an environment variable and is responsible for its own cleanup. This is less convenient than the Laravel equivalent and is a known gap.

The test-kit feature

into_router plus oneshot covers the router-level pipeline and needs no framework support at all, which is why the chapter leads with it. When a test needs the whole application – subsystem startup, state construction, the lot – arcature::test_kit boots one in process and drives it as a tower::Service, so there is no socket, no port and no teardown race.

Enable it under [dev-dependencies]:

[dev-dependencies]
arcature = { version = "0.1", features = ["test-kit"] }

The feature belongs there and nowhere else: shipping a test harness inside a production binary is the mistake the feature split exists to prevent.

What it holds: TestApp (the in-process driver) and TestServer (a real socket, for the few things a tower::Service call cannot exercise, such as a WebSocket upgrade); TestRequest and TestResponse with the assertions; session seeding behind the auth feature, so acting_as has something to act as; a two-condition database gate with transaction-per-test and assert_database_has behind the database feature; and recorder fakes for events, jobs and mail, wired into the seams those subsystems already expose rather than being parallel copies of them. #[arcature::test(app = ...)] binds a fresh TestApp to the test function’s parameter.

It registers nothing globally – no inventory, no thread-local, no ambient application. A test names the thing it is testing and holds it in a value.

Deployment

An Arcature application is one binary that listens on one TCP port. There is no separate Node process, no PHP-FPM pool, no asset server. What you deploy is the binary, the built frontend assets, and whatever the application reads out of the environment.

The pipeline

Every request travels through a fixed, ordered stack. The order is a contract written down in src/application/pipeline.rs and asserted by the test suite; it does not depend on the order the builder methods were called in.

Outermost first:

#StageNote
1DevProxyForwards Vite requests in development. Pass-through unless arc dev set an IPC endpoint.
2ProxyPre-routing URI rewriting.
3HealthMerged beside the router, not layered over it.
4UagEndpointMerged beside the router too. Debug builds only, after an explicit .uag_endpoint(..).
5CompressionSees the final body, whoever produced it.
6SecurityHeadersOutside the body limit and timeout, so a 413 and a 408 carry them too. Mints the per-request CSP nonce on the way down.
7CORSAnswers a preflight without waking anything below.
8RequestId, TraceContextEvery response carries x-request-id. Outside the admission stages, so a refused request still resolves its trace.
9AccessLog, MetricsOutside the panic catcher, the body limit, the timeout and the rate limiter, so a 500, a 413, a 408 and a 429 are logged and counted.
10CatchPanicA panic becomes a 500, not a dropped connection.
11ErrorMappingRFC 9457 bodies for bodiless errors; release redaction.
12BodyLimitRejects an oversized upload before buffering it.
13TimeoutA slow handler cannot hold a connection open.
14MaintenanceOutside session and CSRF.
15RateLimitInside maintenance, outside the session.
16SessionLoaded before CSRF needs the token.
17CSRFAn unsafe request is rejected before it can act.
18InertiaInnermost framework layer, so a CSRF rejection is not dressed up as a page.
19PageContractsData, not behaviour.
20RedirectMapperFinishes redirect().route(..) and .with(..) against the route table and the session.
21user .layer()sApplied in call order, innermost.
22RouterRoute matching and the handler.
23StaticFilesThe router’s fallback.

Stages 5 through 21 are off unless asked for, RedirectMapper excepted – it is installed by default, because a redirect().route(..) that silently 400s is not a default anybody wants. An application that calls nothing but .routes() gets a bare router plus the health endpoints. That is deliberate: each entry in the list above is a decision someone made, which is what makes the list readable.

The reasoning behind each position is in ADR 0004 and in the module documentation itself.

Health, liveness and readiness

Three endpoints, mounted under /up by default (.health_prefix("/healthz") moves them, .health(false) removes them):

PathQuestionAnswered from
GET /up/liveIs this process alive?The lifecycle alone. Never touches a database, cache, or the network.
GET /up/readyShould this process receive traffic?The lifecycle and every started subsystem’s probe.
GET /upThe same, as JSON for a human.Both.

Wiring a restart policy to readiness is the classic outage: a database blip restarts the pod, and the restart does not bring the database back. Point liveness probes at /up/live and load-balancer health checks at /up/ready.

These three bypass maintenance mode and Inertia, and always answer application/json with Cache-Control: no-store. A cached readiness answer is a wrong answer.

Readiness is false before startup finishes: the health handle holds the subsystem set in a OnceLock that startup fills in, so a process that has not booted reports that it has not booted.

Startup and shutdown order

run_with_state starts subsystems in a fixed order and tears them down in reverse:

start:  database -> jobs -> cache -> storage -> mail
serve:  mark ready, accept connections
drain:  begin_drain (readiness turns 503, requests in flight continue)
stop:   mail -> storage -> cache -> jobs -> database

The drain step is why the readiness endpoint exists in the form it does. begin_drain runs before the listener stops, so /up/ready answers 503 while in-flight requests are still being served. That window is exactly what a load balancer needs to take the instance out of rotation without dropping anything.

SIGTERM and Ctrl-C both trigger graceful shutdown on Unix; on Windows only Ctrl-C is wired.

serve(listener) is the escape hatch: it takes an already-bound listener and skips ordered startup entirely. Health endpoints still work, but they report on an empty resource set, because on that path there are no subsystems.

Running more than one instance

Most of the framework is indifferent to how many processes you run. Three subsystems are not, and the difference between them matters more than the list suggests: two have a cross-instance mode you switch on, and one does not.

Sessions are shared, if you configure a store that shares them. The session-store-db feature puts sessions in the same database the application already uses, so a request may land on any instance and a deploy does not log everyone out. MemoryStore does neither. This is a configuration choice with a correct answer, not a limit.

Rate limiting is per-process until you point it at Redis. The default backend is an in-process HashMap of token buckets, so with n instances behind a load balancer a client gets roughly n times the nominal quota. RateLimit::redis(cache) (needs the cache feature) moves the buckets to Redis/Valkey and the quota becomes global. Decide this deliberately: OnBackendError controls what happens when Redis is unreachable, and it defaults to Refuse — the limiter fails closed rather than silently becoming no limiter at all.

A per-hour quota keyed by address is the one combination that costs throughput. The in-memory backend sweeps its bucket table past 8192 entries and drops every bucket that has refilled to capacity, which is what keeps one-bucket-per-IP from growing without bound. It only works while buckets refill faster than new addresses arrive. Under a per-hour quota a bucket stays ineligible for six minutes, so the sweep drops nothing, the table keeps growing, and every subsequent request rescans it while holding a blocking mutex. Measured at 128 connections: a fresh key on every request costs nothing under a per-second quota and 5.6x throughput under a per-hour one — 6786 requests a second against 1201. That run is recorded in baselines/load-baseline.x86_64-unknown-linux-gnu.txt, and tests/load_profile.rs reproduces it, one variable per row. The memory reading agrees independently: it is the only one of the four runs whose resident set moved, +4.2% against +0.1% to +0.3% for the other three, which is the bucket table growing.

That combination is exactly the shape of a login or password-reset throttle, so it is worth choosing on purpose. RateLimit::redis(cache) avoids it entirely — there is no client-side map to scan, only a per-key expiry the server honours. Failing that, prefer the faster-refilling spelling of the same rate: per_minute(600) and per_hour(10) allow nearly the same traffic over an hour, but the first refills a spent bucket in a tenth of a second and only the second accumulates.

Realtime fan-out is per-process, and there is no switch. Broadcast wraps a tokio::sync::broadcast channel, which is a channel between tasks inside one process. A message published on instance A reaches only the WebSocket and SSE subscribers connected to instance A. Nothing errors and nothing warns: subscribers on instance B simply never see it, which is why this is worth stating plainly rather than leaving to be discovered. With two instances and clients spread evenly, roughly half of each broadcast is lost from any given client’s point of view.

Until a cross-instance bridge exists, there are three honest ways to live with this:

  • Run one instance. Vertical scale goes a long way, and this is the only option that needs no extra reasoning.
  • Pin realtime connections to one instance. A load balancer routing WebSocket and SSE upgrades to a single backend keeps fan-out correct while ordinary HTTP scales out. Whether that instance’s failure is acceptable is an availability question, not a correctness one.
  • Publish from a shared source. If every message originates from a job worker or an external system, have each instance subscribe to that source and re-publish locally. This is the bridge, written by hand.

A Redis pub/sub bridge is the obvious general answer and redis is already in the tree behind the cache feature, but it is not written: it would need delivery semantics, ordering and back-pressure decided on purpose rather than inherited, and no traffic has yet asked the question.

Maintenance mode

Maintenance is an Arc-backed handle, not a global and not a file on disk. Flip it from an admin route, a signal handler, or a test. Everything except the health endpoints and any path passed to Maintenance::allow gets a 503 with a Retry-After header and an RFC 9457 body — so a browser, a fetch, and a CLI client all get an answer they can act on.

Because nothing looks the handle up in a registry, an application that does not keep the handle cannot engage maintenance mode. That is the intended trade: no ambient switch that some other part of the process can flip.

Errors in release

ErrorMapping::new().redact_errors(true) replaces text/plain 5xx bodies with a generic problem document. The layer sits at stage 10, inside the panic catcher and outside everything that runs application code, so it catches both handler errors and the bodiless 404, 405, 408 and 413 that axum and tower-http emit on their own.

Redaction is a builder flag, not an automatic consequence of a release build. Set it explicitly.

Security headers

SecurityHeaders::new() sets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin. .with_hsts() adds Strict-Transport-Security: max-age=31536000; includeSubDomains, and .with_csp(policy) sets a Content-Security-Policy from a string you supply.

Add HSTS only once TLS is actually terminated in front of the process and you are prepared for the one-year commitment, subdomains included.

CSP nonces

.with_csp_nonce(template) is the other way to set the policy. It takes a template containing {nonce} and substitutes a fresh 144-bit random value on every request:

SecurityHeaders::new()
    .with_hsts()
    .with_csp_nonce("default-src 'self'; script-src 'self' 'nonce-{nonce}'")?

A template with no {nonce} in it is refused at construction rather than quietly sent without one, and .with_csp(..) and .with_csp_nonce(..) replace each other — the last one called wins.

The nonce goes into the request extensions before the request reaches anything else, and the framework stamps it onto every element it emits itself: the Inertia data-page payload script, the module script and stylesheet links resolved from the Vite manifest, and the Vite HMR client in development. Read it in a handler by extracting CspNonce (or Option<CspNonce>), and in a hand-written root document by calling body.nonce_attribute().

What the framework cannot stamp is anything the application writes itself: an inline <script> in your own root document, an analytics snippet, a third-party widget that injects scripts. Those either carry the nonce or stop running.

Three details worth getting right before you turn this on:

  • A nonce constrains only the directive that carries it. script-src 'nonce-X' says nothing about style-src or frame-src.
  • A CSP Level 2 or later browser ignores 'unsafe-inline' in a directive that also carries a nonce, which is why script-src 'nonce-X' 'unsafe-inline' is the documented fallback for old browsers rather than a contradiction. But 'unsafe-inline' in a directive with no nonce in it – style-src, usually — is not ignored by anything.
  • Without 'strict-dynamic' a nonce does not propagate to scripts that a nonce’d script goes on to insert, so a code-split bundle needs 'self' (or 'strict-dynamic') in script-src alongside the nonce.

Do not let a shared cache store nonce’d HTML. The document and the header are cached together so they stay consistent, but every visitor then gets a nonce that every other visitor already knows, which is the one property it had. Arcature sets no Cache-Control on the initial document; excluding it is the CDN configuration’s job.

Ports and the environment

The listen port is resolved at startup in this order, highest first:

  1. ARCATURE_BACKEND_PORT
  2. PORT
  3. APP_PORT
  4. whatever .config(..) or .port(..) last set, defaulting to 3000

The first that parses as a u16 wins; one that is present but malformed – PORT= in a compose file, say — is skipped rather than fatal, so an empty variable does not stop the process booting.

ARCATURE_BACKEND_PORT is first, ahead of the platform’s PORT, because arc dev sets it and its supervisor owns the process’s only TCP listener. If PORT outranked it, a stale PORT in a developer’s .env would aim the child at the address the supervisor already holds, and the one-port topology would fail with a message about the port being in use.

AppConfig::from_env() reads APP_NAME, APP_URL, APP_ENV and APP_PORT. Hand the result to .config(..) and port becomes the port the server binds.

name and url appear on the startup line — the one record a booting application emits unprompted — so a process that believes it is reachable at an address nobody expected says so immediately rather than three days later in a broken emailed link. url is otherwise spent through AppConfig::absolute_url(path), which roots a path at APP_URL with the trailing slash normalised away; that is the accessor to reach for whenever a link has to be built with no request in scope, which is every link that matters — password resets, redirect_uri, anything signed. path is joined and never substituted, so passing something that looks like a URL of its own produces a path segment under the configured host rather than a link to another one.

env is carried, readable back through Application::config(), and deliberately barred from gating behaviour. Every protection that could plausibly key off an environment — the security headers, HSTS, release redaction of error messages, the UAG endpoint — keys off cfg!(debug_assertions) instead, so it is decided when the binary is built. An APP_ENV that could switch them off would let anyone who can set an environment variable downgrade a production binary without redeploying it.

ARCATURE_VITE_IPC is set by arc dev and consulted by both the dev proxy and the asset resolver. In production it is unset, and both subsystems fall back to hashed build output. See ADR 0003.

Building for release

cargo build --release

Feature selection is how you control what ends up in the binary. The default feature set is batteries-included and compiles the generated application with no extra flags; fullstack adds the operator-adjacent extras (storage-s3, dev-proxy, uag). Operator opt-ins — otel, api-docs, oauth — stay off unless you name them; api-docs in particular publishes a map of your attack surface.

Database drivers are separate features — db-postgres, db-sqlite, db-mysql — and exactly one belongs in a build. Enabling database alone gives a build that cannot connect to anything, which is deliberate: it is the only way one crate serves all three without a SQLite user compiling the Postgres protocol.

#![forbid(unsafe_code)] applies to the whole crate.

Continuous integration

CI runs on both the MSRV (1.97.1) and stable, with RUSTFLAGS: -D warnings and a postgres:17 service on postgres://postgres:postgres@localhost:5432/arcature_test. The gates, in order:

cargo fmt --all — --check
cargo clippy --all-targets
cargo build
cargo test
cargo build --no-default-features
cargo build --features fullstack
cargo hack check --each-feature --no-dev-deps
cargo hack build --feature-powerset --skip database --keep-going

The feature-matrix jobs are there because feature gating is a compile-surface decision: a feature that only builds when another one happens to be on is a bug, and cargo hack is the only thing that finds it. A separate job runs cargo publish --dry-run --no-verify.

The justfile at the repository root wraps these as just check, just fmt, just lint, just test, just features and just docs.

Releasing

Tagging a version triggers .github/workflows/release.yml, which publishes arcature-macros first and then arcature, and builds the arc binary for Linux, macOS and Windows.

There is no npm step, and there will not be one. See ADR 0001.

Upgrading

Upgrading from 0.1.1 to 0.1.2

Nothing to do. 0.1.2 removes nothing, changes no public signature, and adds no feature flag, so cargo update -p arcature takes it.

Read on only if you serve Server-Sent Events, or if something parses your logs.

Two behaviours change, and neither is a compiler error

An SSE connection now counts against the connection limit for as long as it is open. It used to count for the instant it was admitted and no longer: SseEndpoint::handle acquired a ConnectionGuard and then wrote let _ = guard;, which drops it on that line — _ is not a binding — under a comment claiming it was held for the life of the stream. The cap therefore bounded concurrent admissions rather than concurrent streams, and an application could hold any number of SSE connections open against a ShutdownConfig::new(1).

If you sized max_connections while that was true, you sized it against a limit that was not being enforced. The n + 1th concurrent SSE stream now gets the 503 the configuration always promised. Raise the number if the old behaviour was what your capacity planning assumed.

realtime::drain is affected in the same direction: an open SSE stream is now visible to it, so a drain that used to return Ok(()) while streams lingered will now wait for them and, if they outlast its bound, return Err(RealtimeError::Shutdown { remaining }).

Handler log lines gain three fields. AccessLogService built its request span and never entered it, so a tracing::info! inside a handler inherited nothing. It now attaches the span to the inner call, and handler events carry request_id, method, path and client_ip. Anything parsing your logs against an exact key set will see the new keys.

Two new builder methods, both opt-in

ApplicationBuilder::metrics(registry) and ApplicationBuilder::trace_context() install the metrics and trace-context layers at stages 9 and 8. Both were previously reachable only through .layer(..), which lands at stage 21 — inside the body limit, the timeout, maintenance and the rate limiter — so a counter there missed every 413, 408, 503 and 429, and a trace context there left those requests with no trace at all.

If you install either by hand today, switch:

-    .layer(MetricsLayer::new(metrics.clone()))
-    .layer(TraceContextLayer)
+    .metrics(metrics.clone())
+    .trace_context()

Your request total will go up, because it starts including the refusals it was always supposed to count.

Upgrading from 0.1.0 to 0.1.1

Nothing to do. 0.1.1 removes nothing and changes no public signature, so if your manifest asks for 0.1 you already accept it:

[dependencies]
arcature = { version = "0.1", features = ["fullstack"] }

cargo update -p arcature takes it. Read the rest of this section only if you want the new subsystems, or if you render pages through a PageContract, or if you rate-limit by IP.

Two behaviours change

Both are listed in the changelog, and both are the kind of change that does not appear in a compiler error.

A page rendered through a PageContract now titles itself. Where the <title> used to be the application title on every route, a page reached through render_page now derives one from its contract name. This only reaches applications using a #[page] contract and one of the stock root documents; a hand-written Fn(ScriptBody) -> String root document ignores the head, as it always has, and Inertia::render is unaffected. If you were relying on one title everywhere, set the head explicitly with Inertia::with_head.

The IP rate limiter now keys on the caller. KeySource::Ip previously had no client address to read and put every request into a single shared bucket, which meant a limiter configured per-IP was in practice a global one. It now resolves a real address. Expect the limiter to start doing what its configuration always said: if a limit was set per-IP and tuned against the global behaviour, the effective ceiling is now that limit times your caller count, so re-check the number. Addresses from X-Forwarded-For are trusted only from peers you list with ApplicationBuilder::trusted_proxies, and the default list is empty — behind a reverse proxy, the address is your proxy’s until you say otherwise.

Fourteen new feature flags

Every one is off by default, and nothing was removed, so an existing build compiles unchanged and links not a byte more. This list is the release: the features are the release, and the two behaviour changes above are the only things that happen without asking.

Sign-in and credentials

FeatureWhat it addsWhy it is opt-in
auth-flowsauth::flows — the decisions between auth’s seams and a login form that are wrong in ways nothing reports. An unknown address costs the same time as a wrong password; a failed attempt is throttled by address and callerAn application with no sign-in screen has no use for it, and it is the half of authentication where a plausible implementation leaks who has an account
auth-resetOne-time password-reset links: mailed once, redeemed once, stored as a SHA-256 digest, and issuing a new one invalidates the previous mailBrings a table and a migration. An application whose accounts are provisioned by an administrator has no use for it
auth-rememberRotating remember-me tokens, with the theft detection that makes a weeks-long credential defensibleBrings a table and a migration. “Stay signed in” is a product decision
api-tokensHashed personal access tokens — an opaque bearer credential for a CLI, a CI job, another service. The database holds only a SHA-256 digestBrings a table and a migration, and is independent of auth: an API with no passwords may still hand out a token

Cryptography

FeatureWhat it addsWhy it is opt-in
cryptcrypt::Encrypter: XChaCha20-Poly1305 over a versioned, self-describing token that refuses to return a single byte of altered ciphertextThe moment a build can produce ciphertext, somebody owns a key-rotation story
signed-urlscrypt::UrlSigner: a link carrying its own proof of origin and its own deadline, refused if edited by a byteSeparate from crypt because signing needs a MAC and encrypting needs a cipher. A one-hour download link should not pull in an AEAD

Request and response

FeatureWhat it addsWhy it is opt-in
uploadsmultipart/form-data bodies, filename sanitizing, content-addressed object names, bounded readers, magic-byte content sniffing, attachment downloadsThe filename, the declared content type and the byte count all come from the client. A build with no upload route has no business carrying a multipart parser
viewsCompiled HTML views through Askama, plus mail bodies rendered from the same templatesAskama compiles templates to Rust at build time, so there is no expression evaluator in the request path and server-side template injection is structurally absent rather than defended against. The trade is that editing a template means rebuilding
i18nFluent translation catalogs, locale negotiation against a whitelist, and the active locale exposed to views and Inertia propsAn application shipping one language should not carry a message parser and a plural-rule table to say so

Persistence

FeatureWhat it addsWhy it is opt-in
session-store-dbA sqlx-backed SessionStore, so sessions survive a restart instead of logging every user out on deployBrings a table and a migration

Notifications

FeatureWhat it addsWhy it is opt-in
notificationsOne event, told to one person, over whichever channels apply. Implies mail, which is the channel it is overwhelmingly used forA channel-less core would be a subsystem that can deliver nothing
notifications-dbThe in-app inbox: one row per delivered notificationBrings a table and a migration. An application that only sends mail should not carry them
notifications-broadcastA live push to whoever is connected now, over the realtime machineryThe inbox answers “what did I miss”; the broadcast answers “what just happened”. Wanting one is not wanting the other
notifications-queueHands the mail channel to the job queue instead of the requestThe only one of the four that changes where the work happens. It takes on running a worker, and an application without one should not be offered a method that writes rows nobody drains

Five of these bring a table: auth-reset, auth-remember, api-tokens, notifications-db and session-store-db. Each has its own idempotent migration, applied the way you apply the job migrations. A project generated by arc new on this version wires arcature_sessions into --migrate already; the rest are yours to apply, because only you know the order they belong in.

One thing auth-reset does not do, because it would be easy to assume it does: spending a reset link does not sign the account’s other sessions out. Sessions are keyed by session id and are not indexed by user, so there is no portable statement that deletes every session belonging to one subject. The mechanism that would hold — a credential stamp checked when a session loads – is a separate piece this release does not ship. If your threat model is “the attacker already has a session and the user is resetting to evict them”, that is yours to build on top.

None of the five new database features shares a table with any other, and no two claim the same advisory lock — tests/advisory_locks.rs fails if that stops being true, so two migrators can run concurrently without one waiting on a lock the other holds under a different name.

Following main instead

main moves ahead of the release and breaks without notice. To follow it, depend by git reference and pin a revision — a branch reference will move under you.

[dependencies]
arcature = { git = "https://github.com/ArcatureLabs/Arcature", rev = "...", features = ["fullstack"] }

The version scheme: semantic versioning

Arcature is versioned MAJOR.MINOR.PATCH, starting at 0.1.0.

FieldIncrements when
MAJORSomething breaks: a removed API, a changed signature, a changed default behaviour. Stays 0 until the API is frozen.
MINORA compatible addition — and, while MAJOR is 0, a breaking change too.
PATCHSomething is fixed compatibly.

The current version is 0.1.2, readable at runtime as arcature::FRAMEWORK_VERSION.

The row that matters is the middle one. Cargo treats the leftmost non-zero field as the major, so under 0.x the minor is the breaking field, and the usual requirement already accounts for it:

arcature = "0.1"

resolves 0.1.1 and 0.1.9 but refuses 0.2.0. An exact pin buys nothing here, and costs you the patches. Raise the minor deliberately, with the changelog open.

What 0.x is telling you is that the public API is not frozen: any minor release before 1.0 may remove or reshape something. The parts most likely to move are listed further down this page. Once 1.0 is tagged, the breaking field moves back to the major and arcature = "1" becomes the safe requirement.

Where breaking changes are recorded

CHANGELOG.md in Keep a Changelog format. Every breaking release gets a ### Removed or ### Changed entry naming the API and the replacement. If a change requires work in an application, the entry says what the work is.

The macro crate moves with the framework

arcature-macros is versioned in lockstep with arcature and is not a separate upgrade decision. arcature depends on an exact version of it, and the release workflow publishes arcature-macros first for that reason. Do not depend on arcature-macros directly.

What is likely to break

These are the parts most likely to move before 1.0, so that nobody builds on them by accident:

  • AppConfig carries APP_NAME, APP_URL, APP_ENV and APP_PORT. APP_PORT, APP_URL and APP_NAME are read by the framework as of 0.1.1; APP_ENV is deliberately read by nothing, because a protection an operator can switch off with an environment variable is not one.
  • arcature::test_kit, uag and oauth are the youngest subsystems and the least exercised by real applications, so their surface is the most likely to move.

Anything still unbuilt is marked “Not yet implemented” in the chapter that would otherwise document it. Nothing in this guide shows an example that does not compile today.

Upgrading the toolchain

rust-toolchain.toml pins stable with rustfmt and clippy. The MSRV is 1.97.1 and CI builds on both it and current stable, so an MSRV bump is a visible change to that file and to the CI matrix, not a silent consequence of using a new language feature. Arcature uses edition 2024.

Decisions

Some of Arcature’s shapes are surprising enough to be worth a written record. Each one states the decision, the context that forced it, and the cost paid. They live in docs/decisions/ in the repository.

The project

The rest of this guide is about the framework. This page is about the project around it: where to ask a question, how a change gets in, who decides, and what someone who finds a vulnerability is promised in return.

The five documents live in .github/ in the repository, one of the three directories GitHub looks in for them.

  • Support. Four doors – bug report, feature request, discussion, private security report – and which one you want depends on what you have. Reading this first is faster than waiting for someone to move your issue.
  • Contributing. The build, test and lint gates, what a change is expected to look like, and the shape of commits and releases. Arcature is one crate with an opinion; this says which contributions sharpen it and which do not.
  • Security policy. Private reporting through the repository’s Security tab, which versions get fixes, what is in scope, and the response targets a reporter can hold the project to.
  • Governance. Arcature has one maintainer, and this says what follows from that – including what happens to the project if that person stops. A bus factor stated plainly is easier to depend on than an org chart nobody staffs.
  • Code of conduct. The Contributor Covenant, and the mailbox that enforces it.