arcature 0.1.0

Arcature: an opinionated full-stack Rust web framework. One package, batteries included.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# Arcature

[![CI](https://github.com/ArcatureLabs/Arcature/actions/workflows/ci.yml/badge.svg)](https://github.com/ArcatureLabs/Arcature/actions/workflows/ci.yml)
[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
[![Rust 1.97.1+](https://img.shields.io/badge/rust-1.97.1%2B-orange.svg)](rust-toolchain.toml)

An opinionated full-stack Rust web framework. One package, batteries included.

Arcature integrates proven wheels -- Axum, Tower, Tokio, SeaORM, SQLx,
Inertia.js, OpenDAL, lettre, tracing -- and owns what sits between them: the
application lifecycle, the request pipeline order, the conventions, and a
coherent vocabulary. The raw Axum, Tower, SeaORM and SQLx escape hatches stay
available for when the framework's opinions run out.

**Status: pre-release.** `main` breaks without notice, and `0.x` says so
deliberately. The `2026.x` versions on crates.io are from an abandoned
predecessor repository, are yanked, and share nothing with this one but the
name -- see [Versioning](#versioning). Where a subsystem is
narrower than its name suggests, that is said in
[the guide](docs/src/SUMMARY.md) and summarised under
[What is not built yet](#what-is-not-built-yet).

## Install

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

Pin a revision -- a branch reference will move under you. Once Arcature is
published, `cargo add arcature` will be the whole install.

Requirements: Rust **1.97.1** or newer (edition 2024). Anything that uses the
database or the job queue needs one of PostgreSQL 17, MySQL 8 or SQLite --
picked at build time, one per build.

## Quick start

```rust
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"))
}
```

`.build()` turns the `ApplicationBuilder` into an `Application`; `run()` lives
on the latter. `run()` returns `EngineResult<()>` -- engine failures (a bound
port, a database that will not connect) are a different kind of failure from a
handler's, and deliberately do not share an error type with `Result<Response>`.

To scaffold a whole Laravel-shaped project instead:

```sh
cargo install --git https://github.com/ArcatureLabs/Arcature arcature --features cli
arc new my-app
cd my-app
cargo run
```

## What Arcature is

One package. One release unit. One version. Features exist only to reduce
compile surface, never to turn the framework into a self-assembly kit. The
default feature set compiles the generated application with no extra flags.

`#![forbid(unsafe_code)]` applies to the whole crate.

Two decisions shape everything else:

**No hidden registry.** No `inventory`, no `linkme`, no `TypeId` map, no
thread-locals. All framework metadata is `&'static` const data emitted by
macros and named by code you wrote, so `cargo expand` and "go to definition"
are enough to find out what is wired up.

**No npm package.** Arcature publishes no JavaScript. Applications use the
official `@inertiajs/*` adapters, and everything the Rust side hands the
browser travels as generated `.ts` files in the application's own tree rather
than through a framework runtime behind a virtual module.

## The request pipeline

Layer order is a contract, written down in `src/application/pipeline.rs` and
asserted by the test suite. `.inertia()` before `.csrf()` and `.csrf()` before
`.inertia()` produce the same pipeline. Outermost first:

```text
 1 DevProxy      7 CORS          13 Timeout       19 PageContracts
 2 Proxy         8 RequestId     14 Maintenance   20 RedirectMapper
 3 Health        9 AccessLog     15 RateLimit     21 user .layer()s
 4 UagEndpoint  10 CatchPanic    16 Session       22 Router
 5 Compression  11 ErrorMapping  17 CSRF          23 StaticFiles
 6 SecurityHdrs 12 BodyLimit     18 Inertia
```

Stages 5 through 21 are off unless asked for, with one exception: stage 20 is
on unless refused, because `redirect().route(..)` silently doing nothing is a
worse default than one extension lookup per response. The reasoning for each
position is in the module documentation and in
[ADR 0004](docs/decisions/0004-layer-order-contract.md).

## Architecture

| Subsystem | Built on | Feature |
|---|---|---|
| HTTP routing | Axum 0.8, Tower 0.5, tower-http 0.6 | always on |
| Async runtime, `#[arcature::main]` | Tokio 1.53 | `macros` |
| The DSL and its runtime contracts | -- | `dx` |
| Native Inertia.js v3 (server half) | the protocol, implemented directly | `inertia` |
| Database | SeaORM 2.0 + SQLx 0.9 over one pool | `database` + one `db-*` |
| Auth, sessions, CSRF, policies | argon2, tower-sessions, secrecy | `auth` |
| Validation | validator 0.21 | `validation` |
| Cache | Redis/Valkey (redis 1.5) | `cache` |
| Storage | OpenDAL 0.58 | `storage-fs`, `storage-s3` |
| Mail | lettre 0.11 (rustls) | `mail` |
| Jobs | Database-backed queue, one claim strategy per dialect | `jobs` |
| Events | in-process dispatch | `events` |
| Realtime | WebSocket + SSE over axum | `realtime` |
| Problem Details (RFC 9457), OpenAPI | -- | `api` |
| Observability | tracing, request ids, JSON logs, Prometheus text, W3C trace context | `observe` |
| Static pages and assets | tower-http `fs` | `pages` |
| The `arc` CLI and templates | clap 4 | `cli`, `templates` |

Operator opt-ins stay off by default: `otel` (OpenTelemetry over OTLP),
`api-docs` (an interactive API reference is a map of the attack surface),
`oauth`, `storage-s3`, `dev-proxy`, `uag`, `test-kit`.

Database drivers are separate features so a SQLite user does not compile the
PostgreSQL protocol. `database` on its own brings the crates but no driver;
exactly one of `db-postgres` / `db-sqlite` / `db-mysql` belongs in a build.
`default` picks `db-postgres`.

### Versioning

Arcature follows semantic versioning. Current version `0.1.0`, readable as
`arcature::FRAMEWORK_VERSION`.

Being in `0.x` shifts SemVer one field left, and Cargo agrees: the breaking
bump is the minor (`0.1` -> `0.2`), the compatible one is the patch. So
`arcature = "0.1"` takes patches and stops at `0.2`, and no exact pin is
needed to stay safe. The public API is not frozen -- that is what `0.x` is
for -- so read the changelog before a minor bump.

`0.1.0` is the first release of this codebase and it restarts the numbering.
crates.io also serves `arcature 2026.0.0` through `2026.2.1`, published from
the predecessor repository this one replaces; those are yanked and are not an
earlier version of what is documented here. A yank only withdraws a version
from new resolution, so anything already pinned to `2026.x` keeps building.

## A tour

### Routes

```rust
use arcature::prelude::*;

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

Named routes generate URLs through `Routes::url_for("links.show", &["7"])`,
which returns `Err(Error::NotFound(..))` for a name that is not in the table.
Paths use Axum 0.8 syntax (`{id}`, not `:id`).

### Requests with validation

```rust
use arcature::prelude::*;

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

pub async fn store(input: Validated<StoreLinkRequest>) -> Result<Response> {
    let request = input.into_inner();
    Ok(json(&request.title))
}
```

You write the `Deserialize` derive yourself; `#[arcature::request]` adds the
`Validate` derive and the marker trait, and must come after the derives. The
attribute on each field is `#[validate(...)]` -- validator's, not a
framework-specific one. A failure is a `422` with an RFC 9457 problem document
carrying an `errors` extension.

### Controllers

```rust
use arcature::database::QueryModel;
use arcature::prelude::*;

pub struct LinksController;

#[arcature::controller]
impl LinksController {
    pub async fn index(State(state): State<AppState>) -> Result<Response> {
        let db = state.db.as_ref().ok_or_else(|| not_found("no database"))?;
        let links = link::Entity::query(db).latest().limit(20).all().await?;
        Ok(json(&links))
    }
}
```

Every method must be `pub`, `async`, take no `self`, and declare a return
type; the macro rejects anything else with `error[ARC-M004]`. `json` takes one
argument -- the value -- and always answers `200`. Use `text(status, body)`
when the status matters.

`Db` is not an Axum extractor. It comes out of your state, which is why the
example above reaches through `State<AppState>`.

### Inertia pages

```rust
pub async fn index(inertia: Inertia) -> Result<Response> {
    let links: Vec<LinkResource> = Vec::new();
    inertia!("links/index", { links })
}
```

The `inertia!` macro requires a binding literally named `inertia` in scope.
The Client Exposure Firewall makes browser exposure opt-in: a type reaches the
browser only by being a `ClientData`, which `#[page]` and `#[resource]`
generate. Nesting a non-`ClientData` type inside one fails to compile.

### Auth

```rust
use arcature::prelude::*;

pub async fn login(
    auth: AuthManager<User>,
    input: Validated<LoginRequest>,
) -> Result<Response> {
    let request = input.into_inner();
    let user = find_user(&request.email).await?;
    auth.login(&user).await?;
    Ok(redirect().to("/dashboard").into_response())
}

pub async fn dashboard(Auth(user): Auth<User>) -> Result<Response> {
    Ok(json(&user.email))
}
```

`login` takes the user by reference and rotates the session id before binding
it -- session-fixation defence that is mandatory rather than opt-in. `Auth<U>`
is the extractor for the current user (`OptionalAuth<U>` when absent is fine);
loading the user from its id is your `UserLoader` impl, so the framework never
guesses how your users are stored.

Authorization is a separate step: `auth.authorize::<Link, LinkPolicy>("update", &link)?`.
Both type parameters are required.

### Jobs

```rust
use arcature::Job;
use arcature::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Job)]
#[job(attempts = 5)]
pub struct SendWelcomeEmail {
    pub user_id: i64,
}

// Enqueue.
jobs.enqueue(&JobRequest::new(&SendWelcomeEmail::JOB, &payload)?).await?;

// Register the handler. `add` takes `&mut self`.
let mut registry = Registry::new();
registry.add(&SendWelcomeEmail::JOB, |job: SendWelcomeEmail| async move {
    Ok(())
})?;
```

The derive is `arcature::Job`, imported explicitly: the prelude cannot glob it
in, because the derive and the `Job` trait share a name in the type namespace.

The queue runs over the pool the application already has -- no broker to run.
Delivery is at-least-once, and each claim carries a UUID fencing token so a
worker whose lease expired cannot complete a job another worker has since
taken.

Claiming a job without two workers taking the same one is the part no dialect
does the same way, so `src/jobs/dialect/` has one module each. PostgreSQL
claims with `UPDATE .. RETURNING` over `FOR UPDATE SKIP LOCKED`; MySQL 8 has
`SKIP LOCKED` but no `RETURNING`, so it picks then marks; SQLite has neither
and serialises on `BEGIN IMMEDIATE`. The three are not pretending to be the
same implementation, and SQLite's is a single-writer design by construction --
fine for one process, not a fleet.

### Events

```rust
use arcature::Event;
use arcature::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Event)]
pub struct UserRegistered {
    pub user_id: i64,
    pub email: String,
}

let dispatcher = Dispatcher::new()
    .register(|event: UserRegistered| async move { Ok(()) });

dispatcher
    .dispatch(&UserRegistered { user_id: 1, email: "a@b.com".into() })
    .await?;
```

In-process and not durable. Events cross the listener boundary as
`serde_json::Value` rather than through a `TypeId` map, which is what keeps
the no-hidden-registry rule intact; the cost is a serialise/deserialise round
trip per listener.

### Cache

`Cache` is a value you hold, not a namespace of static functions:

```rust
use std::time::Duration;

let users = cache
    .remember("users:all", Duration::from_secs(300), || async {
        load_users().await
    })
    .await?;
```

A miss is not an error, but a backend failure is -- and it does not run the
loader. There is no silent fail-open. The loader's error type only has to be
`Into<CacheError>`.

### Storage

`disk` is an instance method on a connected `Storage`, and every data-path
method takes a validated `StoragePath`:

```rust
use arcature::prelude::*;

let storage = Storage::connect(StorageConfig::fs("storage/app")?).await?;
let path = StoragePath::new("avatars/1.png")?;

storage.disk("default").put(&path, b"...").await?;
let data = storage.disk("default").get(&path).await?;
```

`StoragePath::new` rejects traversal, absolute paths and empty segments, so a
user-supplied filename cannot escape the disk root. `disk(name)` panics for a
name that was never registered -- a typo is a bug, not a runtime branch; use
`try_disk` when the name is genuinely dynamic.

### Mail

`Mail` is also a value: a `Mailer` plus a `From` address.

```rust
use arcature::mail::lettre::message::Message;
use arcature::mail::{Email, EmailError, Mailable};
use arcature::prelude::*;

pub struct WelcomeEmail;

impl Mailable for WelcomeEmail {
    fn build(&self, email: Email) -> Result<Message, EmailError> {
        email.subject("Welcome").html("<h1>Welcome</h1>")
    }
}

let mail = Mail::from_str(mailer, "noreply@example.com")?;
mail.to("user@example.com").send(&WelcomeEmail).await?;
```

`Mail::send` hands your `Mailable` an `Email` builder with `From` and `To`
already set. On the builder, only the body terminators -- `plain`, `html`,
`alternative`, `plain_with_attachments` and `alternative_with_attachments` --
return a `Result`; everything before them is infallible. SMTP credentials have a
`Debug` that prints the type name and no `Display` at all, so they cannot be
logged by accident.

## The `arc` CLI

| Command | Does |
|---|---|
| `arc new <name>` | Scaffold an application (`--stack`, `--db`, `--dest`). |
| `arc serve` | Run the application (`--bind`, `--port`). |
| `arc migrate` | Run pending migrations. |
| `arc schedule` | Run the scheduler. |
| `arc make:<kind> <name>` | Generate one artifact. 16 kinds: controller, model, migration, request, resource, policy, service, job, event, listener, middleware, command, page, test, factory, seeder. |
| `arc key:generate` | Generate the session key. |
| `arc storage:link` | Link `public/storage` to the local disk. |
| `arc db:seed`, `db:fresh`, `db:reset` | Database lifecycle. |
| `arc queue work\|drain\|stats` | Drive the job queue. |
| `arc doctor` | Check the environment. |
| `arc version` | Print the version. |

`arc dev`, `arc typegen` and `arc build` parse and report that they are not
wired yet. They are declared rather than hidden so `arc --help` shows the real
surface and a typo suggests the right name.

## Documentation

The guide lives in [`docs/`](docs/) and builds with mdBook:

```sh
cargo install mdbook
mdbook serve docs
```

Chapters: getting started, routing, controllers, validation, Inertia,
database, cache, storage, auth, jobs, events, mail, testing, deployment,
upgrading.

The decisions that are surprising enough to need a written record are in
[`docs/decisions/`](docs/decisions/), each one page, each stating the decision,
the context, and the cost paid:

- [No npm package]docs/decisions/0001-no-npm-package.md
- [The CSRF cookie is `XSRF-TOKEN`, not `__Host-csrf`]docs/decisions/0002-xsrf-token-cookie.md
- [Exactly one TCP port, in development too]docs/decisions/0003-one-tcp-port.md
- [Layer order is a written contract]docs/decisions/0004-layer-order-contract.md
- [There is no hidden registry]docs/decisions/0005-no-hidden-registry.md

## What is not built yet

Nothing on this list. Every surface the guide documents is wired to something
that reads it; where a surface is narrower than its name suggests, the
narrowing is written into its own documentation rather than tracked here.

The nearest thing to an exception is `AppConfig`: `port` is consumed, and
`name`, `url` and `env` are carried and readable but read by no framework
code -- no framework surface builds an absolute URL yet, and `env` is barred
by design from gating behaviour. That is stated on the type, and in
[the deployment chapter](docs/src/deployment.md).

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for the build, test and lint gates, and
[SECURITY.md](SECURITY.md) for reporting a vulnerability privately. Changes are
recorded in [CHANGELOG.md](CHANGELOG.md).

The gates in one line each:

```sh
just check    # cargo check --all-targets
just fmt      # cargo fmt --all
just lint     # fmt --check, then clippy --all-targets -D warnings
just test     # cargo test
just features # the cargo-hack feature matrix CI runs
just docs     # cargo doc --no-deps --features fullstack
just ci       # everything CI runs, in CI's order
```

## License

Apache-2.0. See [LICENSE](LICENSE).